diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 4fc5c034d19..4c20e77c286 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -67,6 +67,14 @@ jobs: bin/console doctrine:schema:create bin/console eccube:fixtures:load + # Agent Commerce (#6794) の UCP スキーマ契約テスト用に UCP 公式 schema を取得する。 + # リポジトリには同梱しない (Apache-2.0)。docblock の参照バージョンと一致するリリースタグ + # v2026-04-08 に固定し、var/ (gitignore) へ clone する。未取得環境では該当テストは skip。 + - name: Fetch UCP spec schemas (tag v2026-04-08) + run: | + git clone --filter=blob:none --branch v2026-04-08 --single-branch --quiet \ + https://github.com/Universal-Commerce-Protocol/ucp.git var/agent-commerce-spec/ucp + - name: PHPUnit id: phpunit env: diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c8f74c9957f..e74c3db0f3d 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -111,6 +111,14 @@ jobs: bin/console doctrine:schema:create bin/console eccube:fixtures:load + # Agent Commerce (#6794) の UCP スキーマ契約テスト用に UCP 公式 schema を取得する。 + # リポジトリには同梱しない (Apache-2.0)。docblock の参照バージョンと一致するリリースタグ + # v2026-04-08 に固定し、var/ (gitignore) へ clone する。未取得環境では該当テストは skip。 + - name: Fetch UCP spec schemas (tag v2026-04-08) + run: | + git clone --filter=blob:none --branch v2026-04-08 --single-branch --quiet \ + https://github.com/Universal-Commerce-Protocol/ucp.git var/agent-commerce-spec/ucp + - name: PHPUnit uses: nick-invision/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: diff --git a/.gitignore b/.gitignore index 52dcccb2915..8338e814354 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules !/app/Plugin/.gitkeep /app/PluginData/* !/app/PluginData/.gitkeep +/app/keystore/* +!/app/keystore/.gitkeep +!/app/keystore/.htaccess /app/template/* !/app/template/admin !/app/template/default diff --git a/.htaccess b/.htaccess index 4439047aca8..015024cf20f 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ DirectoryIndex index.php index.html .ht Allow from all - + order allow,deny deny from all diff --git a/app/DoctrineMigrations/Version20260604120000.php b/app/DoctrineMigrations/Version20260604120000.php new file mode 100644 index 00000000000..58a0f48dabe --- /dev/null +++ b/app/DoctrineMigrations/Version20260604120000.php @@ -0,0 +1,56 @@ + alpha-2) の初期データを投入する. + * + * テーブル自体は doctrine:schema:update でエンティティから生成される。 + * 新規インストールは import_csv (definition.yml) で投入されるため、 + * 本マイグレーションは既存インストールのアップグレード時の backfill を担う + * (空テーブルのときのみ INSERT する冪等実装)。 + */ +final class Version20260604120000 extends AbstractMigration +{ + public const NAME = 'mtb_country_iso_code'; + + public function up(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.self::NAME); + if ($count > 0) { + return; + } + + $this->addSql("INSERT INTO mtb_country_iso_code (id, name, sort_no, discriminator_type) VALUES (352, 'IS', 1, 'countryisocode'), (372, 'IE', 2, 'countryisocode'), (31, 'AZ', 3, 'countryisocode'), (4, 'AF', 4, 'countryisocode'), (840, 'US', 5, 'countryisocode'), (850, 'VI', 6, 'countryisocode'), (16, 'AS', 7, 'countryisocode'), (784, 'AE', 8, 'countryisocode'), (12, 'DZ', 9, 'countryisocode'), (32, 'AR', 10, 'countryisocode'), (533, 'AW', 11, 'countryisocode'), (8, 'AL', 12, 'countryisocode'), (51, 'AM', 13, 'countryisocode'), (660, 'AI', 14, 'countryisocode'), (24, 'AO', 15, 'countryisocode'), (28, 'AG', 16, 'countryisocode'), (20, 'AD', 17, 'countryisocode'), (887, 'YE', 18, 'countryisocode'), (826, 'GB', 19, 'countryisocode'), (86, 'IO', 20, 'countryisocode'), (92, 'VG', 21, 'countryisocode'), (376, 'IL', 22, 'countryisocode'), (380, 'IT', 23, 'countryisocode'), (368, 'IQ', 24, 'countryisocode'), (364, 'IR', 25, 'countryisocode'), (356, 'IN', 26, 'countryisocode'), (360, 'ID', 27, 'countryisocode'), (876, 'WF', 28, 'countryisocode'), (800, 'UG', 29, 'countryisocode'), (804, 'UA', 30, 'countryisocode'), (860, 'UZ', 31, 'countryisocode'), (858, 'UY', 32, 'countryisocode'), (218, 'EC', 33, 'countryisocode'), (818, 'EG', 34, 'countryisocode'), (233, 'EE', 35, 'countryisocode'), (231, 'ET', 36, 'countryisocode'), (232, 'ER', 37, 'countryisocode'), (222, 'SV', 38, 'countryisocode'), (36, 'AU', 39, 'countryisocode'), (40, 'AT', 40, 'countryisocode'), (248, 'AX', 41, 'countryisocode'), (512, 'OM', 42, 'countryisocode'), (528, 'NL', 43, 'countryisocode'), (288, 'GH', 44, 'countryisocode'), (132, 'CV', 45, 'countryisocode'), (831, 'GG', 46, 'countryisocode'), (328, 'GY', 47, 'countryisocode'), (398, 'KZ', 48, 'countryisocode'), (634, 'QA', 49, 'countryisocode'), (581, 'UM', 50, 'countryisocode'), (124, 'CA', 51, 'countryisocode'), (266, 'GA', 52, 'countryisocode'), (120, 'CM', 53, 'countryisocode'), (270, 'GM', 54, 'countryisocode'), (116, 'KH', 55, 'countryisocode'), (580, 'MP', 56, 'countryisocode'), (324, 'GN', 57, 'countryisocode'), (624, 'GW', 58, 'countryisocode'), (196, 'CY', 59, 'countryisocode'), (192, 'CU', 60, 'countryisocode'), (531, 'CW', 61, 'countryisocode'), (300, 'GR', 62, 'countryisocode'), (296, 'KI', 63, 'countryisocode'), (417, 'KG', 64, 'countryisocode'), (320, 'GT', 65, 'countryisocode'), (312, 'GP', 66, 'countryisocode'), (316, 'GU', 67, 'countryisocode'), (414, 'KW', 68, 'countryisocode'), (184, 'CK', 69, 'countryisocode'), (304, 'GL', 70, 'countryisocode'), (162, 'CX', 71, 'countryisocode'), (268, 'GE', 72, 'countryisocode'), (308, 'GD', 73, 'countryisocode'), (191, 'HR', 74, 'countryisocode'), (136, 'KY', 75, 'countryisocode'), (404, 'KE', 76, 'countryisocode'), (384, 'CI', 77, 'countryisocode'), (166, 'CC', 78, 'countryisocode'), (188, 'CR', 79, 'countryisocode'), (174, 'KM', 80, 'countryisocode'), (170, 'CO', 81, 'countryisocode'), (178, 'CG', 82, 'countryisocode'), (180, 'CD', 83, 'countryisocode'), (682, 'SA', 84, 'countryisocode'), (239, 'GS', 85, 'countryisocode'), (882, 'WS', 86, 'countryisocode'), (678, 'ST', 87, 'countryisocode'), (652, 'BL', 88, 'countryisocode'), (894, 'ZM', 89, 'countryisocode'), (666, 'PM', 90, 'countryisocode'), (674, 'SM', 91, 'countryisocode'), (663, 'MF', 92, 'countryisocode'), (694, 'SL', 93, 'countryisocode'), (262, 'DJ', 94, 'countryisocode'), (292, 'GI', 95, 'countryisocode'), (832, 'JE', 96, 'countryisocode'), (388, 'JM', 97, 'countryisocode'), (760, 'SY', 98, 'countryisocode'), (702, 'SG', 99, 'countryisocode'), (534, 'SX', 100, 'countryisocode'), (716, 'ZW', 101, 'countryisocode'), (756, 'CH', 102, 'countryisocode'), (752, 'SE', 103, 'countryisocode'), (729, 'SD', 104, 'countryisocode'), (744, 'SJ', 105, 'countryisocode'), (724, 'ES', 106, 'countryisocode'), (740, 'SR', 107, 'countryisocode'), (144, 'LK', 108, 'countryisocode'), (703, 'SK', 109, 'countryisocode'), (705, 'SI', 110, 'countryisocode'), (748, 'SZ', 111, 'countryisocode'), (690, 'SC', 112, 'countryisocode'), (226, 'GQ', 113, 'countryisocode'), (686, 'SN', 114, 'countryisocode'), (688, 'RS', 115, 'countryisocode'), (659, 'KN', 116, 'countryisocode'), (670, 'VC', 117, 'countryisocode'), (426, 'LS', 118, 'countryisocode'), (654, 'SH', 119, 'countryisocode'), (662, 'LC', 120, 'countryisocode'), (706, 'SO', 121, 'countryisocode'), (90, 'SB', 122, 'countryisocode'), (796, 'TC', 123, 'countryisocode'), (764, 'TH', 124, 'countryisocode'), (410, 'KR', 125, 'countryisocode'), (158, 'TW', 126, 'countryisocode'), (762, 'TJ', 127, 'countryisocode'), (834, 'TZ', 128, 'countryisocode'), (203, 'CZ', 129, 'countryisocode'), (148, 'TD', 130, 'countryisocode'), (140, 'CF', 131, 'countryisocode'), (156, 'CN', 132, 'countryisocode'), (788, 'TN', 133, 'countryisocode'), (408, 'KP', 134, 'countryisocode'), (152, 'CL', 135, 'countryisocode'), (798, 'TV', 136, 'countryisocode'), (208, 'DK', 137, 'countryisocode'), (276, 'DE', 138, 'countryisocode'), (768, 'TG', 139, 'countryisocode'), (772, 'TK', 140, 'countryisocode'), (214, 'DO', 141, 'countryisocode'), (212, 'DM', 142, 'countryisocode'), (780, 'TT', 143, 'countryisocode'), (795, 'TM', 144, 'countryisocode'), (792, 'TR', 145, 'countryisocode'), (776, 'TO', 146, 'countryisocode'), (566, 'NG', 147, 'countryisocode'), (520, 'NR', 148, 'countryisocode'), (516, 'NA', 149, 'countryisocode'), (10, 'AQ', 150, 'countryisocode'), (570, 'NU', 151, 'countryisocode'), (558, 'NI', 152, 'countryisocode'), (562, 'NE', 153, 'countryisocode'), (392, 'JP', 154, 'countryisocode'), (732, 'EH', 155, 'countryisocode'), (540, 'NC', 156, 'countryisocode'), (554, 'NZ', 157, 'countryisocode'), (524, 'NP', 158, 'countryisocode'), (574, 'NF', 159, 'countryisocode'), (578, 'NO', 160, 'countryisocode'), (334, 'HM', 161, 'countryisocode'), (48, 'BH', 162, 'countryisocode'), (332, 'HT', 163, 'countryisocode'), (586, 'PK', 164, 'countryisocode'), (336, 'VA', 165, 'countryisocode'), (591, 'PA', 166, 'countryisocode'), (548, 'VU', 167, 'countryisocode'), (44, 'BS', 168, 'countryisocode'), (598, 'PG', 169, 'countryisocode'), (60, 'BM', 170, 'countryisocode'), (585, 'PW', 171, 'countryisocode'), (600, 'PY', 172, 'countryisocode'), (52, 'BB', 173, 'countryisocode'), (275, 'PS', 174, 'countryisocode'), (348, 'HU', 175, 'countryisocode'), (50, 'BD', 176, 'countryisocode'), (626, 'TL', 177, 'countryisocode'), (612, 'PN', 178, 'countryisocode'), (242, 'FJ', 179, 'countryisocode'), (608, 'PH', 180, 'countryisocode'), (246, 'FI', 181, 'countryisocode'), (64, 'BT', 182, 'countryisocode'), (74, 'BV', 183, 'countryisocode'), (630, 'PR', 184, 'countryisocode'), (234, 'FO', 185, 'countryisocode'), (238, 'FK', 186, 'countryisocode'), (76, 'BR', 187, 'countryisocode'), (250, 'FR', 188, 'countryisocode'), (254, 'GF', 189, 'countryisocode'), (258, 'PF', 190, 'countryisocode'), (260, 'TF', 191, 'countryisocode'), (100, 'BG', 192, 'countryisocode'), (854, 'BF', 193, 'countryisocode'), (96, 'BN', 194, 'countryisocode'), (108, 'BI', 195, 'countryisocode'), (704, 'VN', 196, 'countryisocode'), (204, 'BJ', 197, 'countryisocode'), (862, 'VE', 198, 'countryisocode'), (112, 'BY', 199, 'countryisocode'), (84, 'BZ', 200, 'countryisocode'), (604, 'PE', 201, 'countryisocode'), (56, 'BE', 202, 'countryisocode'), (616, 'PL', 203, 'countryisocode'), (70, 'BA', 204, 'countryisocode'), (72, 'BW', 205, 'countryisocode'), (535, 'BQ', 206, 'countryisocode'), (68, 'BO', 207, 'countryisocode'), (620, 'PT', 208, 'countryisocode'), (344, 'HK', 209, 'countryisocode'), (340, 'HN', 210, 'countryisocode'), (584, 'MH', 211, 'countryisocode'), (446, 'MO', 212, 'countryisocode'), (807, 'MK', 213, 'countryisocode'), (450, 'MG', 214, 'countryisocode'), (175, 'YT', 215, 'countryisocode'), (454, 'MW', 216, 'countryisocode'), (466, 'ML', 217, 'countryisocode'), (470, 'MT', 218, 'countryisocode'), (474, 'MQ', 219, 'countryisocode'), (458, 'MY', 220, 'countryisocode'), (833, 'IM', 221, 'countryisocode'), (583, 'FM', 222, 'countryisocode'), (710, 'ZA', 223, 'countryisocode'), (728, 'SS', 224, 'countryisocode'), (104, 'MM', 225, 'countryisocode'), (484, 'MX', 226, 'countryisocode'), (480, 'MU', 227, 'countryisocode'), (478, 'MR', 228, 'countryisocode'), (508, 'MZ', 229, 'countryisocode'), (492, 'MC', 230, 'countryisocode'), (462, 'MV', 231, 'countryisocode'), (498, 'MD', 232, 'countryisocode'), (504, 'MA', 233, 'countryisocode'), (496, 'MN', 234, 'countryisocode'), (499, 'ME', 235, 'countryisocode'), (500, 'MS', 236, 'countryisocode'), (400, 'JO', 237, 'countryisocode'), (418, 'LA', 238, 'countryisocode'), (428, 'LV', 239, 'countryisocode'), (440, 'LT', 240, 'countryisocode'), (434, 'LY', 241, 'countryisocode'), (438, 'LI', 242, 'countryisocode'), (430, 'LR', 243, 'countryisocode'), (642, 'RO', 244, 'countryisocode'), (442, 'LU', 245, 'countryisocode'), (646, 'RW', 246, 'countryisocode'), (422, 'LB', 247, 'countryisocode'), (638, 'RE', 248, 'countryisocode'), (643, 'RU', 249, 'countryisocode')"); + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $this->addSql('DELETE FROM mtb_country_iso_code'); + } +} diff --git a/app/config/eccube/packages/eccube_nav.yaml b/app/config/eccube/packages/eccube_nav.yaml index e83df1b6ee3..b6117e24772 100644 --- a/app/config/eccube/packages/eccube_nav.yaml +++ b/app/config/eccube/packages/eccube_nav.yaml @@ -85,6 +85,9 @@ parameters: maintenance: name: admin.content.maintenance_management url: admin_content_maintenance + agent_commerce: + name: admin.content.agent_commerce + url: admin_content_agent_commerce setting: name: admin.setting icon: fa-cog diff --git a/app/config/eccube/packages/framework.yaml b/app/config/eccube/packages/framework.yaml index 61de8a42dba..577f99516e4 100644 --- a/app/config/eccube/packages/framework.yaml +++ b/app/config/eccube/packages/framework.yaml @@ -49,6 +49,9 @@ framework: # to avoid collisions when multiple apps share the same cache backend (e.g. a Redis server) # See https://symfony.com/doc/current/reference/configuration/framework.html#prefix-seed prefix_seed: ec-cube + # Lock factory. flock store works on shared hosting without external infra. + # Used by Agent Commerce (UCP Catalog) cache stampede prevention. + lock: flock # The 'ide' option turns all of the file paths in an exception page # into clickable links that open the given file using your favorite IDE. # When 'ide' is set to null the file is opened in your web browser. diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ebdebaa9853..61300304191 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -11,6 +11,10 @@ parameters: locale: '%env(ECCUBE_LOCALE)%' timezone: '%env(ECCUBE_TIMEZONE)%' currency: '%env(ECCUBE_CURRENCY)%' + # Agent Commerce (#6794): 未設定時は空文字を既定とする (string 型注入で null TypeError を避ける) + env(ECCUBE_AGENT_COMMERCE_UCP_SIGNING_KEY): '' + env(ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL): '' + env(ECCUBE_AGENT_COMMERCE_ACP_FEED_API_KEY): '' services: # default configuration for services in *this* file @@ -237,3 +241,50 @@ services: eccube.asset.user_data_version_strategy: alias: Eccube\Asset\FilemtimeVersionStrategy public: true + + # Agent Commerce (ACP / UCP) 共通基盤 + Eccube\Service\AgentCommerce\Security\FilesystemKeyStore: + arguments: + $projectDir: '%kernel.project_dir%' + $envPathOverrides: + ucp_signing: '%env(ECCUBE_AGENT_COMMERCE_UCP_SIGNING_KEY)%' + + Eccube\Service\AgentCommerce\Security\KeyStoreInterface: + alias: Eccube\Service\AgentCommerce\Security\FilesystemKeyStore + + Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: + alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner + + # Agent Commerce: Product Feed / Catalog 共通基盤 (#6794) + Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolverInterface: + alias: Eccube\Service\AgentCommerce\Catalog\ProductReferenceResolver + + Eccube\Service\AgentCommerce\Catalog\CatalogProviderInterface: + alias: Eccube\Service\AgentCommerce\Catalog\CatalogProvider + + # Agent Commerce: ACP Product Feed (push) (#6794) + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedValidator: + arguments: + $schemaPath: '%kernel.project_dir%/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json' + + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient: + arguments: + $baseUrl: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL)%' + $apiKey: '%env(ECCUBE_AGENT_COMMERCE_ACP_FEED_API_KEY)%' + + Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClientInterface: + alias: Eccube\Service\AgentCommerce\Catalog\Acp\AcpFeedClient + + # Agent Commerce: UCP Catalog (pull / REST) (#6794) + Eccube\Service\AgentCommerce\Catalog\Ucp\UcpCatalogCache: + arguments: + $cacheDir: '%kernel.cache_dir%' + + # Agent Commerce: UCP Discovery (/.well-known/ucp) (#6794 / #6777) + # payment_handlers は決済ハンドラプラグインが tagged service で寄与する (既定は空 {}). + Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry: + arguments: + $registries: !tagged_iterator eccube.agent_commerce.payment_handler_registry + + Eccube\Service\AgentCommerce\Discovery\PaymentHandlerRegistryInterface: + alias: Eccube\Service\AgentCommerce\Discovery\EmptyPaymentHandlerRegistry diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index ea2133e30e6..9e0513a6ba9 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -77,3 +77,7 @@ services: Eccube\Service\Composer\ComposerApiService: autowire: true public: true + # AddressMappingService はまだ consumer が無く private では除去されるためテスト時のみ public 化 + Eccube\Service\AgentCommerce\AddressMappingService: + autowire: true + public: true diff --git a/app/keystore/.gitkeep b/app/keystore/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/keystore/.htaccess b/app/keystore/.htaccess new file mode 100644 index 00000000000..baa56e5a369 --- /dev/null +++ b/app/keystore/.htaccess @@ -0,0 +1,2 @@ +order allow,deny +deny from all \ No newline at end of file diff --git a/composer.json b/composer.json index fc737569d9d..f2f875e3b99 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "symfony/flex": "^2.7", "symfony/form": "^7.4", "symfony/framework-bundle": "^7.4", + "symfony/http-client": "^7.4", "symfony/http-foundation": "^7.4", "symfony/http-kernel": "^7.4", "symfony/intl": "^7.4", diff --git a/composer.lock b/composer.lock index 337df74fe16..4d397c86efc 100644 --- a/composer.lock +++ b/composer.lock @@ -7541,6 +7541,189 @@ ], "time": "2026-03-30T12:55:43+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:50+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.13", diff --git a/src/Eccube/Command/AgentCommerce/AcpFeedPushCommand.php b/src/Eccube/Command/AgentCommerce/AcpFeedPushCommand.php new file mode 100644 index 00000000000..bd6c1466fed --- /dev/null +++ b/src/Eccube/Command/AgentCommerce/AcpFeedPushCommand.php @@ -0,0 +1,105 @@ +addOption('full', null, InputOption::VALUE_NONE, 'Perform a full replacement push (products.jsonl + metadata.json) instead of a differential upsert') + ->addOption('feed-id', null, InputOption::VALUE_REQUIRED, 'The target ACP feed id. For --full this is the feed metadata id; for upsert it is the existing feed id'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('ACP Product Feed Push'); + + // 認証情報 (base URL + API key) 未設定時は AcpFeedClient が AcpFeedException を投げる + // (専用の有効化フラグは持たない。Bearer 等のシークレットは出力しない)。 + $feedId = $input->getOption('feed-id'); + if (!\is_string($feedId) || $feedId === '') { + $io->error('The --feed-id option is required.'); + + return Command::INVALID; + } + + $full = (bool) $input->getOption('full'); + + try { + if ($full) { + $io->section('Full replacement push'); + $io->text('Generating products.jsonl and metadata.json, then registering the feed...'); + $result = $this->feedClient->pushFullReplacement($feedId); + $io->success(sprintf('Full replacement push completed. %d product(s) generated for feed "%s".', $result['product_count'], $feedId)); + + return Command::SUCCESS; + } + + $io->section('Differential upsert push'); + $io->text(sprintf('Fetching current products from feed "%s" for differential upsert...', $feedId)); + + // TODO: 「次回 push 対象」マークに基づく差分抽出 (stock_updated_at 等) は後続実装。 + // 現状は現行 feed の全商品を取得して再 upsert するプレースホルダ動作とする。 + $products = $this->feedClient->getFeedProducts($feedId); + if ($products === []) { + $io->note('No products to upsert.'); + + return Command::SUCCESS; + } + + $accepted = $this->feedClient->upsertProducts($feedId, $products); + if ($accepted) { + $io->success(sprintf('Upsert accepted for %d product(s) on feed "%s".', \count($products), $feedId)); + } else { + $io->warning(sprintf('Upsert was not accepted by the Feed API for feed "%s".', $feedId)); + } + + return Command::SUCCESS; + } catch (AcpFeedException $e) { + // メッセージにシークレットは含まれない (client 側で除去済み)。 + $io->error('ACP Feed push failed: '.$e->getMessage()); + + return Command::FAILURE; + } + } +} diff --git a/src/Eccube/Command/AgentCommerce/UcpCatalogCacheClearCommand.php b/src/Eccube/Command/AgentCommerce/UcpCatalogCacheClearCommand.php new file mode 100644 index 00000000000..633cf1adaba --- /dev/null +++ b/src/Eccube/Command/AgentCommerce/UcpCatalogCacheClearCommand.php @@ -0,0 +1,49 @@ +title('UCP Catalog Cache Clear'); + + $this->cache->clear(); + + $io->success('UCP catalog cache cleared.'); + + return Command::SUCCESS; + } +} diff --git a/src/Eccube/Command/AgentCommerce/UcpCatalogCacheWarmupCommand.php b/src/Eccube/Command/AgentCommerce/UcpCatalogCacheWarmupCommand.php new file mode 100644 index 00000000000..d19c34817bd --- /dev/null +++ b/src/Eccube/Command/AgentCommerce/UcpCatalogCacheWarmupCommand.php @@ -0,0 +1,104 @@ +addOption('limit', null, InputOption::VALUE_REQUIRED, 'Number of products to include in the warmed default search page', (string) self::DEFAULT_LIMIT); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('UCP Catalog Cache Warmup'); + + $limit = max(1, (int) $input->getOption('limit')); + + $io->text(sprintf('Generating the default search response (first %d display product(s))...', $limit)); + + $body = $this->buildDefaultSearchBody($limit); + + // コントローラの decodeBody は空ボディ / "{}" のいずれも空 payload として同一の + // 既定 search を返すため、両ボディのキャッシュキーを生成する。 + foreach (['', '{}'] as $rawBody) { + $key = $this->cache->buildKey('search', $rawBody); + $this->cache->warmup($key, fn (): string => $body); + } + + $io->success('UCP catalog cache warmed up for the default search response.'); + + return Command::SUCCESS; + } + + /** + * フィルタ無し先頭ページの search レスポンス本文 (JSON) を生成する. + */ + private function buildDefaultSearchBody(int $limit): string + { + $items = []; + foreach ($this->catalogProvider->provideDisplayProducts() as $product) { + $item = $this->catalogMapper->mapProduct($product); + if ($item instanceof AgentCatalogItemDto) { + $items[] = $item; + } + if (\count($items) >= $limit) { + break; + } + } + + $response = $this->responseBuilder->buildSearchResponse($items, ['has_next_page' => false]); + + return json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Eccube/Controller/Admin/AgentCommerce/AgentCommerceController.php b/src/Eccube/Controller/Admin/AgentCommerce/AgentCommerceController.php new file mode 100644 index 00000000000..c0f69c9d752 --- /dev/null +++ b/src/Eccube/Controller/Admin/AgentCommerce/AgentCommerceController.php @@ -0,0 +1,192 @@ + + */ + #[Route(path: '/%eccube_admin_route%/content/agent_commerce', name: 'admin_content_agent_commerce', methods: ['GET'])] + #[Template(template: '@admin/Content/AgentCommerce/index.twig')] + public function index(): array + { + // discovery / catalog は常時公開のため表示用フラグは不要。 + return []; + } + + /** + * ACP「今すぐ push」: 現行 feed への差分 upsert. + */ + #[Route(path: '/%eccube_admin_route%/content/agent_commerce/acp_feed/push', name: 'admin_content_agent_commerce_acp_feed_push', methods: ['POST'])] + public function acpFeedPush(Request $request): RedirectResponse + { + if (!$this->isTokenValid()) { + throw new BadRequestHttpException(); + } + + $feedId = (string) $request->request->get('feed_id', ''); + if ($feedId === '') { + $this->addError('admin.content.agent_commerce.acp.feed_id_required', 'admin'); + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + try { + // TODO: 差分検出マーク (stock_updated_at 等) に基づく upsert は後続実装。 + // 現状は現行 feed の全商品を再 upsert する。 + $products = $this->acpFeedClient->getFeedProducts($feedId); + if ($products === []) { + $this->addWarning('admin.content.agent_commerce.acp.push_no_products', 'admin'); + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + $accepted = $this->acpFeedClient->upsertProducts($feedId, $products); + if ($accepted) { + $this->addSuccess('admin.content.agent_commerce.acp.push_success', 'admin'); + } else { + $this->addWarning('admin.content.agent_commerce.acp.push_not_accepted', 'admin'); + } + } catch (AcpFeedException $e) { + $this->addError('admin.content.agent_commerce.acp.push_failed', 'admin'); + log_error('ACP Feed push (differential) failed.', ['message' => $e->getMessage()]); + } + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + /** + * ACP「全置換 push」: products.jsonl / metadata.json を生成して feed を登録. + */ + #[Route(path: '/%eccube_admin_route%/content/agent_commerce/acp_feed/push_full', name: 'admin_content_agent_commerce_acp_feed_push_full', methods: ['POST'])] + public function acpFeedPushFull(Request $request): RedirectResponse + { + if (!$this->isTokenValid()) { + throw new BadRequestHttpException(); + } + + $feedId = (string) $request->request->get('feed_id', ''); + if ($feedId === '') { + $this->addError('admin.content.agent_commerce.acp.feed_id_required', 'admin'); + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + try { + $result = $this->acpFeedClient->pushFullReplacement($feedId); + $this->addSuccess(trans('admin.content.agent_commerce.acp.push_full_success', ['%count%' => $result['product_count']]), 'admin'); + } catch (AcpFeedException $e) { + $this->addError('admin.content.agent_commerce.acp.push_failed', 'admin'); + log_error('ACP Feed push (full replacement) failed.', ['message' => $e->getMessage()]); + } + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + /** + * UCP「キャッシュクリア」. + */ + #[Route(path: '/%eccube_admin_route%/content/agent_commerce/ucp_catalog/cache_clear', name: 'admin_content_agent_commerce_ucp_catalog_cache_clear', methods: ['POST'])] + public function ucpCatalogCacheClear(): RedirectResponse + { + if (!$this->isTokenValid()) { + throw new BadRequestHttpException(); + } + + $this->ucpCatalogCache->clear(); + $this->addSuccess('admin.content.agent_commerce.ucp.cache_clear_success', 'admin'); + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + /** + * UCP「生成」: 既定の search (空ボディ) レスポンスを事前生成する. + */ + #[Route(path: '/%eccube_admin_route%/content/agent_commerce/ucp_catalog/cache_warmup', name: 'admin_content_agent_commerce_ucp_catalog_cache_warmup', methods: ['POST'])] + public function ucpCatalogCacheWarmup(): RedirectResponse + { + if (!$this->isTokenValid()) { + throw new BadRequestHttpException(); + } + + $body = $this->buildDefaultSearchBody(self::WARMUP_LIMIT); + foreach (['', '{}'] as $rawBody) { + $key = $this->ucpCatalogCache->buildKey('search', $rawBody); + $this->ucpCatalogCache->warmup($key, fn (): string => $body); + } + + $this->addSuccess('admin.content.agent_commerce.ucp.cache_warmup_success', 'admin'); + + return $this->redirectToRoute('admin_content_agent_commerce'); + } + + /** + * フィルタ無し先頭ページの search レスポンス本文 (JSON) を生成する. + */ + private function buildDefaultSearchBody(int $limit): string + { + $items = []; + foreach ($this->catalogProvider->provideDisplayProducts() as $product) { + $item = $this->catalogMapper->mapProduct($product); + if ($item instanceof AgentCatalogItemDto) { + $items[] = $item; + } + if (\count($items) >= $limit) { + break; + } + } + + $response = $this->responseBuilder->buildSearchResponse($items, ['has_next_page' => false]); + + return json_encode($response, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Eccube/Controller/AgentCommerce/UcpCatalogController.php b/src/Eccube/Controller/AgentCommerce/UcpCatalogController.php new file mode 100644 index 00000000000..3ea944cb81b --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/UcpCatalogController.php @@ -0,0 +1,354 @@ +getContent(); + $payload = $this->decodeBody($rawBody); + $key = $this->cache->buildKey('search', $rawBody); + + $body = $this->cache->getOrCompute($key, function () use ($payload): string { + $query = isset($payload['query']) && is_string($payload['query']) ? $payload['query'] : null; + [$limit, $offset] = $this->resolvePagination($payload); + + // 1 件多く取得して次ページ有無を判定する + $products = $this->findDisplayProducts($query, $offset, $limit + 1); + $hasNext = count($products) > $limit; + if ($hasNext) { + $products = array_slice($products, 0, $limit); + } + + $items = $this->mapProducts($products); + + $pagination = ['has_next_page' => $hasNext]; + if ($hasNext) { + $pagination['cursor'] = $this->encodeCursor($offset + $limit); + } + + return $this->encodeJson( + $this->responseBuilder->buildSearchResponse($items, $pagination) + ); + }); + + return $this->respond($request, $body); + } + + /** + * POST /catalog/lookup — ids[] (product ID / variant ID / SKU) を解決して返す. + */ + #[Route(path: '/catalog/lookup', name: 'agent_ucp_catalog_lookup', methods: ['POST'])] + public function lookup(Request $request): Response + { + $rawBody = $request->getContent(); + $payload = $this->decodeBody($rawBody); + $key = $this->cache->buildKey('lookup', $rawBody); + + $body = $this->cache->getOrCompute($key, function () use ($payload): string { + $ids = isset($payload['ids']) && is_array($payload['ids']) ? $payload['ids'] : []; + + $products = []; + $seen = []; + foreach ($ids as $id) { + if (!is_string($id) && !is_int($id)) { + continue; + } + $product = $this->resolveProductByIdentifier((string) $id); + if ($product === null) { + continue; + } + $productId = $product->getId(); + if ($productId === null || isset($seen[$productId])) { + continue; + } + $seen[$productId] = true; + $products[] = $product; + } + + $items = $this->mapProducts($products); + + // TODO: UCP lookup_response.variants[] は lookup_variant であり input_correlation[] + // (`inputs`, minItems:1, {id, match?}) を MUST とする (catalog_lookup.json)。 + // 要求 id と解決した variant の相関 (exact/featured) を出力する必要がある。 + // 現状は未出力 (UcpCatalogSchemaContractTest::testLookupResponseMatchesUcpSchema は incomplete)。 + return $this->encodeJson($this->responseBuilder->buildLookupResponse($items)); + }); + + return $this->respond($request, $body); + } + + /** + * POST /catalog/product — id (product ID / variant ID) で単一商品を返す. + * + * 必須の product が解決できない場合は 404 (Error) を返す。 + */ + #[Route(path: '/catalog/product', name: 'agent_ucp_catalog_product', methods: ['POST'])] + public function product(Request $request): Response + { + $rawBody = $request->getContent(); + $payload = $this->decodeBody($rawBody); + + $id = isset($payload['id']) && (is_string($payload['id']) || is_int($payload['id'])) + ? (string) $payload['id'] + : null; + if ($id === null || $id === '') { + throw new NotFoundHttpException('product not found'); + } + + $key = $this->cache->buildKey('product', $rawBody); + + // 解決不能は 404 とするためキャッシュ前に存在判定する + $product = $this->resolveProductByIdentifier($id); + if ($product === null) { + throw new NotFoundHttpException('product not found'); + } + $item = $this->catalogMapper->mapProduct($product); + if ($item === null) { + throw new NotFoundHttpException('product not found'); + } + + $body = $this->cache->getOrCompute( + $key, + fn (): string => $this->encodeJson($this->responseBuilder->buildProductResponse($item)), + ); + + return $this->respond($request, $body); + } + + /** + * リクエストボディ (JSON) を連想配列へデコードする. 空 / 不正は空配列扱い. + * + * @return array + */ + private function decodeBody(string $rawBody): array + { + if (trim($rawBody) === '') { + return []; + } + + $decoded = json_decode($rawBody, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * pagination リクエストから [limit, offset] を解決する. + * + * cursor は本実装独自の不透明 offset (base64) として扱う。 + * + * @param array $payload + * + * @return array{0: int, 1: int} + */ + private function resolvePagination(array $payload): array + { + $pagination = isset($payload['pagination']) && is_array($payload['pagination']) + ? $payload['pagination'] + : []; + + $limit = self::DEFAULT_LIMIT; + if (isset($pagination['limit']) && is_int($pagination['limit']) && $pagination['limit'] >= 1) { + $limit = min($pagination['limit'], self::MAX_LIMIT); + } + + $offset = 0; + if (isset($pagination['cursor']) && is_string($pagination['cursor']) && $pagination['cursor'] !== '') { + $offset = $this->decodeCursor($pagination['cursor']); + } + + return [$limit, $offset]; + } + + private function encodeCursor(int $offset): string + { + return base64_encode('offset:'.$offset); + } + + private function decodeCursor(string $cursor): int + { + $decoded = base64_decode($cursor, true); + if ($decoded === false || !str_starts_with($decoded, 'offset:')) { + return 0; + } + + return max(0, (int) substr($decoded, strlen('offset:'))); + } + + /** + * 公開商品をフリーテキスト (name LIKE) で検索し、id 昇順でページング取得する. + * + * @return Product[] + */ + private function findDisplayProducts(?string $query, int $offset, int $limit): array + { + $qb = $this->productRepository->createQueryBuilder('p') + ->where('p.Status = :status') + ->setParameter('status', ProductStatus::DISPLAY_SHOW) + ->orderBy('p.id', 'ASC') + ->setFirstResult($offset) + ->setMaxResults($limit); + + if ($query !== null && trim($query) !== '') { + $escaped = str_replace(['%', '_'], ['\\%', '\\_'], trim($query)); + $qb->andWhere('p.name LIKE :keyword') + ->setParameter('keyword', '%'.$escaped.'%'); + } + + /** @var Product[] $result */ + $result = $qb->getQuery()->getResult(); + + return $result; + } + + /** + * 識別子 (product ID / variant ID / SKU) から Product を解決する. + * + * 数値は product ID を優先し、見つからなければ variant (ProductClass) ID として解決する。 + * 数値以外は SKU (ProductClass.code) として解決する。 + */ + private function resolveProductByIdentifier(string $identifier): ?Product + { + if ($identifier === '') { + return null; + } + + if (ctype_digit($identifier)) { + $product = $this->productRepository->find((int) $identifier); + if ($product !== null) { + return $product; + } + + $productClass = $this->referenceResolver->resolveByProductClassId((int) $identifier); + if ($productClass !== null) { + return $productClass->getProduct(); + } + + return null; + } + + $productClass = $this->referenceResolver->resolveBySku($identifier); + + return $productClass?->getProduct(); + } + + /** + * Product 配列を AgentCatalogItemDto 配列へ写す (非公開 / 出力不可は除外). + * + * @param Product[] $products + * + * @return AgentCatalogItemDto[] + */ + private function mapProducts(array $products): array + { + $items = []; + foreach ($products as $product) { + $item = $this->catalogMapper->mapProduct($product); + if ($item !== null) { + $items[] = $item; + } + } + + return $items; + } + + /** + * 連想配列を JSON 文字列へエンコードする. + * + * @param array $data + */ + private function encodeJson(array $data): string + { + return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + + /** + * JSON 本文を返す. Accept-Encoding に gzip があれば gzip 圧縮する. + */ + private function respond(Request $request, string $body): Response + { + $acceptEncoding = (string) $request->headers->get('Accept-Encoding', ''); + if (str_contains($acceptEncoding, 'gzip')) { + $compressed = gzencode($body, 6); + if ($compressed !== false) { + return new Response($compressed, JsonResponse::HTTP_OK, [ + 'Content-Type' => 'application/json', + 'Content-Encoding' => 'gzip', + 'Vary' => 'Accept-Encoding', + ]); + } + } + + return new Response($body, JsonResponse::HTTP_OK, ['Content-Type' => 'application/json']); + } +} diff --git a/src/Eccube/Controller/AgentCommerce/UcpDiscoveryController.php b/src/Eccube/Controller/AgentCommerce/UcpDiscoveryController.php new file mode 100644 index 00000000000..be62c2f325a --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/UcpDiscoveryController.php @@ -0,0 +1,93 @@ += 60 (no-store/no-cache/private は禁止). + * - well-known はオリジンルート固定・1 オリジン 1 枚 (RFC 8615). + * + * discovery は公開して害がないため常時公開する (checkout の有無は capability 宣言で表現する). + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/schemas/profile.json + */ +class UcpDiscoveryController extends AbstractController +{ + public function __construct( + private readonly UcpProfileBuilder $profileBuilder, + ) { + } + + /** + * GET /.well-known/ucp — UCP discovery profile を JSON で返す. + */ + #[Route(path: '/.well-known/ucp', name: 'agent_ucp_discovery', methods: ['GET'])] + public function index(): Response + { + $profile = $this->profileBuilder->build(); + + // services / capabilities / payment_handlers は UCP profile schema 上 object 必須. + // 空配列は JSON では [] になるため、JSON_FORCE_OBJECT に頼らず stdClass へ正規化して {} を保証する. + $body = json_encode( + $this->normalizeForJson($profile), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR + ); + + $response = new Response($body, JsonResponse::HTTP_OK, [ + 'Content-Type' => 'application/json', + // public, max-age >= 60 (no-store/no-cache/private 禁止) + 'Cache-Control' => 'public, max-age=300', + ]); + + // EC-CUBE は全リクエストでセッションを開始するため、Symfony の AbstractSessionListener が + // 既定で Cache-Control を private, must-revalidate へ上書きする。discovery 文書は + // 機密を含まない公開ドキュメント (RFC 8615) であり public キャッシュが必須 (no-store/no-cache/private 禁止) + // のため、このマーカーヘッダで自動上書きを抑止する (リスナがレスポンス送出前に除去する)。 + $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true'); + + return $response; + } + + /** + * profile の object 必須フィールド (空のとき) を stdClass に置換し {} 出力を保証する. + * + * @param array $profile + * + * @return array + */ + private function normalizeForJson(array $profile): array + { + if (isset($profile['ucp']) && is_array($profile['ucp'])) { + foreach (['services', 'capabilities', 'payment_handlers'] as $objectKey) { + if (isset($profile['ucp'][$objectKey]) && $profile['ucp'][$objectKey] === []) { + $profile['ucp'][$objectKey] = new \stdClass(); + } + } + } + + return $profile; + } +} diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 042253f5157..b5e3fd92f66 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -154,6 +154,15 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ga_id', type: Types::STRING, length: 255, nullable: true)] private ?string $gaId = null; + #[ORM\Column(name: 'acp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_catalog_requires_auth = false; + /** * Get id. * @@ -831,5 +840,59 @@ public function getGaId(): ?string { return $this->gaId; } + + /** + * Set acpCheckoutEnabled. + */ + public function setAcpCheckoutEnabled(bool $acpCheckoutEnabled): BaseInfo + { + $this->acp_checkout_enabled = $acpCheckoutEnabled; + + return $this; + } + + /** + * Get acpCheckoutEnabled. + */ + public function isAcpCheckoutEnabled(): bool + { + return $this->acp_checkout_enabled; + } + + /** + * Set ucpCheckoutEnabled. + */ + public function setUcpCheckoutEnabled(bool $ucpCheckoutEnabled): BaseInfo + { + $this->ucp_checkout_enabled = $ucpCheckoutEnabled; + + return $this; + } + + /** + * Get ucpCheckoutEnabled. + */ + public function isUcpCheckoutEnabled(): bool + { + return $this->ucp_checkout_enabled; + } + + /** + * Set ucpCatalogRequiresAuth. + */ + public function setUcpCatalogRequiresAuth(bool $ucpCatalogRequiresAuth): BaseInfo + { + $this->ucp_catalog_requires_auth = $ucpCatalogRequiresAuth; + + return $this; + } + + /** + * Get ucpCatalogRequiresAuth. + */ + public function isUcpCatalogRequiresAuth(): bool + { + return $this->ucp_catalog_requires_auth; + } } } diff --git a/src/Eccube/Entity/Master/CountryIsoCode.php b/src/Eccube/Entity/Master/CountryIsoCode.php new file mode 100644 index 00000000000..345fd81ffda --- /dev/null +++ b/src/Eccube/Entity/Master/CountryIsoCode.php @@ -0,0 +1,36 @@ + $args + */ + public function postUpdate(LifecycleEventArgs $args): void + { + $this->markIfCatalogEntity($args); + } + + /** + * @param LifecycleEventArgs $args + */ + public function postPersist(LifecycleEventArgs $args): void + { + $this->markIfCatalogEntity($args); + } + + /** + * @param LifecycleEventArgs $args + */ + public function postRemove(LifecycleEventArgs $args): void + { + $this->markIfCatalogEntity($args); + } + + /** + * フラッシュ完了後、カタログ関連の変更があればキャッシュを 1 度だけクリアする. + * + * TODO: ACP Feed の「次回 push 対象マーク」をここで記録する (差分 upsert 用)。 + */ + public function postFlush(PostFlushEventArgs $args): void + { + if (!$this->dirty) { + return; + } + + $this->dirty = false; + $this->cache->clear(); + } + + /** + * 対象が Product / ProductClass なら dirty フラグを立てる. + * + * @param LifecycleEventArgs $args + */ + private function markIfCatalogEntity(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + if ($entity instanceof Product || $entity instanceof ProductClass) { + $this->dirty = true; + } + } +} diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index ec5f4de8a9c..2bf342d587e 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -218,6 +218,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]), ], ]) + // エージェントコマース checkout の有効化フラグ (discovery / catalog は常時公開、checkout のみ制御) + ->add('acp_checkout_enabled', ToggleSwitchType::class) + ->add('ucp_checkout_enabled', ToggleSwitchType::class) ; $builder->add( diff --git a/src/Eccube/Repository/Master/CountryIsoCodeRepository.php b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php new file mode 100644 index 00000000000..149bc547b97 --- /dev/null +++ b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php @@ -0,0 +1,31 @@ + + */ +class CountryIsoCodeRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CountryIsoCode::class); + } +} diff --git a/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json b/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json new file mode 100644 index 00000000000..e05516a6050 --- /dev/null +++ b/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json @@ -0,0 +1,626 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/schemas/feed/bundle.schema.json", + "title": "Feed - Schema Bundle", + "$defs": { + "Description": { + "description": "Structured long-form or rich-text description content for a product or variant.", + "type": "object", + "additionalProperties": false, + "properties": { + "plain": { + "type": "string", + "description": "Plain-text description intended for clients that do not render rich formatting." + }, + "html": { + "type": "string", + "description": "HTML-formatted description content." + }, + "markdown": { + "type": "string", + "description": "Markdown-formatted description content." + } + }, + "minProperties": 1, + "example": { + "plain": "Classic cotton tee in red, size small." + } + }, + "Price": { + "description": "Monetary amount expressed in minor units with an associated ISO 4217 currency code.", + "type": "object", + "additionalProperties": false, + "required": ["amount", "currency"], + "properties": { + "amount": { + "type": "integer", + "minimum": 0, + "description": "Monetary amount expressed in ISO 4217 minor units." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "Three-letter ISO 4217 currency identifier." + } + }, + "example": { + "amount": 1999, + "currency": "USD" + } + }, + "Availability": { + "description": "Purchasability and fulfillment state for a variant.", + "type": "object", + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean", + "description": "Indicates whether the variant is currently purchasable. Use status for fulfillment context." + }, + "status": { + "type": "string", + "description": "Extensible fulfillment state for the variant. Known values include in_stock, limited_stock, backorder, preorder, out_of_stock, and discontinued." + } + }, + "example": { + "available": true, + "status": "in_stock" + } + }, + "Barcode": { + "description": "Machine-readable identifier attached to a variant, such as a GTIN or UPC.", + "type": "object", + "additionalProperties": false, + "required": ["type", "value"], + "properties": { + "type": { + "type": "string", + "description": "Barcode scheme or identifier type, such as GTIN, UPC, or EAN." + }, + "value": { + "type": "string", + "description": "Raw barcode value as provided by the merchant." + } + }, + "example": { + "type": "GTIN", + "value": "00012345600012" + } + }, + "Media": { + "description": "Media asset associated with a product or variant.", + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "description": "Media kind, such as image, video, or model." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Canonical URL where the media asset can be retrieved." + }, + "alt_text": { + "type": "string", + "description": "Human-readable alternate text describing the asset." + }, + "width": { + "type": "integer", + "description": "Rendered width of the asset in pixels, when known." + }, + "height": { + "type": "integer", + "description": "Rendered height of the asset in pixels, when known." + } + }, + "example": { + "type": "image", + "url": "https://cdn.merchant.com/products/classic-tee/main.jpg", + "alt_text": "Classic Tee front view" + } + }, + "VariantOption": { + "description": "One selected characteristic of a variant, such as size or color.", + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { + "type": "string", + "description": "Display name of the option dimension, such as Color or Size." + }, + "value": { + "type": "string", + "description": "Selected option value for this variant." + } + }, + "example": { + "name": "Color", + "value": "Red" + } + }, + "Category": { + "description": "Category assignment for a product or variant within a specific taxonomy.", + "type": "object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "value": { + "type": "string", + "description": "Category label or hierarchical path, for example Mens > Sweaters > Crewnecks." + }, + "taxonomy": { + "type": "string", + "description": "Names the taxonomy system used for the category value, such as google_product_category, shopify, or merchant." + } + }, + "example": { + "value": "Apparel > Shirts", + "taxonomy": "merchant" + } + }, + "Link": { + "description": "Merchant-provided informational or policy link associated with a seller.", + "type": "object", + "additionalProperties": false, + "required": ["type", "url"], + "properties": { + "type": { + "type": "string", + "description": "Extensible link type, such as privacy_policy, terms_of_service, refund_policy, shipping_policy, or faq." + }, + "title": { + "type": "string", + "description": "Human-readable label for the linked resource." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Canonical URL for the linked resource." + } + }, + "example": { + "type": "shipping_policy", + "title": "Shipping Policy", + "url": "https://merchant.com/policies/shipping" + } + }, + "Seller": { + "description": "Merchant or seller identity associated with a variant offer.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Display name of the seller or merchant of record." + }, + "links": { + "type": "array", + "description": "Informational or policy links associated with this seller.", + "items": { + "$ref": "#/$defs/Link" + } + } + }, + "example": { + "name": "Example Merchant", + "links": [ + { + "type": "refund_policy", + "title": "Refund Policy", + "url": "https://merchant.com/policies/refunds" + } + ] + } + }, + "Condition": { + "description": "Extensible list of applicable item conditions, such as new or secondhand.", + "type": "array", + "items": { + "type": "string", + "description": "Condition label supplied by the merchant." + }, + "example": ["new"] + }, + "Measure": { + "description": "Measured quantity paired with a unit for unit-price calculations.", + "type": "object", + "additionalProperties": false, + "required": ["value", "unit"], + "properties": { + "value": { + "type": "number", + "description": "Measured quantity for the package or item." + }, + "unit": { + "type": "string", + "description": "Unit label for the measured quantity, such as oz, ml, or kg." + } + }, + "example": { + "value": 12, + "unit": "oz" + } + }, + "ReferenceMeasure": { + "description": "Reference unit used when normalizing a unit price for display.", + "type": "object", + "additionalProperties": false, + "required": ["value", "unit"], + "properties": { + "value": { + "type": "integer", + "description": "Reference quantity used to normalize the unit price." + }, + "unit": { + "type": "string", + "description": "Reference unit label, such as ml, g, or oz." + } + }, + "example": { + "value": 100, + "unit": "ml" + } + }, + "UnitPrice": { + "description": "Normalized unit price for products sold by weight, volume, or measure.", + "type": "object", + "additionalProperties": false, + "required": ["amount", "currency", "measure", "reference"], + "properties": { + "amount": { + "type": "integer", + "minimum": 0, + "description": "Normalized price amount expressed in ISO 4217 minor units." + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "Three-letter ISO 4217 currency identifier." + }, + "measure": { + "$ref": "#/$defs/Measure", + "description": "Actual packaged measure associated with the sale item." + }, + "reference": { + "$ref": "#/$defs/ReferenceMeasure", + "description": "Reference measure used to display the normalized unit price." + } + }, + "example": { + "amount": 499, + "currency": "USD", + "measure": { + "value": 12, + "unit": "oz" + }, + "reference": { + "value": 1, + "unit": "oz" + } + } + }, + "Variant": { + "description": "Purchasable variant of a product within a feed.", + "type": "object", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { + "type": "string", + "description": "Stable global identifier for this variant." + }, + "title": { + "type": "string", + "description": "Display title for the variant." + }, + "description": { + "$ref": "#/$defs/Description", + "description": "Structured description content for the variant." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Canonical URL for the variant detail page." + }, + "barcodes": { + "type": "array", + "description": "Machine-readable identifiers associated with this variant.", + "items": { + "$ref": "#/$defs/Barcode" + } + }, + "price": { + "$ref": "#/$defs/Price", + "description": "Active selling price for the variant." + }, + "list_price": { + "$ref": "#/$defs/Price", + "description": "Reference or pre-discount price for the variant." + }, + "unit_price": { + "$ref": "#/$defs/UnitPrice", + "description": "Normalized unit price, when applicable." + }, + "availability": { + "$ref": "#/$defs/Availability", + "description": "Purchasability and fulfillment state for the variant." + }, + "categories": { + "type": "array", + "description": "Category assignments associated with this variant.", + "items": { + "$ref": "#/$defs/Category" + } + }, + "condition": { + "$ref": "#/$defs/Condition", + "description": "Extensible list of conditions applicable to this variant." + }, + "variant_options": { + "type": "array", + "description": "Option selections that distinguish this variant, such as Color: Red or Size: Small.", + "items": { + "$ref": "#/$defs/VariantOption" + } + }, + "media": { + "type": "array", + "description": "Media assets specific to this variant. The first item is the primary listing asset.", + "items": { + "$ref": "#/$defs/Media" + } + }, + "seller": { + "$ref": "#/$defs/Seller", + "description": "Seller or merchant of record for this variant." + }, + "marketplace": { + "$ref": "#/$defs/Seller", + "description": "Marketplace or intermediary platform through which this variant is offered, if applicable." + } + }, + "example": { + "id": "sku123-red-s", + "title": "Classic Tee - Red / Small", + "description": { + "plain": "Classic cotton tee in red, size small." + }, + "url": "https://merchant.com/products/classic-tee?variant=sku123-red-s", + "barcodes": [ + { + "type": "GTIN", + "value": "00012345600012" + } + ], + "price": { + "amount": 1999, + "currency": "USD" + }, + "list_price": { + "amount": 2499, + "currency": "USD" + }, + "availability": { + "available": true, + "status": "in_stock" + }, + "variant_options": [ + { + "name": "Color", + "value": "Red" + }, + { + "name": "Size", + "value": "Small" + } + ], + "media": [ + { + "type": "image", + "url": "https://cdn.merchant.com/products/classic-tee/red-small-1.jpg", + "alt_text": "Classic Tee in red, size small" + } + ], + "seller": { + "name": "Example Merchant" + }, + "marketplace": { + "name": "Example Marketplace" + } + } + }, + "Product": { + "description": "Catalog product grouping one or more purchasable variants within a feed.", + "type": "object", + "additionalProperties": false, + "required": ["id", "variants"], + "properties": { + "id": { + "type": "string", + "description": "Stable global identifier for this product." + }, + "title": { + "type": "string", + "description": "Display title for the product." + }, + "description": { + "$ref": "#/$defs/Description", + "description": "Structured description content for the product." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Canonical URL for the product detail page." + }, + "media": { + "type": "array", + "description": "Media assets associated with the product.", + "items": { + "$ref": "#/$defs/Media" + } + }, + "variants": { + "type": "array", + "description": "Purchasable variants grouped under this product.", + "items": { + "$ref": "#/$defs/Variant" + } + } + }, + "example": { + "id": "prod_classic_tee", + "title": "Classic Tee", + "media": [ + { + "type": "image", + "url": "https://cdn.merchant.com/products/classic-tee/main.jpg", + "alt_text": "Classic Tee front view" + } + ], + "variants": [ + { + "id": "sku123-red-s", + "title": "Classic Tee - Red / Small" + } + ] + } + }, + "FeedMetadata": { + "description": "Server-managed metadata describing a feed resource.", + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Stable identifier for the feed resource." + }, + "target_country": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "Optional ISO 3166-1 alpha-2 country code describing the feed's target market." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp of the most recent update applied to this feed." + } + }, + "example": { + "id": "feed_8f3K2x", + "target_country": "US", + "updated_at": "2026-03-01T00:00:00Z" + } + }, + "CreateFeedRequest": { + "description": "Request payload used to create a feed.", + "type": "object", + "additionalProperties": false, + "properties": { + "target_country": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "Optional ISO 3166-1 alpha-2 country code describing the feed's target market." + } + }, + "example": { + "target_country": "US" + } + }, + "ProductsResponse": { + "description": "Response envelope containing the current product set for a feed.", + "type": "object", + "additionalProperties": false, + "required": ["products"], + "properties": { + "products": { + "type": "array", + "description": "Full list of products currently associated with the feed.", + "items": { + "$ref": "#/$defs/Product" + } + } + }, + "example": { + "products": [ + { + "id": "prod_classic_tee", + "title": "Classic Tee", + "variants": [ + { + "id": "sku123-red-s", + "title": "Classic Tee - Red / Small" + } + ] + } + ] + } + }, + "UpsertProductsRequest": { + "description": "Request payload that partially upserts products into a feed. Products omitted from the request remain unchanged.", + "type": "object", + "additionalProperties": false, + "required": ["products"], + "properties": { + "products": { + "type": "array", + "description": "Subset of products to create or update within the feed, matched by Product.id.", + "items": { + "$ref": "#/$defs/Product" + } + } + }, + "example": { + "products": [ + { + "id": "prod_classic_tee", + "title": "Classic Tee", + "variants": [ + { + "id": "sku124-red-m", + "title": "Classic Tee - Red / Medium", + "availability": { + "available": false, + "status": "out_of_stock" + } + } + ] + } + ] + } + }, + "Error": { + "description": "Structured error returned when a feed request cannot be fulfilled.", + "type": "object", + "additionalProperties": false, + "required": ["type", "code", "message"], + "properties": { + "type": { + "type": "string", + "description": "High-level error category." + }, + "code": { + "type": "string", + "description": "Machine-readable error code for programmatic handling." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "param": { + "type": "string", + "description": "Optional request parameter or field associated with the error." + } + }, + "example": { + "type": "invalid_request", + "code": "feed_not_found", + "message": "Feed not found", + "param": "id" + } + } + } +} diff --git a/src/Eccube/Resource/AgentCommerce/README.md b/src/Eccube/Resource/AgentCommerce/README.md new file mode 100644 index 00000000000..753558d76b3 --- /dev/null +++ b/src/Eccube/Resource/AgentCommerce/README.md @@ -0,0 +1,15 @@ +# Agent Commerce 同梱リソース + +## `Acp/schema.feed.json` + +ACP (Agentic Commerce Protocol) の Product Feed JSON Schema です。 +`AcpFeedValidator` が **push 前の生成データ検証 (pre-push validation)** に runtime で使用します。 +サーバ (OpenAI) 側の ack が粗い (`{accepted: bool}`) ため、加盟店側で送信前に +`products.jsonl` / `metadata.json` がスキーマ適合することを保証する目的の必須リソースです。 + +- 出所: [agentic-commerce-protocol](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol) + `spec/2026-04-17/json-schema/schema.feed.json` +- バージョン: ACP `2026-04-17` +- ライセンス: **Apache License 2.0** (原文ママ・無改変) + +更新する場合は上記リポジトリの同名ファイルと同期し、バージョンを併記してください。 diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml index bd8383c3485..6d81b1d63e9 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml @@ -1,5 +1,6 @@ - mtb_authority.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml index bd8383c3485..6d81b1d63e9 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml @@ -1,5 +1,6 @@ - mtb_authority.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..ac130d0d206 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1147,6 +1147,27 @@ admin.setting.system.log_display: Logs admin.setting.system.login_history: Login History admin.setting.system.master_data_management: Master Data admin.setting.system.system_info: System Info +admin.content.agent_commerce: Agent Commerce +admin.content.agent_commerce.notice: This feature is not offered in Japan. Use it for verification purposes only. +admin.content.agent_commerce.published: Published +admin.content.agent_commerce.acp.title: ACP Product Feed (push) +admin.content.agent_commerce.acp.description: Push the product feed to the OpenAI-hosted ACP Feed API. Pushing requires credentials (base URL / API key) to be configured. +admin.content.agent_commerce.acp.feed_id: Feed ID +admin.content.agent_commerce.acp.feed_id_required: Please enter a feed ID. +admin.content.agent_commerce.acp.push_now: Push now (differential) +admin.content.agent_commerce.acp.push_full: Full replacement push +admin.content.agent_commerce.acp.push_success: The differential push has been sent. +admin.content.agent_commerce.acp.push_full_success: "The full replacement push has been sent (%count% items)." +admin.content.agent_commerce.acp.push_no_products: There are no products to push. +admin.content.agent_commerce.acp.push_not_accepted: The Feed API did not accept the request. +admin.content.agent_commerce.acp.push_failed: Failed to send the push. Please check the configuration. +admin.content.agent_commerce.ucp.title: UCP Catalog (pull) +admin.content.agent_commerce.ucp.description: Manage the UCP catalog API cache. The catalog is always public. +admin.content.agent_commerce.ucp.catalog_status: Catalog API +admin.content.agent_commerce.ucp.cache_warmup: Warm up cache +admin.content.agent_commerce.ucp.cache_clear: Clear cache +admin.content.agent_commerce.ucp.cache_warmup_success: The catalog cache has been warmed up. +admin.content.agent_commerce.ucp.cache_clear_success: The catalog cache has been cleared. #------------------------------------------------------------------------------------ # Settings : Store Settings : General @@ -1186,6 +1207,10 @@ admin.setting.shop.shop.option_point_rate: Point Return Rate admin.setting.shop.shop.option_point_conversion_rate: Point Conversion Rate admin.setting.shop.shop.ga: "Google Analytics" admin.setting.shop.shop.ga.tracking_id: "Tracking ID" +admin.setting.shop.shop.agent_commerce: "Agentic Commerce" +admin.setting.shop.shop.agent_commerce.description: "Enable checkout via AI agents (ChatGPT / Gemini, etc.). Discovery and catalog are always public; only checkout is controlled here. Checkout is not yet available in Japan, so normally leave these disabled." +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "Enable ACP checkout" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "Enable UCP checkout" #------------------------------------------------------------------------------------ # Settings:Store Settings:Trade Law Settings diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..821c17e820f 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1146,6 +1146,27 @@ admin.setting.system.log_display: ログ表示 admin.setting.system.login_history: ログイン履歴 admin.setting.system.master_data_management: マスタデータ管理 admin.setting.system.system_info: システム情報 +admin.content.agent_commerce: エージェントコマース +admin.content.agent_commerce.notice: この機能は日本国内では未提供です。検証目的に限定してご利用ください。 +admin.content.agent_commerce.published: 公開中 +admin.content.agent_commerce.acp.title: ACP プロダクトフィード (push) +admin.content.agent_commerce.acp.description: OpenAI がホストする ACP Feed API へ商品フィードを送出します。送出には認証情報 (base URL / API キー) の設定が必要です。 +admin.content.agent_commerce.acp.feed_id: フィード ID +admin.content.agent_commerce.acp.feed_id_required: フィード ID を入力してください。 +admin.content.agent_commerce.acp.push_now: 今すぐ push (差分) +admin.content.agent_commerce.acp.push_full: 全置換 push +admin.content.agent_commerce.acp.push_success: 差分 push を送信しました。 +admin.content.agent_commerce.acp.push_full_success: "全置換 push を送信しました (%count% 件)。" +admin.content.agent_commerce.acp.push_no_products: 送信対象の商品がありません。 +admin.content.agent_commerce.acp.push_not_accepted: Feed API に受理されませんでした。 +admin.content.agent_commerce.acp.push_failed: push の送信に失敗しました。設定を確認してください。 +admin.content.agent_commerce.ucp.title: UCP カタログ (pull) +admin.content.agent_commerce.ucp.description: UCP カタログ API のキャッシュを操作します。カタログは常時公開されます。 +admin.content.agent_commerce.ucp.catalog_status: カタログ API +admin.content.agent_commerce.ucp.cache_warmup: キャッシュ生成 +admin.content.agent_commerce.ucp.cache_clear: キャッシュクリア +admin.content.agent_commerce.ucp.cache_warmup_success: カタログキャッシュを生成しました。 +admin.content.agent_commerce.ucp.cache_clear_success: カタログキャッシュをクリアしました。 #------------------------------------------------------------------------------------ # 設定:店舗設定:基本設定 @@ -1185,6 +1206,10 @@ admin.setting.shop.shop.option_point_rate: ポイント付与率 admin.setting.shop.shop.option_point_conversion_rate: ポイント換算レート admin.setting.shop.shop.ga: "Googleアナリティクス設定" admin.setting.shop.shop.ga.tracking_id: "トラッキングID" +admin.setting.shop.shop.agent_commerce: "エージェントコマース" +admin.setting.shop.shop.agent_commerce.description: "AIエージェント (ChatGPT / Gemini 等) 経由のチェックアウトを有効化します。Discovery / カタログは常時公開され、ここでは checkout のみを制御します。checkout は日本未提供のため、通常は無効のままにしてください。" +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "ACP チェックアウトを有効にする" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "UCP チェックアウトを有効にする" #------------------------------------------------------------------------------------ # 設定:店舗設定:特定商取引法設定 diff --git a/src/Eccube/Resource/template/admin/Content/AgentCommerce/index.twig b/src/Eccube/Resource/template/admin/Content/AgentCommerce/index.twig new file mode 100644 index 00000000000..dda41b8937f --- /dev/null +++ b/src/Eccube/Resource/template/admin/Content/AgentCommerce/index.twig @@ -0,0 +1,97 @@ +{# +This file is part of EC-CUBE + +Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved. + +http://www.ec-cube.co.jp/ + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +#} + +{% extends '@admin/default_frame.twig' %} + +{% set menus = ['content', 'agent_commerce'] %} + +{% block title %}{{ 'admin.content.agent_commerce'|trans }}{% endblock %} +{% block sub_title %}{{ 'admin.content.contents_management'|trans }}{% endblock %} + +{% block main %} +
+
+ + + + {# ACP Product Feed (push) #} +
+
+ {{ 'admin.content.agent_commerce.acp.title'|trans }} +
+
+

{{ 'admin.content.agent_commerce.acp.description'|trans }}

+ + {# 差分 push / 全置換 push は同一フォームの formaction で振り分ける (feed_id を共有) #} + {# push は認証情報 (ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL / _API_KEY) 未設定時はエラーになる #} +
+ +
+
+ + +
+
+ + +
+
+
+ + {# UCP Catalog (pull / cache) #} +
+
+ {{ 'admin.content.agent_commerce.ucp.title'|trans }} +
+
+

{{ 'admin.content.agent_commerce.ucp.description'|trans }}

+ +
+
+ {{ 'admin.content.agent_commerce.ucp.catalog_status'|trans }} +
+
+ {{ 'admin.content.agent_commerce.published'|trans }} +
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+{% endblock %} diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index b30e83f8049..67ef3f96e25 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -373,6 +373,30 @@ file that was distributed with this source code. +
+
{{ 'admin.setting.shop.shop.agent_commerce'|trans }}
+
+

{{ 'admin.setting.shop.shop.agent_commerce.description'|trans }}

+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.acp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.acp_checkout_enabled) }} + {{ form_errors(form.acp_checkout_enabled) }} +
+
+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.ucp_checkout_enabled) }} + {{ form_errors(form.ucp_checkout_enabled) }} +
+
+
+
diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php new file mode 100644 index 00000000000..c286d14d76c --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -0,0 +1,141 @@ +countryIsoCodeRepository->find($numericCountryId)?->getName(); + } + + /** + * Pref から region 文字列 (都道府県名) を返す。null は null を返す。 + */ + public function getRegionFromPref(?Pref $pref): ?string + { + if ($pref === null) { + return null; + } + + return $pref->getName(); + } + + /** + * 住所系エンティティを ACP / UCP の住所 DTO 相当の配列へ写す。 + * + * @return array{ + * family_name: ?string, + * given_name: ?string, + * family_name_kana: ?string, + * given_name_kana: ?string, + * company: ?string, + * postal_code: ?string, + * region: ?string, + * address1: ?string, + * address2: ?string, + * country: ?string, + * phone: ?string + * } + */ + public function toAddressArray(Customer|CustomerAddress|Shipping $source): array + { + $country = $this->extractCountry($source); + $countryId = $country !== null ? $country->getId() : null; + + return [ + 'family_name' => $this->callIfExists($source, 'getName01'), + 'given_name' => $this->callIfExists($source, 'getName02'), + 'family_name_kana' => $this->callIfExists($source, 'getKana01'), + 'given_name_kana' => $this->callIfExists($source, 'getKana02'), + 'company' => $this->callIfExists($source, 'getCompanyName'), + 'postal_code' => $this->callIfExists($source, 'getPostalCode'), + 'region' => $this->getRegionFromPref($this->extractPref($source)), + 'address1' => $this->callIfExists($source, 'getAddr01'), + 'address2' => $this->callIfExists($source, 'getAddr02'), + 'country' => $this->getAlpha2FromCountryId($countryId), + 'phone' => $this->extractPhoneNumber($source), + ]; + } + + private function extractCountry(Customer|CustomerAddress|Shipping $source): ?Country + { + // Customer / CustomerAddress / Shipping いずれも getCountry() を持つ。 + return $source->getCountry(); + } + + private function extractPref(Customer|CustomerAddress|Shipping $source): ?Pref + { + // Customer / CustomerAddress / Shipping いずれも getPref() を持つ。 + return $source->getPref(); + } + + /** + * 電話番号を取得する。 + * Customer / CustomerAddress / Shipping いずれも getPhoneNumber() を持つ。 + */ + private function extractPhoneNumber(Customer|CustomerAddress|Shipping $source): ?string + { + return $source->getPhoneNumber(); + } + + /** + * 指定 getter が存在すれば呼び出して文字列(or null)を返す。存在しなければ null。 + */ + private function callIfExists(Customer|CustomerAddress|Shipping $source, string $method): ?string + { + if (!method_exists($source, $method)) { + return null; + } + + try { + $value = $source->$method(); + } catch (\TypeError) { + // 一部エンティティ (Shipping の getName01/getKana01 等) は getter の戻り型が + // 非 null の string だが、値が未設定だと TypeError になる。null 扱いにする。 + return null; + } + + return $value === null ? null : (string) $value; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClient.php b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClient.php new file mode 100644 index 00000000000..d442ea03815 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClient.php @@ -0,0 +1,221 @@ +assertEnabled(); + + $payload = []; + if ($targetCountry !== null && $targetCountry !== '') { + $payload['target_country'] = $targetCountry; + } + + return $this->request('POST', '/feeds', $payload); + } + + public function getFeed(string $id): array + { + $this->assertEnabled(); + + return $this->request('GET', '/feeds/'.rawurlencode($id), null); + } + + public function getFeedProducts(string $id): array + { + $this->assertEnabled(); + + $response = $this->request('GET', '/feeds/'.rawurlencode($id).'/products', null); + + $products = $response['products'] ?? null; + if (!\is_array($products)) { + throw new AcpFeedTransportException('ACP Feed API getFeedProducts response is missing the products array.'); + } + + /** @var array> $products */ + return array_values($products); + } + + public function upsertProducts(string $id, array $products): bool + { + $this->assertEnabled(); + + // 送信前に各 Product を schema.feed.json で検証する (client 側検証が主)。 + foreach ($products as $product) { + $this->validator->validateProduct($product); + } + + $response = $this->request('PATCH', '/feeds/'.rawurlencode($id).'/products', ['products' => array_values($products)]); + + return (bool) ($response['accepted'] ?? false); + } + + /** + * 全置換 push: products.jsonl / metadata.json を生成し、POST /feeds で feed を登録するところまで行う. + * + * jsonl / metadata の取り込み自体は OpenAI 側の責務であり、本メソッドは生成物と + * 登録した FeedMetadata を返す。生成した metadata は push 前に schema 検証する。 + * + * @return array{products_jsonl: string, metadata: array, feed: array, product_count: int} + */ + public function pushFullReplacement(string $feedId, ?\DateTimeImmutable $updatedAt = null): array + { + $this->assertEnabled(); + + $stream = fopen('php://temp', 'w+'); + if ($stream === false) { + throw new AcpFeedException('Unable to open a temporary stream for ACP feed generation.'); + } + + try { + $count = $this->generator->generateProductsJsonl($stream); + rewind($stream); + $jsonl = stream_get_contents($stream); + if ($jsonl === false) { + throw new AcpFeedException('Failed to read generated products.jsonl from the temporary stream.'); + } + } finally { + fclose($stream); + } + + $metadata = $this->generator->generateMetadata($feedId, $updatedAt); + $this->validator->validateMetadata($metadata); + + $targetCountry = isset($metadata['target_country']) && \is_string($metadata['target_country']) + ? $metadata['target_country'] + : null; + $feed = $this->createFeed($targetCountry); + + return [ + 'products_jsonl' => $jsonl, + 'metadata' => $metadata, + 'feed' => $feed, + 'product_count' => $count, + ]; + } + + /** + * base URL / api_key の設定有無を確認する. + * + * ACP feed push は outbound 送信であり、認証情報 (base URL + api_key) が設定されている + * ことが実質の有効化条件となる。専用の有効化フラグは設けない。 + * + * @throws AcpFeedException 未設定の場合 + */ + private function assertEnabled(): void + { + if ($this->baseUrl === '') { + throw new AcpFeedException('ACP Feed base URL is not configured (ECCUBE_AGENT_COMMERCE_ACP_FEED_BASE_URL).'); + } + + if ($this->apiKey === '') { + throw new AcpFeedException('ACP Feed API key is not configured (ECCUBE_AGENT_COMMERCE_ACP_FEED_API_KEY).'); + } + } + + /** + * Feed API へ 1 リクエスト送出し、JSON ボディを連想配列で返す. + * + * @param array|null $body JSON ボディ (null は body なし) + * + * @return array + * + * @throws AcpFeedTransportException 4xx/5xx / 通信障害時 + */ + private function request(string $method, string $path, ?array $body): array + { + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->apiKey, + 'Accept' => 'application/json', + ], + ]; + if ($body !== null) { + $options['json'] = $body; + } + + try { + $response = $this->httpClient->request($method, $this->buildUrl($path), $options); + $status = $response->getStatusCode(); + // 4xx/5xx を例外化したいので throw=false で本文を取得する。 + $content = $response->getContent(false); + } catch (HttpClientExceptionInterface $e) { + // 通信障害等。Bearer を含まないメッセージのみ伝播する。 + throw new AcpFeedTransportException(sprintf('ACP Feed API transport error during %s %s.', $method, $path), previous: $e); + } + + $decoded = $content === '' ? [] : json_decode($content, true); + if (!\is_array($decoded)) { + $decoded = []; + } + + if ($status >= 400) { + throw $this->toTransportException($status, $decoded, $method, $path); + } + + /** @var array $decoded */ + return $decoded; + } + + /** + * ACP flat Error shape ({type, code, message, param?}) を例外へ変換する. + * + * @param array $decoded + */ + private function toTransportException(int $status, array $decoded, string $method, string $path): AcpFeedTransportException + { + $error = \is_array($decoded['error'] ?? null) ? $decoded['error'] : $decoded; + + $type = \is_string($error['type'] ?? null) ? $error['type'] : null; + $code = \is_string($error['code'] ?? null) ? $error['code'] : null; + $param = \is_string($error['param'] ?? null) ? $error['param'] : null; + $message = \is_string($error['message'] ?? null) + ? $error['message'] + : sprintf('ACP Feed API returned HTTP %d for %s %s.', $status, $method, $path); + + return new AcpFeedTransportException($message, $status, $type, $code, $param); + } + + private function buildUrl(string $path): string + { + return rtrim($this->baseUrl, '/').'/'.ltrim($path, '/'); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClientInterface.php b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClientInterface.php new file mode 100644 index 00000000000..e76bd41ed1f --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedClientInterface.php @@ -0,0 +1,83 @@ + FeedMetadata {id, target_country?, updated_at?} + * + * @throws AcpFeedException 設定不備 / 機能無効 / HTTP エラー時 + */ + public function createFeed(?string $targetCountry = null): array; + + /** + * GET /feeds/{id} — feed の現状 FeedMetadata を取得する. + * + * @return array FeedMetadata + * + * @throws AcpFeedException + */ + public function getFeed(string $id): array; + + /** + * GET /feeds/{id}/products — feed の現状 Product 一覧を取得する. + * + * @return array> Product 配列 + * + * @throws AcpFeedException + */ + public function getFeedProducts(string $id): array; + + /** + * PATCH /feeds/{id}/products — Product を部分 upsert する. + * + * 送信前に各 Product を schema.feed.json で検証する (サーバ ack が {accepted:bool} と + * 粗いため client 側検証が主)。 + * + * @param array> $products upsert 対象 Product 連想配列の配列 + * + * @return bool サーバ応答の accepted フラグ + * + * @throws AcpFeedException + */ + public function upsertProducts(string $id, array $products): bool; + + /** + * 全置換 push — products.jsonl / metadata.json を生成し、POST /feeds で feed を登録する. + * + * jsonl / metadata の取り込み自体は OpenAI 側の責務であり、本メソッドは生成物と + * 登録した FeedMetadata を返す。生成した metadata は push 前に schema 検証する。 + * + * @return array{products_jsonl: string, metadata: array, feed: array, product_count: int} + * + * @throws AcpFeedException + */ + public function pushFullReplacement(string $feedId, ?\DateTimeImmutable $updatedAt = null): array; +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedGenerator.php b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedGenerator.php new file mode 100644 index 00000000000..88b4f403ac9 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedGenerator.php @@ -0,0 +1,125 @@ +catalogProvider->provideDisplayProducts() as $product) { + $dto = $this->catalogMapper->mapProduct($product); + if ($dto === null) { + continue; + } + + $line = json_encode( + $this->serializer->serialize($dto), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + ); + + if (fwrite($stream, $line."\n") === false) { + throw new AcpFeedException('Failed to write a product line to the feed stream.'); + } + + ++$written; + } + + return $written; + } + + /** + * FeedMetadata 連想配列を生成する. + * + * target_country は BaseInfo の国 (ISO numeric) を alpha-2 へ変換して設定する + * (解決できない場合は省略 = schema 上 optional)。updated_at はテスト容易性のため + * 引数で受けられるようにし、未指定なら呼び出し時刻 (now) を使う。 + * + * @return array {id, target_country?, updated_at} + */ + public function generateMetadata(string $feedId, ?\DateTimeImmutable $updatedAt = null): array + { + if ($feedId === '') { + throw new AcpFeedException('Feed id must not be empty when generating metadata.'); + } + + $updatedAt ??= new \DateTimeImmutable(); + + $metadata = [ + 'id' => $feedId, + 'updated_at' => $updatedAt->format(\DateTimeInterface::RFC3339), + ]; + + $targetCountry = $this->resolveTargetCountry(); + if ($targetCountry !== null) { + $metadata['target_country'] = $targetCountry; + } + + return $metadata; + } + + /** + * BaseInfo の国 (ISO numeric id) を alpha-2 へ変換する. 解決不能なら null. + */ + private function resolveTargetCountry(): ?string + { + $baseInfo = $this->baseInfoRepository->get(); + + $country = $baseInfo->getCountry(); + if ($country === null) { + return null; + } + + return $this->addressMappingService->getAlpha2FromCountryId($country->getId()); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializer.php b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializer.php new file mode 100644 index 00000000000..f261df8716b --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializer.php @@ -0,0 +1,190 @@ + + */ + public function serialize(AgentCatalogItemDto $product): array + { + // required: id, variants + $result = [ + 'id' => $product->id, + 'variants' => array_map( + $this->serializeVariant(...), + $product->variants, + ), + ]; + + if ($product->title !== '') { + $result['title'] = $product->title; + } + + $description = $this->serializeDescription($product->description); + if ($description !== null) { + $result['description'] = $description; + } + + if ($product->url !== null && $product->url !== '') { + $result['url'] = $product->url; + } + + $media = $this->serializeMediaList($product->media); + if ($media !== []) { + $result['media'] = $media; + } + + return $result; + } + + /** + * AgentCatalogVariantDto を ACP Variant 連想配列へ変換する. + * + * @return array + */ + public function serializeVariant(AgentCatalogVariantDto $variant): array + { + // required: id, title + $result = [ + 'id' => $variant->id, + 'title' => $variant->title, + 'price' => [ + 'amount' => $variant->priceMinorUnits, + 'currency' => $variant->currency, + ], + 'availability' => [ + 'available' => $variant->available, + 'status' => $variant->availabilityStatus->value, + ], + ]; + + $description = $this->serializeDescription($variant->description); + if ($description !== null) { + $result['description'] = $description; + } + + if ($variant->url !== null && $variant->url !== '') { + $result['url'] = $variant->url; + } + + if ($variant->listPriceMinorUnits !== null) { + $result['list_price'] = [ + 'amount' => $variant->listPriceMinorUnits, + 'currency' => $variant->currency, + ]; + } + + $options = []; + foreach ($variant->options as $option) { + $options[] = [ + 'name' => $option->name, + 'value' => $option->value, + ]; + } + if ($options !== []) { + $result['variant_options'] = $options; + } + + $barcodes = []; + foreach ($variant->barcodes as $barcode) { + $barcodes[] = [ + 'type' => $barcode->type, + 'value' => $barcode->value, + ]; + } + if ($barcodes !== []) { + $result['barcodes'] = $barcodes; + } + + $media = $this->serializeMediaList($variant->media); + if ($media !== []) { + $result['media'] = $media; + } + + return $result; + } + + /** + * @param AgentCatalogMediaDto[] $mediaList + * + * @return array> + */ + private function serializeMediaList(array $mediaList): array + { + $result = []; + foreach ($mediaList as $media) { + // required: type, url + $item = [ + 'type' => $media->type, + 'url' => $media->url, + ]; + if ($media->altText !== null && $media->altText !== '') { + $item['alt_text'] = $media->altText; + } + if ($media->width !== null) { + $item['width'] = $media->width; + } + if ($media->height !== null) { + $item['height'] = $media->height; + } + $result[] = $item; + } + + return $result; + } + + /** + * Description DTO を ACP Description 連想配列へ変換する. + * 全フィールド null (= 空) の場合は null を返し、呼び出し側で出力対象から除外させる + * (schema は minProperties:1)。 + * + * @return array|null + */ + private function serializeDescription(?AgentCatalogDescriptionDto $description): ?array + { + if ($description === null || $description->isEmpty()) { + return null; + } + + $result = []; + if ($description->plain !== null && $description->plain !== '') { + $result['plain'] = $description->plain; + } + if ($description->html !== null && $description->html !== '') { + $result['html'] = $description->html; + } + if ($description->markdown !== null && $description->markdown !== '') { + $result['markdown'] = $description->markdown; + } + + return $result === [] ? null : $result; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedValidator.php b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedValidator.php new file mode 100644 index 00000000000..d247fa3f4b3 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Acp/AcpFeedValidator.php @@ -0,0 +1,158 @@ +#/$defs/"} を + * 与えて単一定義へ検証する。push 前に必ず通すことで不正データ送出を防ぐ。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json + */ +class AcpFeedValidator +{ + private ?object $schema = null; + + private ?string $schemaId = null; + + public function __construct( + private readonly string $schemaPath = __DIR__.'/../../../../Resource/AgentCommerce/Acp/schema.feed.json', + ) { + } + + /** + * FeedMetadata 連想配列を schema.feed.json の $defs/FeedMetadata に対し検証する. + * + * @param array $metadata + * + * @throws AcpFeedValidationException 適合しない場合 + */ + public function validateMetadata(array $metadata): void + { + $this->validateAgainstDef($metadata, 'FeedMetadata'); + } + + /** + * Product 連想配列を schema.feed.json の $defs/Product に対し検証する. + * + * @param array $product + * + * @throws AcpFeedValidationException 適合しない場合 + */ + public function validateProduct(array $product): void + { + $this->validateAgainstDef($product, 'Product'); + } + + /** + * 値を schema バンドルの指定 $defs エントリに対し検証する. + * + * @param array $value + * + * @throws AcpFeedValidationException 適合しない場合 + */ + private function validateAgainstDef(array $value, string $def): void + { + $schema = $this->loadSchema(); + + $storage = new SchemaStorage(); + $storage->addSchema((string) $this->schemaId, $schema); + + $validator = new Validator(new Factory($storage)); + + // 連想配列を JSON Schema が扱える stdClass ツリーへ変換する。 + // 空配列は JSON では [] (array) のままで良いが、空オブジェクトは生じない想定。 + $data = $this->toObject($value); + + $refSchema = (object) ['$ref' => $this->schemaId.'#/$defs/'.$def]; + + $validator->validate($data, $refSchema); + + if (!$validator->isValid()) { + /** @var array $errors */ + $errors = $validator->getErrors(); + $summary = implode('; ', array_map( + static fn (array $e): string => sprintf('%s: %s', $e['property'] ?? $e['pointer'] ?? '(root)', $e['message'] ?? ''), + $errors, + )); + + throw new AcpFeedValidationException(sprintf('ACP Feed %s payload does not conform to schema.feed.json: %s', $def, $summary), $errors); + } + } + + /** + * 連想配列を json_decode(json_encode(...)) で stdClass / array のツリーへ正規化する. + * + * @param array $value + */ + private function toObject(array $value): \stdClass + { + $encoded = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $decoded = json_decode($encoded, false, 512, JSON_THROW_ON_ERROR); + + if (!$decoded instanceof \stdClass) { + throw new AcpFeedException('ACP Feed payload must decode to an object.'); + } + + return $decoded; + } + + /** + * schema バンドルを読み込み $id を確定させる (初回のみ I/O). + */ + private function loadSchema(): object + { + if ($this->schema !== null) { + return $this->schema; + } + + $path = $this->resolveSchemaPath(); + $contents = @file_get_contents($path); + if ($contents === false) { + throw new AcpFeedException(sprintf('Unable to read ACP feed schema file: %s', $path)); + } + + $decoded = json_decode($contents, false, 512, JSON_THROW_ON_ERROR); + if (!$decoded instanceof \stdClass) { + throw new AcpFeedException('ACP feed schema must decode to an object.'); + } + + $this->schema = $decoded; + $this->schemaId = isset($decoded->{'$id'}) && \is_string($decoded->{'$id'}) + ? $decoded->{'$id'} + : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; + + return $this->schema; + } + + private function resolveSchemaPath(): string + { + // コンストラクタ既定値の相対パスを正規化する。明示指定があればそのまま使う。 + $real = realpath($this->schemaPath); + if ($real !== false) { + return $real; + } + + // realpath に失敗してもファイルが存在すれば渡されたパスを使う。 + return $this->schemaPath; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogBarcodeDto.php b/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogBarcodeDto.php new file mode 100644 index 00000000000..252cf0aeb03 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogBarcodeDto.php @@ -0,0 +1,30 @@ +plain === null && $this->html === null && $this->markdown === null; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogItemDto.php b/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogItemDto.php new file mode 100644 index 00000000000..ef98053b58a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/AgentCatalogItemDto.php @@ -0,0 +1,42 @@ + 0 -> IN_STOCK + * - stock_unlimited でなく stock <= 0 / null -> OUT_OF_STOCK + * + * LIMITED_STOCK / BACKORDER / PREORDER / DISCONTINUED は仕様準拠のため定義しているが + * 標準では出力しない (Customize で CatalogMapper を decoration して使う拡張ポイント。 + * 例: 少量在庫 -> LIMITED_STOCK、ProductStatus::DISPLAY_ABOLISHED -> DISCONTINUED、 + * 入荷予定 -> BACKORDER / PREORDER)。EC-CUBE 状態との対応表は CatalogMapper の docblock を参照。 + * + * @see CatalogMapper + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json + */ +enum AvailabilityStatus: string +{ + case IN_STOCK = 'in_stock'; + case LIMITED_STOCK = 'limited_stock'; + case BACKORDER = 'backorder'; + case PREORDER = 'preorder'; + case OUT_OF_STOCK = 'out_of_stock'; + case DISCONTINUED = 'discontinued'; +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/CatalogMapper.php b/src/Eccube/Service/AgentCommerce/Catalog/CatalogMapper.php new file mode 100644 index 00000000000..ffa78611ae2 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/CatalogMapper.php @@ -0,0 +1,350 @@ + price02 のとき (実際に割引がある場合) のみ** list_price を出力する。barcodes は標準では出力しない + * (Customize seam)。画像 URL / 商品詳細 URL は絶対 URL で出力する。 + * + * ## EC-CUBE 状態 → 出力 / AvailabilityStatus の対応 (標準実装) + * + * (1) カタログ出力対象の判定 (出るか / 出ないか): + * + * | EC-CUBE 状態 | 判定 | 出力 | + * |---------------------------------------|-------------------------------|-------------------| + * | ProductStatus::DISPLAY_SHOW (=1) | isDisplayed() = true | 含める | + * | ProductStatus::DISPLAY_HIDE (=2) | isDisplayed() = false | 除外 (mapProduct=null) | + * | ProductStatus::DISPLAY_ABOLISHED (=3) | isDisplayed() = false | 除外 | + * | ProductClass::isVisible() = false | getDisplayProductClasses 除外 | その variant のみ除外 | + * + * (2) availability の判定 (出力対象 variant のみ。available と status は連動): + * + * | stock_unlimited | stock (ProductClass::getStock) | available | availabilityStatus | + * |-----------------|--------------------------------|-----------|--------------------| + * | true (無制限) | 値は無視 | true | in_stock | + * | false | > 0 | true | in_stock | + * | false | <= 0 または null | false | out_of_stock | + * + * 標準で出力する AvailabilityStatus は in_stock / out_of_stock の 2 値のみ。 + * limited_stock / backorder / preorder / discontinued は仕様準拠のため enum に定義しているが + * 標準実装では出力しない (Customize seam: 例 少量在庫→limited_stock、DISPLAY_ABOLISHED→discontinued)。 + * なお ProductStatus は Product 単位、stock_unlimited / stock は ProductClass 単位の判定である。 + * + * @see AvailabilityStatus + */ +class CatalogMapper +{ + public function __construct( + private readonly MinorUnitConverter $minorUnitConverter, + private readonly UrlGeneratorInterface $urlGenerator, + /** save_image アセットの base_path (framework.yaml の assets.packages.save_image と一致). */ + private readonly string $saveImageUrlPath = '/html/upload/save_image', + ) { + } + + /** + * Product を AgentCatalogItemDto へ写す. + * + * 非公開商品、または公開中だが出力可能な ProductClass が 1 件も無い場合は null を返す + * (variants[] は ACP / UCP いずれでも必須・非空のため)。 + */ + public function mapProduct(Product $product): ?AgentCatalogItemDto + { + if (!$this->isDisplayed($product)) { + return null; + } + + $variants = []; + foreach ($this->getDisplayProductClasses($product) as $productClass) { + $variants[] = $this->mapVariant($productClass); + } + + if ($variants === []) { + return null; + } + + $id = $product->getId(); + if ($id === null) { + return null; + } + + return new AgentCatalogItemDto( + id: (string) $id, + title: $product->getName(), + description: $this->mapDescription($product->getDescriptionDetail()), + url: $this->generateProductUrl($id), + media: $this->mapProductMedia($product), + categories: [], + variants: $variants, + ); + } + + /** + * ProductClass を AgentCatalogVariantDto へ写す. + */ + public function mapVariant(ProductClass $productClass): AgentCatalogVariantDto + { + $currency = $productClass->getCurrencyCode(); + + // 店頭 (商品詳細) と同じ税込価格を出力する (日本の総額表示)。 + // 金額は税込 getter (price02_inc_tax / price01_inc_tax) を使い、 + // 設定有無は税別 getter (?string) の null で判定する (定価は未設定があり得る)。 + $priceMinor = $productClass->getPrice02() !== null + ? $this->minorUnitConverter->toMinorUnits($productClass->getPrice02IncTax(), $currency) + : 0; + + // list_price は ACP/UCP では「割引前の参照価格 (pre-discount price)」。 + // price01 (通常価格) > price02 (販売価格) = 実際に割引がある場合のみ出力する + // (price01 <= price02 では誤った割引表示になるため省く)。 + // ※ 店頭 (detail.twig) は price01 が設定されていれば常に通常価格を表示するが、 + // feed の list_price は pre-discount セマンティクスのため割引時のみに限定する。 + $listPriceMinor = null; + if ($productClass->getPrice01() !== null) { + $candidate = $this->minorUnitConverter->toMinorUnits($productClass->getPrice01IncTax(), $currency); + if ($candidate > $priceMinor) { + $listPriceMinor = $candidate; + } + } + + $available = $this->isAvailable($productClass); + $product = $productClass->getProduct(); + $productId = $product?->getId(); + + return new AgentCatalogVariantDto( + id: (string) $productClass->getId(), + title: $product?->getName() ?? '', + priceMinorUnits: $priceMinor, + currency: $currency, + available: $available, + availabilityStatus: $this->resolveAvailabilityStatus($productClass), + sku: $productClass->getCode(), + listPriceMinorUnits: $listPriceMinor, + description: null, + url: $productId !== null ? $this->generateProductUrl($productId) : null, + options: $this->mapOptions($productClass), + barcodes: $this->mapBarcodes(), + media: [], + ); + } + + /** + * 商品が公開中 (ProductStatus::DISPLAY_SHOW) かどうか. + */ + private function isDisplayed(Product $product): bool + { + return $product->getStatus()?->getId() === ProductStatus::DISPLAY_SHOW; + } + + /** + * 出力対象 (visible) の ProductClass を返す. + * + * @return ProductClass[] + */ + private function getDisplayProductClasses(Product $product): array + { + $classes = $product->getProductClasses(); + if ($classes === null) { + return []; + } + + $result = []; + foreach ($classes as $productClass) { + if ($productClass->isVisible()) { + $result[] = $productClass; + } + } + + return $result; + } + + /** + * 在庫状況から available (購入可否) を判定する. + * + * 在庫無制限 -> true / 在庫あり (stock > 0) -> true / それ以外 -> false。 + */ + private function isAvailable(ProductClass $productClass): bool + { + if ($productClass->isStockUnlimited()) { + return true; + } + + return $this->stockQuantity($productClass) > 0; + } + + /** + * 在庫状況を AvailabilityStatus へ写す. + * + * 無制限 / 在庫あり -> in_stock、在庫なし -> out_of_stock。 + * (limited_stock の閾値は標準では設けない) + */ + private function resolveAvailabilityStatus(ProductClass $productClass): AvailabilityStatus + { + if ($productClass->isStockUnlimited()) { + return AvailabilityStatus::IN_STOCK; + } + + return $this->stockQuantity($productClass) > 0 + ? AvailabilityStatus::IN_STOCK + : AvailabilityStatus::OUT_OF_STOCK; + } + + /** + * 在庫数を int で取得する (getStock() は ?string のため正規化). + */ + private function stockQuantity(ProductClass $productClass): int + { + $stock = $productClass->getStock(); + + return $stock === null ? 0 : (int) $stock; + } + + /** + * 規格分類 (ClassCategory1 / ClassCategory2) を variant option へ写す. + * + * @return AgentCatalogOptionDto[] + */ + private function mapOptions(ProductClass $productClass): array + { + $options = []; + foreach ([$productClass->getClassCategory1(), $productClass->getClassCategory2()] as $classCategory) { + $option = $this->mapOption($classCategory); + if ($option !== null) { + $options[] = $option; + } + } + + return $options; + } + + private function mapOption(?ClassCategory $classCategory): ?AgentCatalogOptionDto + { + if ($classCategory === null) { + return null; + } + + $className = $classCategory->getClassName(); + if ($className === null) { + return null; + } + + return new AgentCatalogOptionDto( + name: $className->getName(), + value: $classCategory->getName(), + ); + } + + /** + * barcodes を写す. 標準では GTIN フィールドが無いため常に空配列 (Customize seam). + * + * @return AgentCatalogBarcodeDto[] + */ + private function mapBarcodes(): array + { + return []; + } + + /** + * 商品説明テキストを description DTO へ写す. + */ + private function mapDescription(?string $detail): ?AgentCatalogDescriptionDto + { + if ($detail === null || trim($detail) === '') { + return null; + } + + return new AgentCatalogDescriptionDto(html: $detail); + } + + /** + * 商品画像を media DTO へ写す (sort_no 昇順、絶対 URL). + * + * @return AgentCatalogMediaDto[] + */ + private function mapProductMedia(Product $product): array + { + $images = $product->getProductImage()->toArray(); + usort($images, static fn (ProductImage $a, ProductImage $b): int => $a->getSortNo() <=> $b->getSortNo()); + + $media = []; + foreach ($images as $image) { + $media[] = new AgentCatalogMediaDto( + url: $this->generateImageUrl($image->getFileName()), + type: 'image', + ); + } + + return $media; + } + + /** + * 商品詳細ページの絶対 URL を生成する. + */ + private function generateProductUrl(int $productId): string + { + return $this->urlGenerator->generate( + 'product_detail', + ['id' => $productId], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + } + + /** + * 商品画像の絶対 URL を生成する. + * + * 画像はルーティング対象でない静的ファイルのため generateUrl() を使えない。 + * 代わりに RequestContext から scheme / host / port / baseUrl を取得して + * generateUrl(ABSOLUTE_URL) と同等の絶対 URL を組み立てる。 + * + * RequestContext は RouterListener が Request から構築するため、リバースプロキシ配下では + * TRUSTED_PROXIES の設定により X-Forwarded-* が反映された外部 scheme/host/port を保持する + * (本メソッドが個別に X-Forwarded-* を解釈するわけではなく、generateUrl と同じ経路で考慮される)。 + * CLI (Request 無し) では router.request_context.* の設定値が使われる。 + */ + private function generateImageUrl(string $fileName): string + { + $context = $this->urlGenerator->getContext(); + $scheme = $context->getScheme(); + $host = $context->getHost(); + + $port = ''; + if ($scheme === 'http' && $context->getHttpPort() !== 80) { + $port = ':'.$context->getHttpPort(); + } elseif ($scheme === 'https' && $context->getHttpsPort() !== 443) { + $port = ':'.$context->getHttpsPort(); + } + + // baseUrl (サブディレクトリ設置時のアプリ接頭辞) を前置し、ルート URL と整合させる。 + $path = $context->getBaseUrl().'/'.trim($this->saveImageUrlPath, '/').'/'.ltrim($fileName, '/'); + + return sprintf('%s://%s%s%s', $scheme, $host, $port, $path); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/CatalogProvider.php b/src/Eccube/Service/AgentCommerce/Catalog/CatalogProvider.php new file mode 100644 index 00000000000..9b295941a61 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/CatalogProvider.php @@ -0,0 +1,62 @@ + + */ + public function provideDisplayProducts(): iterable + { + $qb = $this->productRepository->createQueryBuilder('p') + ->where('p.Status = :status') + ->setParameter('status', ProductStatus::DISPLAY_SHOW) + ->orderBy('p.id', 'ASC'); + + $count = 0; + foreach ($qb->getQuery()->toIterable() as $product) { + yield $product; + + if (++$count % self::CLEAR_INTERVAL === 0) { + $this->entityManager->clear(); + } + } + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/CatalogProviderInterface.php b/src/Eccube/Service/AgentCommerce/Catalog/CatalogProviderInterface.php new file mode 100644 index 00000000000..1978d2b346d --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/CatalogProviderInterface.php @@ -0,0 +1,34 @@ + + */ + public function provideDisplayProducts(): iterable; +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedException.php b/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedException.php new file mode 100644 index 00000000000..59ebddc2b05 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedException.php @@ -0,0 +1,23 @@ +statusCode; + } + + public function getErrorType(): ?string + { + return $this->errorType; + } + + public function getErrorCode(): ?string + { + return $this->errorCode; + } + + public function getParam(): ?string + { + return $this->param; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedValidationException.php b/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedValidationException.php new file mode 100644 index 00000000000..494ecd26074 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Exception/AcpFeedValidationException.php @@ -0,0 +1,41 @@ + $violations + * justinrainbow/json-schema の getErrors() 形式の違反詳細 + */ + public function __construct( + string $message, + private readonly array $violations = [], + ) { + parent::__construct($message); + } + + /** + * @return array + */ + public function getViolations(): array + { + return $this->violations; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolver.php b/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolver.php new file mode 100644 index 00000000000..d73eb8c3043 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolver.php @@ -0,0 +1,54 @@ +productClassRepository->findOneBy(['code' => $sku]); + } + + public function resolveByProductClassId(int $productClassId): ?ProductClass + { + return $this->productClassRepository->find($productClassId); + } + + /** + * 標準では barcode 解決手段を持たないため常に null。 + * Customize で GTIN マスタ等を導入した場合に override する。 + */ + public function resolveByBarcode(string $barcode): ?ProductClass + { + return null; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolverInterface.php b/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolverInterface.php new file mode 100644 index 00000000000..83510422bb4 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/ProductReferenceResolverInterface.php @@ -0,0 +1,45 @@ +/agent-commerce/catalog 配下)。 + * + * キャッシュミス時の再生成スタンピードを防ぐため symfony/lock の LockFactory で + * 鍵ごとに排他し、ロック取得失敗時はブロックして待機する (取得後に再度キャッシュ確認)。 + * + * 値は gzip 圧縮済みの本文をそのまま保持してよい (Content-Encoding: gzip での再利用)。 + */ +class UcpCatalogCache +{ + /** + * キャッシュ名前空間. + */ + private const NAMESPACE = 'agent_commerce_ucp_catalog'; + + private readonly FilesystemAdapter $adapter; + + /** + * @param int $defaultLifetime キャッシュ TTL (秒). 0 で無期限 + */ + public function __construct( + private readonly LockFactory $lockFactory, + string $cacheDir, + private readonly int $defaultLifetime = 3600, + ) { + $this->adapter = new FilesystemAdapter( + self::NAMESPACE, + $this->defaultLifetime, + rtrim($cacheDir, '/').'/agent-commerce/catalog', + ); + } + + /** + * リクエストボディから安定したキャッシュキーを生成する. + * + * @param string $operation search / lookup / product 等のオペレーション識別子 + * @param string $rawBody リクエストボディ (生 JSON) + */ + public function buildKey(string $operation, string $rawBody): string + { + return $operation.'_'.hash('sha256', $rawBody); + } + + /** + * キャッシュ済みの値を取得する. 未キャッシュなら null. + */ + public function get(string $key): ?string + { + $item = $this->adapter->getItem($key); + if (!$item->isHit()) { + return null; + } + + $value = $item->get(); + + return is_string($value) ? $value : null; + } + + /** + * 値をキャッシュへ格納する. + */ + public function set(string $key, string $value): void + { + $item = $this->adapter->getItem($key); + $item->set($value); + $item->expiresAfter($this->defaultLifetime > 0 ? $this->defaultLifetime : null); + $this->adapter->save($item); + } + + /** + * キャッシュ取得 → ミス時のみ生成、を stampede 防止付きで実行する. + * + * 1. キャッシュヒットなら即返す。 + * 2. ミスなら鍵ごとのロックを取得 (取得まで待機)。 + * 3. ロック取得後に再度キャッシュ確認 (待機中に他プロセスが生成済みなら再利用)。 + * 4. 依然ミスなら $generator で生成して格納し返す。 + * + * @param callable():string $generator キャッシュミス時に値を生成するコールバック + */ + public function getOrCompute(string $key, callable $generator): string + { + $cached = $this->get($key); + if ($cached !== null) { + return $cached; + } + + $lock = $this->lockFactory->createLock('ucp_catalog_'.$key); + $lock->acquire(true); + try { + // 待機中に他プロセスが生成済みの可能性があるため再確認 + $cached = $this->get($key); + if ($cached !== null) { + return $cached; + } + + $value = $generator(); + $this->set($key, $value); + + return $value; + } finally { + $lock->release(); + } + } + + /** + * 全キャッシュを破棄する (UCP「キャッシュクリア」相当). + */ + public function clear(): void + { + $this->adapter->clear(); + } + + /** + * 指定キーへ値を事前生成して格納する (UCP「生成」相当). + * + * @param callable():string $generator 値を生成するコールバック + */ + public function warmup(string $key, callable $generator): void + { + $this->set($key, $generator()); + } + + /** + * 内部アダプタの CacheItem を直接扱う必要がある場合の入口 (テスト / 高度な制御用). + */ + public function getItem(string $key): CacheItemInterface + { + return $this->adapter->getItem($key); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializer.php b/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializer.php new file mode 100644 index 00000000000..14d9fbfe7d1 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializer.php @@ -0,0 +1,233 @@ + + */ + public function serialize(AgentCatalogItemDto $product): array + { + $variants = array_map( + $this->serializeVariant(...), + $product->variants, + ); + + // required: id, title, description, price_range, variants + $result = [ + 'id' => $product->id, + 'title' => $product->title, + 'description' => $this->serializeDescription($product->description), + 'price_range' => $this->buildPriceRange($product->variants), + 'variants' => $variants, + ]; + + if ($product->url !== null && $product->url !== '') { + $result['url'] = $product->url; + } + + if ($product->categories !== []) { + // UCP の categories は category.json に適合する必要があり、required は {value}。 + $result['categories'] = array_map( + static fn (string $name): array => ['value' => $name], + $product->categories, + ); + } + + $media = $this->serializeMediaList($product->media); + if ($media !== []) { + $result['media'] = $media; + } + + return $result; + } + + /** + * AgentCatalogVariantDto を UCP Variant 連想配列へ変換する. + * + * @return array + */ + public function serializeVariant(AgentCatalogVariantDto $variant): array + { + // required: id, title, description, price + $result = [ + 'id' => $variant->id, + 'title' => $variant->title, + 'description' => $this->serializeDescription($variant->description), + 'price' => [ + 'amount' => $variant->priceMinorUnits, + 'currency' => $variant->currency, + ], + 'availability' => [ + 'available' => $variant->available, + 'status' => $variant->availabilityStatus->value, + ], + ]; + + if ($variant->sku !== null && $variant->sku !== '') { + $result['sku'] = $variant->sku; + } + + if ($variant->url !== null && $variant->url !== '') { + $result['url'] = $variant->url; + } + + if ($variant->listPriceMinorUnits !== null) { + $result['list_price'] = [ + 'amount' => $variant->listPriceMinorUnits, + 'currency' => $variant->currency, + ]; + } + + $options = []; + foreach ($variant->options as $option) { + // UCP の Variant.options は selected_option.json に適合する必要があり、 + // required は {name, label}。AgentCatalogOptionDto の value を label へ写す。 + $options[] = [ + 'name' => $option->name, + 'label' => $option->value, + ]; + } + if ($options !== []) { + $result['options'] = $options; + } + + $barcodes = []; + foreach ($variant->barcodes as $barcode) { + $barcodes[] = [ + 'type' => $barcode->type, + 'value' => $barcode->value, + ]; + } + if ($barcodes !== []) { + $result['barcodes'] = $barcodes; + } + + $media = $this->serializeMediaList($variant->media); + if ($media !== []) { + $result['media'] = $media; + } + + return $result; + } + + /** + * variants[] の price から price_range (min / max) を算出する. + * + * variants が空の場合 (理論上 mapProduct が null を返すため発生しない) は + * amount 0 の縮退 range を返す。currency は先頭 variant のものを採用する。 + * + * @param AgentCatalogVariantDto[] $variants + * + * @return array{min: array{amount: int, currency: string}, max: array{amount: int, currency: string}} + */ + private function buildPriceRange(array $variants): array + { + if ($variants === []) { + $zero = ['amount' => 0, 'currency' => 'JPY']; + + return ['min' => $zero, 'max' => $zero]; + } + + $currency = $variants[0]->currency; + $min = $variants[0]->priceMinorUnits; + $max = $variants[0]->priceMinorUnits; + foreach ($variants as $variant) { + $min = min($min, $variant->priceMinorUnits); + $max = max($max, $variant->priceMinorUnits); + } + + return [ + 'min' => ['amount' => $min, 'currency' => $currency], + 'max' => ['amount' => $max, 'currency' => $currency], + ]; + } + + /** + * @param AgentCatalogMediaDto[] $mediaList + * + * @return array> + */ + private function serializeMediaList(array $mediaList): array + { + $result = []; + foreach ($mediaList as $media) { + $item = [ + 'type' => $media->type, + 'url' => $media->url, + ]; + if ($media->altText !== null && $media->altText !== '') { + $item['alt_text'] = $media->altText; + } + if ($media->width !== null) { + $item['width'] = $media->width; + } + if ($media->height !== null) { + $item['height'] = $media->height; + } + $result[] = $item; + } + + return $result; + } + + /** + * Description DTO を UCP Description 連想配列へ変換する. + * + * UCP では Product / Variant の description は必須かつ minProperties:1。 + * DTO が空 (全 null) の場合でも plain を空文字で出力すると minProperties:1 を満たさない + * ため、最低 1 形式 (plain) を保証する。html / markdown も値があれば付与する。 + * + * @return array + */ + private function serializeDescription(?AgentCatalogDescriptionDto $description): array + { + if ($description === null || $description->isEmpty()) { + return ['plain' => '']; + } + + $result = []; + if ($description->plain !== null && $description->plain !== '') { + $result['plain'] = $description->plain; + } + if ($description->html !== null && $description->html !== '') { + $result['html'] = $description->html; + } + if ($description->markdown !== null && $description->markdown !== '') { + $result['markdown'] = $description->markdown; + } + + return $result === [] ? ['plain' => ''] : $result; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogResponseBuilder.php b/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogResponseBuilder.php new file mode 100644 index 00000000000..a334a61dc94 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Catalog/Ucp/UcpCatalogResponseBuilder.php @@ -0,0 +1,123 @@ +|null $pagination response pagination (has_next_page 必須) + * + * @return array + */ + public function buildSearchResponse(array $items, ?array $pagination = null): array + { + $response = [ + 'ucp' => $this->buildUcpEnvelope(), + 'products' => $this->serializeItems($items), + 'messages' => [], + ]; + + if ($pagination !== null) { + $response['pagination'] = $pagination; + } + + return $response; + } + + /** + * POST /catalog/lookup のレスポンス本文を組み立てる. + * + * @param AgentCatalogItemDto[] $items + * + * @return array + */ + public function buildLookupResponse(array $items): array + { + return [ + 'ucp' => $this->buildUcpEnvelope(), + 'products' => $this->serializeItems($items), + 'messages' => [], + ]; + } + + /** + * POST /catalog/product のレスポンス本文を組み立てる. + * + * @return array + */ + public function buildProductResponse(AgentCatalogItemDto $item): array + { + return [ + 'ucp' => $this->buildUcpEnvelope(), + 'product' => $this->serializer->serialize($item), + 'messages' => [], + ]; + } + + /** + * ucp ラッパー (response_catalog_schema) を組み立てる. + * + * @return array{version: string, status: string} + */ + private function buildUcpEnvelope(): array + { + return [ + 'version' => self::UCP_VERSION, + 'status' => 'success', + ]; + } + + /** + * AgentCatalogItemDto の配列を UCP Product 連想配列の配列へ変換する. + * + * @param AgentCatalogItemDto[] $items + * + * @return array> + */ + private function serializeItems(array $items): array + { + return array_map( + $this->serializer->serialize(...), + array_values($items), + ); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php b/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php new file mode 100644 index 00000000000..56dc29d13b2 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Discovery/EmptyPaymentHandlerRegistry.php @@ -0,0 +1,48 @@ + $registries tagged service 群 (自身は含めない) + */ + public function __construct(private readonly iterable $registries = []) + { + } + + /** + * {@inheritdoc} + */ + public function collect(): array + { + $handlers = []; + foreach ($this->registries as $registry) { + foreach ($registry->collect() as $key => $value) { + $handlers[$key] = $value; + } + } + + return $handlers; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php b/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php new file mode 100644 index 00000000000..6b28f24af92 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Discovery/PaymentHandlerRegistryInterface.php @@ -0,0 +1,41 @@ +> reverse-domain キーのレジストリ (既定は空) + */ + public function collect(): array; +} diff --git a/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php b/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php new file mode 100644 index 00000000000..6efd0568475 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Discovery/UcpProfileBuilder.php @@ -0,0 +1,144 @@ + + */ + public function build(): array + { + // Catalog は公開商品データのため常時公開・常時広告する。 + // checkout capability は ucp_checkout_enabled に応じて #6574 で追加する。 + $ucp = [ + 'version' => self::UCP_VERSION, + 'services' => $this->buildServices(), + 'payment_handlers' => $this->buildPaymentHandlers(), + 'capabilities' => $this->buildCapabilities(), + ]; + + $profile = [ + 'ucp' => $ucp, + ]; + + $signingKeys = $this->messageSigner->getPublicJwks(); + if ($signingKeys !== []) { + $profile['signing_keys'] = $signingKeys; + } + + return $profile; + } + + /** + * services レジストリを組み立てる. Catalog REST service を常時宣言する. + * + * @return array> reverse-domain キーのレジストリ + */ + private function buildServices(): array + { + // endpoint はベースパスのみ宣言する (個別 RPC パスは capability/schema 側で定義). + // catalog の各エンドポイントは同一プレフィックス配下のため search ルートから親パスを導出する. + $searchUrl = $this->urlGenerator->generate( + 'agent_ucp_catalog_search', + [], + UrlGeneratorInterface::ABSOLUTE_URL + ); + // ".../catalog/search" から service ベース ".../catalog" を導く. + $endpoint = preg_replace('#/search$#', '', $searchUrl) ?? $searchUrl; + + return [ + self::CATALOG_SERVICE_KEY => [ + 'version' => self::UCP_VERSION, + 'transport' => 'rest', + 'endpoint' => $endpoint, + ], + ]; + } + + /** + * capabilities レジストリを組み立てる. Catalog の search/lookup を常時宣言する. + * + * @return array> reverse-domain キーのレジストリ + */ + private function buildCapabilities(): array + { + return [ + self::CATALOG_SEARCH_CAPABILITY => [ + 'version' => self::UCP_VERSION, + 'schema' => 'https://ucp.dev/schemas/shopping/catalog_search.json', + ], + self::CATALOG_LOOKUP_CAPABILITY => [ + 'version' => self::UCP_VERSION, + 'schema' => 'https://ucp.dev/schemas/shopping/catalog_lookup.json', + ], + ]; + } + + /** + * payment_handlers レジストリを組み立てる (寄与が無ければ空オブジェクト {}). + * + * @return array> + */ + private function buildPaymentHandlers(): array + { + return $this->paymentHandlerRegistry->collect(); + } +} diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php new file mode 100644 index 00000000000..a513f80dc40 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -0,0 +1,117 @@ + 1000 // 0 桁通貨: ×10^0 → 桁は増えない + * ("1000.00", "USD") => 100000 // 2 桁通貨: ×10^2 → 0 が 2 つ増える + * ("12.34", "USD") => 1234 + * ("-15.00", "USD") => -1500 // 割引・返金などの負数も可 + * + * @param string $amount major-unit の金額 (decimal 文字列). 負数可 + * @param string $currency ISO 4217 通貨コード (例: JPY, USD) + */ + public function toMinorUnits(string $amount, string $currency): int + { + $amount = trim($amount); + $digits = $this->getFractionDigits($currency); + + // 符号を分離して非負の値として丸め、最後に符号を戻す (round-half-up を絶対値で行うため)。 + $negative = false; + if (str_starts_with($amount, '-')) { + $negative = true; + $amount = substr($amount, 1); + } elseif (str_starts_with($amount, '+')) { + $amount = substr($amount, 1); + } + + // 空や不正な表記は 0 とみなす。 + if ($amount === '' || $amount === '.') { + return 0; + } + + // scale を桁数 + 1 にして、丸め用の補正を加える。 + $scale = $digits + 1; + $factor = bcpow('10', (string) $digits, 0); + + // amount * 10^digits を計算 (まだ小数を含みうる)。 + $scaled = bcmul($amount, $factor, $scale); + + // round-half-up: 正の値に 0.5 を足してから切り捨て (bcmath は truncate)。 + $rounded = bcadd($scaled, '0.5', 0); + + $result = (int) $rounded; + + return $negative ? -$result : $result; + } + + /** + * minor-unit の整数を major-unit の decimal 文字列へ変換する (toMinorUnits の逆変換). + * + * (1000, "JPY") => "1000" // 0 桁通貨: そのまま + * (100000, "USD") => "1000.00" // 2 桁通貨: 下 2 桁が小数部 + * (1234, "USD") => "12.34" + * (-1500, "USD") => "-15.00" + * + * @param int $minorUnits minor-unit の整数. 負数可 + * @param string $currency ISO 4217 通貨コード + */ + public function toAmountString(int $minorUnits, string $currency): string + { + $digits = $this->getFractionDigits($currency); + + if ($digits === 0) { + return (string) $minorUnits; + } + + $factor = bcpow('10', (string) $digits, 0); + + // bcdiv は scale 桁で丸めずに切り捨てるが、minor->major は割り切れるため scale=digits で正確。 + return bcdiv((string) $minorUnits, $factor, $digits); + } + + /** + * 通貨の小数桁数を ISO 4217 権威データから取得する. + * + * 未知の通貨コードの場合は安全側に倒して 2 桁を返す。 + */ + private function getFractionDigits(string $currency): int + { + $currency = strtoupper(trim($currency)); + + if ($currency !== '' && Currencies::exists($currency)) { + return Currencies::getFractionDigits($currency); + } + + return 2; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php new file mode 100644 index 00000000000..e97450e20ef --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php @@ -0,0 +1,58 @@ +> JWK の配列 + */ + public function getPublicJwks(): array; + + /** + * 現用鍵の Key ID (kid) を返す. + */ + public function getCurrentKid(): string; +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php new file mode 100644 index 00000000000..bfe7e27b4bf --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php @@ -0,0 +1,99 @@ +:" 形式で統一する (例: "ucp:checkout")。 + * "agent:catalog:read" のような旧形式は無効とする。protocol 越境 + * (例: acp トークンで ucp capability を要求) は許可しない。 + */ +class AgentCommerceScopeRegistry +{ + /** + * 正準 scope レジストリ. protocol => 許可する capability の配列. + * + * app/Customize で差し替えやすいようメソッド経由で参照する。 + * + * @var array> + */ + private const CANONICAL_SCOPES = [ + 'acp' => ['checkout', 'catalog'], + 'ucp' => ['checkout', 'cart', 'catalog', 'identity'], + ]; + + /** + * scope 文字列が正準レジストリに存在するか判定する. + * + * "ucp:checkout" などレジストリに定義された ":" のみ true。 + */ + public function isValidScope(string $scope): bool + { + $parts = explode(':', $scope); + + // 正確に protocol:capability の 2 要素でなければ無効 (例: "agent:catalog:read" は 3 要素)。 + if (count($parts) !== 2) { + return false; + } + + [$protocol, $capability] = $parts; + + if ($protocol === '' || $capability === '') { + return false; + } + + return isset(self::CANONICAL_SCOPES[$protocol]) + && in_array($capability, self::CANONICAL_SCOPES[$protocol], true); + } + + /** + * 指定 protocol が持つ全 scope 文字列を返す. + * + * 例: "ucp" => ["ucp:checkout", "ucp:cart", "ucp:catalog", "ucp:identity"] + * + * @return list + */ + public function scopesForProtocol(string $protocol): array + { + if (!isset(self::CANONICAL_SCOPES[$protocol])) { + return []; + } + + return array_map( + static fn (string $capability): string => $protocol.':'.$capability, + self::CANONICAL_SCOPES[$protocol] + ); + } + + /** + * 付与済み scope 群が、指定 protocol/capability の操作を許可しているか判定する. + * + * grantedScopes に ":" が含まれ、かつその scope が + * 正準であり protocol が一致するときのみ true。protocol 越境は false。 + * + * @param list $grantedScopes トークンに付与された scope 文字列の配列 + */ + public function supports(string $protocol, string $capability, array $grantedScopes): bool + { + $required = $protocol.':'.$capability; + + // 要求 scope 自体が正準でなければ許可しない (越境・未定義の capability を排除)。 + if (!$this->isValidScope($required)) { + return false; + } + + return in_array($required, $grantedScopes, true); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php new file mode 100644 index 00000000000..bb94f601745 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php @@ -0,0 +1,83 @@ + $envPathOverrides purpose => 絶対パスの上書きマップ + */ + public function __construct( + private readonly string $projectDir, + private readonly array $envPathOverrides = [], + ) { + } + + /** + * {@inheritdoc} + */ + public function read(string $purpose): ?string + { + $path = $this->resolvePath($purpose); + + if (!is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + return $contents === false ? null : $contents; + } + + /** + * {@inheritdoc} + */ + public function write(string $purpose, string $pem): void + { + $path = $this->resolvePath($purpose); + $dir = \dirname($path); + + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + + file_put_contents($path, $pem); + chmod($path, 0600); + } + + /** + * purpose から鍵ファイルの絶対パスを解決する。 + * + * $envPathOverrides[$purpose] が非空文字ならそのパスを優先し、 + * それ以外は既定パスを使用する。 + */ + private function resolvePath(string $purpose): string + { + $override = $this->envPathOverrides[$purpose] ?? ''; + + if ($override !== '') { + return $override; + } + + return $this->projectDir.'/app/keystore/agent-commerce/'.$purpose.'.key'; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php new file mode 100644 index 00000000000..f6180661371 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php @@ -0,0 +1,40 @@ + grace period 中の旧公開鍵 PEM 群 + */ + private readonly array $gracePublicKeyPems; + + private ?PrivateKey $privateKey = null; + + /** + * @param KeyStoreInterface $keyStore 秘密鍵 PEM の読み書きストア + * @param string $purpose 鍵の用途識別子 (KeyStore のパス解決に使用) + * @param array $gracePublicKeyPems 旧公開鍵 PEM 群 (verify/JWK に含める) + */ + public function __construct( + private readonly KeyStoreInterface $keyStore, + private readonly string $purpose = 'ucp_signing', + array $gracePublicKeyPems = [], + ) { + $this->gracePublicKeyPems = array_values($gracePublicKeyPems); + } + + /** + * {@inheritdoc} + */ + public function sign(string $signatureBase): string + { + $raw = $this->getPrivateKey() + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->sign($signatureBase); + + return $this->base64urlEncode($raw); + } + + /** + * {@inheritdoc} + */ + public function verify(string $signatureBase, string $signature): bool + { + $raw = $this->base64urlDecode($signature); + if ($raw === '') { + return false; + } + + // 現用鍵 + grace period の旧公開鍵すべてに対して検証を試みる. + foreach ($this->collectPublicKeys() as $publicKey) { + try { + $verified = $publicKey + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->verify($signatureBase, $raw); + } catch (\Throwable) { + // 不正な署名長などで例外が出る実装差異を吸収し, 次の鍵へ. + continue; + } + + if ($verified) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function getPublicJwks(): array + { + $jwks = []; + foreach ($this->collectPublicKeys() as $publicKey) { + $jwks[] = $this->toPublicJwk($publicKey); + } + + return $jwks; + } + + /** + * {@inheritdoc} + */ + public function getCurrentKid(): string + { + return $this->thumbprint($this->getCurrentPublicKey()); + } + + /** + * 現用秘密鍵を取得する. 鍵ストアに無ければ EC P-256 を生成して永続化する. + */ + private function getPrivateKey(): PrivateKey + { + if ($this->privateKey instanceof PrivateKey) { + return $this->privateKey; + } + + $pem = $this->keyStore->read($this->purpose); + if ($pem === null || trim($pem) === '') { + /** @var PrivateKey $generated */ + $generated = EC::createKey('secp256r1'); + $pem = $generated->toString('PKCS8'); + $this->keyStore->write($this->purpose, $pem); + $this->privateKey = $generated; + + return $this->privateKey; + } + + $loaded = PublicKeyLoader::load($pem); + if (!$loaded instanceof PrivateKey) { + throw new \RuntimeException(sprintf('鍵ストアの "%s" は EC 秘密鍵ではありません.', $this->purpose)); + } + + $this->privateKey = $loaded; + + return $this->privateKey; + } + + private function getCurrentPublicKey(): PublicKey + { + return $this->getPrivateKey()->getPublicKey(); + } + + /** + * 現用 + grace の公開鍵を返す. + * + * @return array + */ + private function collectPublicKeys(): array + { + $keys = [$this->getCurrentPublicKey()]; + + foreach ($this->gracePublicKeyPems as $pem) { + if (trim($pem) === '') { + continue; + } + $loaded = PublicKeyLoader::load($pem); + if ($loaded instanceof PrivateKey) { + $keys[] = $loaded->getPublicKey(); + } elseif ($loaded instanceof PublicKey) { + $keys[] = $loaded; + } + } + + return $keys; + } + + /** + * 公開鍵を EC JWK (連想配列) に変換する. 秘密鍵パラメータ d は含めない. + * + * @return array + */ + private function toPublicJwk(PublicKey $publicKey): array + { + $coords = $this->extractCoordinates($publicKey); + + $jwk = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => $coords['x'], + 'y' => $coords['y'], + 'use' => 'sig', + 'alg' => 'ES256', + ]; + $jwk['kid'] = $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + + return $jwk; + } + + /** + * 公開鍵から JWK の座標 (x, y; base64url) を抽出する. + * + * 主: phpseclib の toString('JWK') を利用. + * フォールバック (コメント): phpseclib のバージョン差で 'JWK' 出力のキー名が + * 異なる / 取得できない場合は, getEncodedCoordinates() 等で得た + * uncompressed point (0x04 || X(32) || Y(32)) を 32byte ずつに分割し, + * それぞれ base64url する実装に切り替えること. ここではまず JWK 出力を解析し, + * 取得不能な場合に uncompressed point から座標を復元する. + * + * @return array{x: string, y: string} + */ + private function extractCoordinates(PublicKey $publicKey): array + { + $x = null; + $y = null; + + try { + $jwkJson = $publicKey->toString('JWK'); + /** @var array|null $decoded */ + $decoded = json_decode($jwkJson, true); + if (is_array($decoded)) { + // JWK は {keys:[{...}]} か単体 {x,y} のいずれもあり得るため両対応. + if (isset($decoded['keys'][0]) && is_array($decoded['keys'][0])) { + $decoded = $decoded['keys'][0]; + } + if (isset($decoded['x']) && is_string($decoded['x'])) { + $x = $decoded['x']; + } + if (isset($decoded['y']) && is_string($decoded['y'])) { + $y = $decoded['y']; + } + } + } catch (\Throwable) { + // 下のフォールバックで座標を復元する. + } + + if ($x === null || $y === null) { + // phpseclib3 の EC 公開鍵は toString('JWK') で必ず x/y を返すため通常到達しない。 + throw new \RuntimeException('EC 公開鍵から JWK 座標を取得できませんでした.'); + } + + return ['x' => $x, 'y' => $y]; + } + + /** + * RFC 7638 JWK Thumbprint を kid として算出する. + */ + private function thumbprint(PublicKey $publicKey): string + { + $coords = $this->extractCoordinates($publicKey); + + return $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + } + + /** + * RFC 7638: 必須メンバ (crv, kty, x, y) を辞書順・余白なし JSON にして SHA-256 する. + */ + private function thumbprintFromCoordinates(string $x, string $y): string + { + // キー昇順 (crv, kty, x, y), 余白なし. + $canonical = json_encode([ + 'crv' => 'P-256', + 'kty' => 'EC', + 'x' => $x, + 'y' => $y, + ], JSON_UNESCAPED_SLASHES); + + return $this->base64urlEncode(hash('sha256', (string) $canonical, true)); + } + + private function base64urlEncode(string $binary): string + { + return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); + } + + private function base64urlDecode(string $value): string + { + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php new file mode 100644 index 00000000000..5a68eb82751 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -0,0 +1,184 @@ + alpha-2 の + * 解決、mtb_country.csv の全 id がマスタで解決できること、姓名分離・DTO 整形を確認する。 + */ +final class AddressMappingServiceTest extends EccubeTestCase +{ + private ?AddressMappingService $service = null; + + protected function setUp(): void + { + parent::setUp(); + // AddressMappingService はまだ consumer が無く private では除去されるため、 + // services_test.yaml で public 化してコンテナから取得する。 + $this->service = self::getContainer()->get(AddressMappingService::class); + } + + public function testGetAlpha2FromCountryIdKnownIds(): void + { + $this->assertSame('JP', $this->service->getAlpha2FromCountryId(392), 'ISO 3166-1 numeric 392 is Japan -> JP'); + $this->assertSame('US', $this->service->getAlpha2FromCountryId(840), 'ISO 3166-1 numeric 840 is United States -> US'); + $this->assertSame('GB', $this->service->getAlpha2FromCountryId(826), 'ISO 3166-1 numeric 826 is United Kingdom -> GB'); + $this->assertSame('CN', $this->service->getAlpha2FromCountryId(156), 'ISO 3166-1 numeric 156 is China -> CN'); + $this->assertSame('KR', $this->service->getAlpha2FromCountryId(410), 'ISO 3166-1 numeric 410 is Republic of Korea -> KR'); + $this->assertSame('RU', $this->service->getAlpha2FromCountryId(643), 'ISO 3166-1 numeric 643 is Russia -> RU'); + } + + public function testGetAlpha2FromCountryIdNullAndUnknown(): void + { + $this->assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); + $this->assertNull($this->service->getAlpha2FromCountryId(999), 'Unknown numeric id must yield null alpha-2 (no exception)'); + } + + /** + * mtb_country.csv に存在する全 numeric id が mtb_country_iso_code マスタで + * 非 null の alpha-2 に解決できること (どの国でも住所変換が破綻しない保証)。 + */ + public function testAllCountryIdsResolveViaMaster(): void + { + $ids = $this->loadCountryIdsFromCsv(); + $this->assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); + + foreach ($ids as $id) { + $alpha2 = $this->service->getAlpha2FromCountryId($id); + $this->assertNotNull($alpha2, sprintf('mtb_country id %d must resolve to a non-null alpha-2 via mtb_country_iso_code', $id)); + $this->assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country id %d must map to a two-letter uppercase alpha-2 code', $id)); + } + } + + public function testGetRegionFromPref(): void + { + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + $this->assertSame('東京都', $this->service->getRegionFromPref($pref), 'Region must be the prefecture name'); + $this->assertNull($this->service->getRegionFromPref(null), 'Null prefecture must yield null region'); + } + + public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void + { + $country = new Country(); + $country->setId(392); + + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + $customer = new Customer(); + $customer->setName01('山田'); + $customer->setName02('太郎'); + $customer->setKana01('ヤマダ'); + $customer->setKana02('タロウ'); + $customer->setCompanyName('株式会社テスト'); + $customer->setPostalCode('1000001'); + $customer->setPref($pref); + $customer->setAddr01('千代田区千代田'); + $customer->setAddr02('1-1'); + $customer->setCountry($country); + $customer->setPhoneNumber('0312345678'); + + $address = $this->service->toAddressArray($customer); + + $this->assertSame('山田', $address['family_name'], 'family_name must come from name01'); + $this->assertSame('太郎', $address['given_name'], 'given_name must come from name02'); + $this->assertSame('ヤマダ', $address['family_name_kana'], 'family_name_kana must come from kana01'); + $this->assertSame('タロウ', $address['given_name_kana'], 'given_name_kana must come from kana02'); + $this->assertSame('株式会社テスト', $address['company'], 'company must come from company_name'); + $this->assertSame('1000001', $address['postal_code'], 'postal_code must be mapped'); + $this->assertSame('東京都', $address['region'], 'region must be the prefecture name'); + $this->assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); + $this->assertSame('1-1', $address['address2'], 'address2 must come from addr02'); + $this->assertSame('JP', $address['country'], 'country must be alpha-2 resolved from Country.id (392 -> JP)'); + $this->assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); + } + + public function testToAddressArrayFromShipping(): void + { + $country = new Country(); + $country->setId(840); + + $pref = new Pref(); + $pref->setId(1); + $pref->setName('北海道'); + + $shipping = new Shipping(); + $shipping->setName01('佐藤'); + $shipping->setName02('花子'); + $shipping->setPostalCode('0600000'); + $shipping->setPref($pref); + $shipping->setAddr01('札幌市中央区'); + $shipping->setCountry($country); + $shipping->setPhoneNumber('0111234567'); + + $address = $this->service->toAddressArray($shipping); + + $this->assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); + $this->assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); + $this->assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); + $this->assertSame('US', $address['country'], 'Shipping country must be alpha-2 resolved from Country.id (840 -> US)'); + $this->assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); + } + + public function testToAddressArrayWithNullCountryAndPref(): void + { + $customer = new Customer(); + $customer->setName01('田中'); + $customer->setName02('一郎'); + + $address = $this->service->toAddressArray($customer); + + $this->assertSame('田中', $address['family_name'], 'family_name must be mapped even when country/pref are null'); + $this->assertNull($address['country'], 'Null Country must yield null country alpha-2'); + $this->assertNull($address['region'], 'Null Pref must yield null region'); + } + + /** + * @return int[] + */ + private function loadCountryIdsFromCsv(): array + { + $csvPath = __DIR__.'/../../../../../src/Eccube/Resource/doctrine/import_csv/ja/mtb_country.csv'; + $this->assertFileExists($csvPath, 'mtb_country.csv must exist at the expected resource path'); + + $ids = []; + $handle = fopen($csvPath, 'r'); + fgetcsv($handle, null, ',', '"', ''); // skip header row + while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { + if (!isset($row[0]) || $row[0] === '') { + continue; + } + $ids[] = (int) $row[0]; + } + fclose($handle); + + return $ids; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php new file mode 100644 index 00000000000..fb0391bf169 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -0,0 +1,73 @@ +get(BaseInfoRepository::class)->get(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + $this->assertFalse($value, sprintf('BaseInfo flag "%s" must default to false (off by default)', $property)); + } + } + + public function testNewBaseInfoFlagsDefaultToFalse(): void + { + $BaseInfo = new BaseInfo(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + $this->assertFalse($value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); + } + } + + private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool + { + $studly = str_replace('_', '', ucwords($property, '_')); + foreach (['is'.$studly, 'get'.$studly] as $getter) { + if (method_exists($BaseInfo, $getter)) { + return (bool) $BaseInfo->{$getter}(); + } + } + + self::fail(sprintf('BaseInfo must expose a getter (is%1$s or get%1$s) for the "%2$s" flag', $studly, $property)); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedClientTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedClientTest.php new file mode 100644 index 00000000000..09e6a1a12c0 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedClientTest.php @@ -0,0 +1,425 @@ +}> + */ + private array $recorded = []; + + protected function setUp(): void + { + parent::setUp(); + $this->recorded = []; + } + + protected function tearDown(): void + { + $this->recorded = []; + parent::tearDown(); + } + + public function testCreateFeedPostsToFeedsEndpointAndReturnsMetadata(): void + { + $client = $this->createClient([ + $this->jsonResponse(201, ['id' => 'feed_8f3K2x', 'target_country' => 'US']), + ]); + + $metadata = $client->createFeed('US'); + + $this->assertSame('feed_8f3K2x', $metadata['id']); + $this->assertCount(1, $this->recorded); + $this->assertSame('POST', $this->recorded[0]['method'], 'createFeed MUST POST to /feeds.'); + $this->assertSame(self::BASE_URL.'/feeds', $this->recorded[0]['url']); + $this->assertSame(['target_country' => 'US'], $this->decodeBody($this->recorded[0])); + } + + public function testCreateFeedOmitsTargetCountryWhenNull(): void + { + $client = $this->createClient([ + $this->jsonResponse(201, ['id' => 'feed_1']), + ]); + + $client->createFeed(); + + $this->assertSame([], $this->decodeBody($this->recorded[0]), 'createFeed without a target country MUST send an empty body.'); + } + + public function testGetFeedGetsMetadataById(): void + { + $client = $this->createClient([ + $this->jsonResponse(200, ['id' => 'feed_abc', 'updated_at' => '2026-03-01T00:00:00Z']), + ]); + + $metadata = $client->getFeed('feed abc'); + + $this->assertSame('feed_abc', $metadata['id']); + $this->assertSame('GET', $this->recorded[0]['method']); + $this->assertSame(self::BASE_URL.'/feeds/feed%20abc', $this->recorded[0]['url'], 'getFeed MUST GET /feeds/{id} with the id rawurlencoded.'); + } + + public function testGetFeedProductsReturnsTheProductsArray(): void + { + $products = [ + ['id' => 'p1', 'variants' => [['id' => 'v1', 'title' => 'V1']]], + ['id' => 'p2', 'variants' => [['id' => 'v2', 'title' => 'V2']]], + ]; + $client = $this->createClient([ + $this->jsonResponse(200, ['products' => $products]), + ]); + + $result = $client->getFeedProducts('feed_1'); + + $this->assertSame($products, $result); + $this->assertSame('GET', $this->recorded[0]['method']); + $this->assertSame(self::BASE_URL.'/feeds/feed_1/products', $this->recorded[0]['url']); + } + + public function testGetFeedProductsThrowsWhenProductsArrayMissing(): void + { + $client = $this->createClient([ + $this->jsonResponse(200, ['unexpected' => true]), + ]); + + $this->expectException(AcpFeedTransportException::class); + + $client->getFeedProducts('feed_1'); + } + + public function testUpsertProductsPatchesProductsAndReturnsAcceptedTrue(): void + { + $products = [$this->validProduct('p1')]; + $client = $this->createClient([ + $this->jsonResponse(200, ['id' => 'feed_1', 'accepted' => true]), + ]); + + $accepted = $client->upsertProducts('feed_1', $products); + + $this->assertTrue($accepted, 'upsertProducts MUST return the server accepted flag.'); + $this->assertSame('PATCH', $this->recorded[0]['method'], 'incremental upsert MUST PATCH /feeds/{id}/products.'); + $this->assertSame(self::BASE_URL.'/feeds/feed_1/products', $this->recorded[0]['url']); + $this->assertSame(['products' => $products], $this->decodeBody($this->recorded[0])); + } + + public function testUpsertProductsReturnsFalseWhenAcceptedFalse(): void + { + $client = $this->createClient([ + $this->jsonResponse(200, ['accepted' => false]), + ]); + + $this->assertFalse($client->upsertProducts('feed_1', [$this->validProduct('p1')])); + } + + public function testUpsertProductsReturnsFalseWhenAcceptedAbsent(): void + { + $client = $this->createClient([ + $this->jsonResponse(200, ['id' => 'feed_1']), + ]); + + $this->assertFalse($client->upsertProducts('feed_1', [$this->validProduct('p1')]), 'A missing accepted flag MUST be treated as not accepted.'); + } + + public function testUpsertProductsRejectsInvalidPayloadBeforeSending(): void + { + // Product missing the required "variants" array -> must be rejected client-side. + $invalid = ['id' => 'p_invalid']; + $client = $this->createClient([ + $this->jsonResponse(200, ['accepted' => true]), + ]); + + try { + $client->upsertProducts('feed_1', [$invalid]); + self::fail('upsertProducts MUST reject a schema-invalid product before sending.'); + } catch (AcpFeedValidationException $e) { + $this->assertNotEmpty($e->getViolations()); + } + + $this->assertCount(0, $this->recorded, 'No outbound request MUST be made when pre-push validation fails.'); + } + + public function testCreateFeedParsesFlatErrorShape(): void + { + $client = $this->createClient([ + $this->jsonResponse(400, [ + 'type' => 'invalid_request', + 'code' => 'bad_target_country', + 'message' => 'target_country is invalid', + 'param' => 'target_country', + ]), + ]); + + try { + $client->createFeed('US'); + self::fail('A 4xx response MUST raise an AcpFeedTransportException.'); + } catch (AcpFeedTransportException $e) { + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame('invalid_request', $e->getErrorType()); + $this->assertSame('bad_target_country', $e->getErrorCode()); + $this->assertSame('target_country', $e->getParam()); + $this->assertSame('target_country is invalid', $e->getMessage()); + } + } + + public function testParsesErrorWrappedInErrorEnvelope(): void + { + $client = $this->createClient([ + $this->jsonResponse(500, [ + 'error' => [ + 'type' => 'server_error', + 'code' => 'internal', + 'message' => 'boom', + ], + ]), + ]); + + try { + $client->getFeed('feed_1'); + self::fail('A 5xx response MUST raise an AcpFeedTransportException.'); + } catch (AcpFeedTransportException $e) { + $this->assertSame(500, $e->getStatusCode()); + $this->assertSame('server_error', $e->getErrorType()); + $this->assertSame('internal', $e->getErrorCode()); + $this->assertSame('boom', $e->getMessage()); + } + } + + public function testErrorWithoutMessageFallsBackToStatusSummary(): void + { + $client = $this->createClient([ + $this->jsonResponse(404, []), + ]); + + try { + $client->getFeed('missing'); + self::fail('A 404 MUST raise an AcpFeedTransportException.'); + } catch (AcpFeedTransportException $e) { + $this->assertSame(404, $e->getStatusCode()); + $this->assertStringContainsString('404', $e->getMessage()); + $this->assertNull($e->getErrorType()); + } + } + + public function testAllOutboundRequestsCarryBearerAuthorizationHeader(): void + { + $client = $this->createClient([ + $this->jsonResponse(201, ['id' => 'feed_1']), + $this->jsonResponse(200, ['id' => 'feed_1']), + $this->jsonResponse(200, ['products' => []]), + $this->jsonResponse(200, ['accepted' => true]), + ]); + + $client->createFeed('US'); + $client->getFeed('feed_1'); + $client->getFeedProducts('feed_1'); + $client->upsertProducts('feed_1', [$this->validProduct('p1')]); + + $this->assertCount(4, $this->recorded); + foreach ($this->recorded as $call) { + $this->assertContains('Authorization: Bearer '.self::API_KEY, $this->normalizedHeaders($call), 'Every outbound Feed API request MUST carry the Bearer api_key.'); + } + } + + public function testApiKeyDoesNotLeakIntoTransportExceptionOnHttpError(): void + { + $client = $this->createClient([ + $this->jsonResponse(401, ['type' => 'auth_error', 'code' => 'invalid_key', 'message' => 'unauthorized']), + ]); + + try { + $client->createFeed('US'); + self::fail('Expected AcpFeedTransportException.'); + } catch (AcpFeedTransportException $e) { + $this->assertStringNotContainsString(self::API_KEY, $e->getMessage(), 'The Bearer api_key MUST NOT leak into exception messages.'); + $this->assertStringNotContainsString(self::API_KEY, (string) $e, 'The Bearer api_key MUST NOT leak into the exception string representation.'); + } + } + + public function testApiKeyDoesNotLeakIntoTransportExceptionOnNetworkFailure(): void + { + // A transport-level failure (e.g. DNS/connection) surfaces via the response. + $client = $this->createClient([ + new MockResponse('', ['error' => 'Connection refused']), + ]); + + try { + $client->getFeed('feed_1'); + self::fail('Expected AcpFeedTransportException on transport failure.'); + } catch (AcpFeedTransportException $e) { + $this->assertStringNotContainsString(self::API_KEY, $e->getMessage(), 'The Bearer api_key MUST NOT leak when a transport error is wrapped.'); + $this->assertStringNotContainsString(self::API_KEY, (string) $e); + } + } + + public function testThrowsWhenBaseUrlNotConfigured(): void + { + $client = $this->createClient([], baseUrl: ''); + + $this->expectException(AcpFeedException::class); + + $client->getFeed('feed_1'); + } + + public function testThrowsWhenApiKeyNotConfigured(): void + { + $client = $this->createClient([], apiKey: ''); + + $this->expectException(AcpFeedException::class); + + $client->getFeed('feed_1'); + } + + public function testGuardRunsBeforeAnyOutboundRequest(): void + { + // 認証情報未設定 (apiKey 空) のときは outbound 送信前にガードが作動する。 + $client = $this->createClient([], apiKey: ''); + + try { + $client->upsertProducts('feed_1', [$this->validProduct('p1')]); + self::fail('Missing credentials MUST short-circuit before any request.'); + } catch (AcpFeedException) { + // expected + } + + $this->assertCount(0, $this->recorded, 'No HTTP request MUST be made when credentials are not configured.'); + } + + /** + * @param array $responses + */ + private function createClient( + array $responses, + string $baseUrl = self::BASE_URL, + string $apiKey = self::API_KEY, + ): AcpFeedClient { + return new AcpFeedClient( + $this->mockHttpClient($responses), + $this->createStub(AcpFeedGenerator::class), + new AcpFeedValidator(), + $baseUrl, + $apiKey, + ); + } + + /** + * @param array $responses + */ + private function mockHttpClient(array $responses): HttpClientInterface + { + return new MockHttpClient(function (string $method, string $url, array $options) use (&$responses): MockResponse { + $this->recorded[] = ['method' => $method, 'url' => $url, 'options' => $options]; + $response = array_shift($responses); + if ($response === null) { + throw new \LogicException('Unexpected extra HTTP request to '.$url); + } + + return $response; + }); + } + + /** + * @param array $body + */ + private function jsonResponse(int $status, array $body): MockResponse + { + return new MockResponse( + json_encode($body, JSON_THROW_ON_ERROR), + ['http_code' => $status, 'response_headers' => ['Content-Type' => 'application/json']], + ); + } + + /** + * @return array ACP Product valid against schema.feed.json $defs/Product + */ + private function validProduct(string $id): array + { + return [ + 'id' => $id, + 'variants' => [ + ['id' => $id.'-v1', 'title' => 'Variant 1'], + ], + ]; + } + + /** + * @param array{method: string, url: string, options: array} $call + * + * @return array + */ + private function decodeBody(array $call): array + { + $body = $call['options']['body'] ?? null; + if (!\is_string($body) || $body === '') { + return []; + } + + /** @var array $decoded */ + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + + /** + * @param array{method: string, url: string, options: array} $call + * + * @return list + */ + private function normalizedHeaders(array $call): array + { + $headers = $call['options']['headers'] ?? []; + $normalized = []; + foreach ($headers as $key => $value) { + if (\is_int($key)) { + // already "Name: value" + $normalized[] = $value; + + continue; + } + foreach ((array) $value as $v) { + $normalized[] = $key.': '.$v; + } + } + + return $normalized; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializerTest.php new file mode 100644 index 00000000000..a68c7568ae4 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Acp/AcpFeedProductSerializerTest.php @@ -0,0 +1,237 @@ +serializer = new AcpFeedProductSerializer(); + } + + public function testSerializeProductHasRequiredIdAndVariants(): void + { + $product = $this->minimalProduct(); + + $result = $this->serializer->serialize($product); + + $this->assertSame('100', $result['id'], 'ACP Product MUST carry the id'); + $this->assertArrayHasKey('variants', $result, 'ACP Product MUST carry variants[]'); + $this->assertCount(1, $result['variants'], 'Each variant DTO serializes to one entry'); + } + + public function testSerializeProductOmitsEmptyTitle(): void + { + $product = new AgentCatalogItemDto(id: '100', title: '', variants: [$this->minimalVariant()]); + + $result = $this->serializer->serialize($product); + + $this->assertArrayNotHasKey('title', $result, 'An empty product title must not be emitted'); + } + + public function testSerializeProductEmitsTitleWhenPresent(): void + { + $product = new AgentCatalogItemDto(id: '100', title: 'Coffee', variants: [$this->minimalVariant()]); + + $result = $this->serializer->serialize($product); + + $this->assertSame('Coffee', $result['title'], 'A non-empty title must be emitted'); + } + + public function testSerializeProductOmitsNullDescriptionAndUrlAndMedia(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: null, + url: null, + media: [], + variants: [$this->minimalVariant()], + ); + + $result = $this->serializer->serialize($product); + + $this->assertArrayNotHasKey('description', $result, 'A null description must not be emitted'); + $this->assertArrayNotHasKey('url', $result, 'A null url must not be emitted'); + $this->assertArrayNotHasKey('media', $result, 'Empty media must not be emitted'); + } + + public function testSerializeProductOmitsEmptyDescriptionDto(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: new AgentCatalogDescriptionDto(), + variants: [$this->minimalVariant()], + ); + + $result = $this->serializer->serialize($product); + + $this->assertArrayNotHasKey('description', $result, 'An all-null description DTO must not be emitted (schema minProperties:1)'); + } + + public function testSerializeProductEmitsDescriptionHtmlOnly(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: new AgentCatalogDescriptionDto(html: '

Rich

'), + variants: [$this->minimalVariant()], + ); + + $result = $this->serializer->serialize($product); + + $this->assertSame(['html' => '

Rich

'], $result['description'], 'Only present description formats are emitted; plain/markdown stay absent'); + } + + public function testSerializeProductEmitsMedia(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + media: [new AgentCatalogMediaDto(url: 'https://x/i.jpg', type: 'image')], + variants: [$this->minimalVariant()], + ); + + $result = $this->serializer->serialize($product); + + $this->assertSame([['type' => 'image', 'url' => 'https://x/i.jpg']], $result['media'], 'Media required keys are type and url; optional alt/width/height absent when null'); + } + + public function testSerializeVariantHasRequiredIdAndTitle(): void + { + $result = $this->serializer->serializeVariant($this->minimalVariant()); + + $this->assertSame('200', $result['id'], 'ACP Variant MUST carry the id'); + $this->assertSame('Coffee Bag', $result['title'], 'ACP Variant MUST carry the title'); + } + + public function testSerializeVariantEmbedsPriceAsMinorUnitInteger(): void + { + $result = $this->serializer->serializeVariant($this->minimalVariant()); + + $this->assertSame(['amount' => 1200, 'currency' => 'JPY'], $result['price'], 'Variant price MUST be a minor-unit integer amount with a currency'); + } + + public function testSerializeVariantEmbedsAvailability(): void + { + $result = $this->serializer->serializeVariant($this->minimalVariant()); + + $this->assertSame(['available' => true, 'status' => 'in_stock'], $result['availability'], 'Variant availability MUST carry available bool and a known status string'); + } + + public function testSerializeVariantOmitsListPriceWhenNull(): void + { + $variant = $this->variant(); + + $result = $this->serializer->serializeVariant($variant); + + $this->assertArrayNotHasKey('list_price', $result, 'A null list price must not be emitted'); + } + + public function testSerializeVariantEmitsListPriceWhenSet(): void + { + $variant = $this->variant(listPriceMinorUnits: 1500); + + $result = $this->serializer->serializeVariant($variant); + + $this->assertSame(['amount' => 1500, 'currency' => 'JPY'], $result['list_price'], 'A set list price is emitted with the variant currency'); + } + + public function testSerializeVariantOmitsEmptyOptionsAndBarcodesAndMedia(): void + { + $result = $this->serializer->serializeVariant($this->minimalVariant()); + + $this->assertArrayNotHasKey('variant_options', $result, 'Empty options must not be emitted'); + $this->assertArrayNotHasKey('barcodes', $result, 'Empty barcodes must not be emitted'); + $this->assertArrayNotHasKey('media', $result, 'Empty media must not be emitted'); + $this->assertArrayNotHasKey('url', $result, 'A null url must not be emitted'); + $this->assertArrayNotHasKey('description', $result, 'A null description must not be emitted'); + } + + public function testSerializeVariantEmitsOptionsAsVariantOptions(): void + { + $variant = $this->variant(options: [new AgentCatalogOptionDto('Color', 'Red')]); + + $result = $this->serializer->serializeVariant($variant); + + $this->assertSame([['name' => 'Color', 'value' => 'Red']], $result['variant_options'], 'Options serialize to ACP variant_options with name/value'); + } + + public function testSerializeVariantEmitsBarcodes(): void + { + $variant = $this->variant(barcodes: [new AgentCatalogBarcodeDto('gtin', '4901234567894')]); + + $result = $this->serializer->serializeVariant($variant); + + $this->assertSame([['type' => 'gtin', 'value' => '4901234567894']], $result['barcodes'], 'Barcodes serialize with type/value when present'); + } + + private function minimalProduct(): AgentCatalogItemDto + { + return new AgentCatalogItemDto(id: '100', title: 'Coffee', variants: [$this->minimalVariant()]); + } + + private function minimalVariant(): AgentCatalogVariantDto + { + return $this->variant(); + } + + /** + * @param AgentCatalogOptionDto[] $options + * @param AgentCatalogBarcodeDto[] $barcodes + */ + private function variant(?int $listPriceMinorUnits = null, array $options = [], array $barcodes = []): AgentCatalogVariantDto + { + return new AgentCatalogVariantDto( + id: '200', + title: 'Coffee Bag', + priceMinorUnits: 1200, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + sku: null, + listPriceMinorUnits: $listPriceMinorUnits, + description: null, + url: null, + options: $options, + barcodes: $barcodes, + media: [], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Catalog/CatalogMapperTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/CatalogMapperTest.php new file mode 100644 index 00000000000..0f14d47e6f2 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/CatalogMapperTest.php @@ -0,0 +1,354 @@ + AgentCatalogItemDto / AgentCatalogVariantDto: + * availability resolution, hidden-product / hidden-variant exclusion, minor-unit + * price conversion, variant option mapping and the default-empty barcodes seam. + * DB-free: entities are built in memory and the UrlGenerator is mocked. + */ +final class CatalogMapperTest extends TestCase +{ + private CatalogMapper $mapper; + + protected function setUp(): void + { + parent::setUp(); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator->method('generate')->willReturnCallback( + static fn (string $name, array $params = [], int $type = UrlGeneratorInterface::ABSOLUTE_PATH): string => 'https://shop.example.com/products/detail/'.($params['id'] ?? '') + ); + $context = new RequestContext('', 'GET', 'shop.example.com', 'https'); + $urlGenerator->method('getContext')->willReturn($context); + + $this->mapper = new CatalogMapper(new MinorUnitConverter(), $urlGenerator); + } + + public function testMapProductReturnsNullForHiddenProduct(): void + { + $product = $this->buildProduct(1, ProductStatus::DISPLAY_HIDE); + $this->addVisibleVariant($product, 11, '1000', 'JPY'); + + $this->assertNotInstanceOf(AgentCatalogItemDto::class, $this->mapper->mapProduct($product), 'Non-displayed products must be excluded from the catalog'); + } + + public function testMapProductReturnsNullWhenNoVisibleVariant(): void + { + $product = $this->buildProduct(2, ProductStatus::DISPLAY_SHOW); + $variant = $this->buildVariant(21, '1000', 'JPY', stock: '5', unlimited: false, visible: false); + $variant->setProduct($product); + $product->addProductClass($variant); + + $this->assertNotInstanceOf(AgentCatalogItemDto::class, $this->mapper->mapProduct($product), 'A displayed product with no visible variant must not be mapped (variants[] is required and non-empty)'); + } + + public function testMapProductMapsCoreFields(): void + { + $product = $this->buildProduct(3, ProductStatus::DISPLAY_SHOW, name: 'Test Coffee', descriptionDetail: '

Rich blend

'); + $this->addVisibleVariant($product, 31, '1200', 'JPY'); + + $dto = $this->mapper->mapProduct($product); + + $this->assertInstanceOf(AgentCatalogItemDto::class, $dto); + $this->assertSame('3', $dto->id, 'Product id must be mapped as a string'); + $this->assertSame('Test Coffee', $dto->title, 'Product name maps to title'); + $this->assertSame('https://shop.example.com/products/detail/3', $dto->url, 'Product url must be the absolute product_detail URL'); + $this->assertInstanceOf(AgentCatalogDescriptionDto::class, $dto->description, 'A non-empty descriptionDetail must produce a description DTO'); + $this->assertSame('

Rich blend

', $dto->description->html, 'descriptionDetail maps to the html field of the description'); + $this->assertCount(1, $dto->variants, 'Each visible ProductClass becomes one variant'); + } + + public function testMapProductExcludesHiddenVariantsButKeepsVisibleOnes(): void + { + $product = $this->buildProduct(4, ProductStatus::DISPLAY_SHOW); + $visible = $this->buildVariant(41, '1000', 'JPY', stock: '5', unlimited: false, visible: true); + $visible->setProduct($product); + $product->addProductClass($visible); + $hidden = $this->buildVariant(42, '2000', 'JPY', stock: '5', unlimited: false, visible: false); + $hidden->setProduct($product); + $product->addProductClass($hidden); + + $dto = $this->mapper->mapProduct($product); + + $this->assertInstanceOf(AgentCatalogItemDto::class, $dto); + $this->assertCount(1, $dto->variants, 'Only visible ProductClass entries are mapped to variants'); + $this->assertSame('41', $dto->variants[0]->id, 'The visible variant must be the one retained'); + } + + public function testMapProductHasEmptyBarcodesAndCategoriesByDefault(): void + { + $product = $this->buildProduct(5, ProductStatus::DISPLAY_SHOW); + $this->addVisibleVariant($product, 51, '1000', 'JPY'); + + $dto = $this->mapper->mapProduct($product); + + $this->assertInstanceOf(AgentCatalogItemDto::class, $dto); + $this->assertSame([], $dto->categories, 'Categories are a Customize seam and empty by default'); + $this->assertSame([], $dto->variants[0]->barcodes, 'Barcodes are a Customize seam and empty by default (no standard GTIN field)'); + } + + public function testMapProductMediaIsOrderedBySortNo(): void + { + $product = $this->buildProduct(6, ProductStatus::DISPLAY_SHOW); + $this->addVisibleVariant($product, 61, '1000', 'JPY'); + $product->addProductImage($this->buildImage('second.jpg', 2)); + $product->addProductImage($this->buildImage('first.jpg', 1)); + + $dto = $this->mapper->mapProduct($product); + + $this->assertInstanceOf(AgentCatalogItemDto::class, $dto); + $this->assertCount(2, $dto->media, 'All product images map to media'); + $this->assertStringEndsWith('/first.jpg', $dto->media[0]->url, 'Media must be sorted ascending by sort_no'); + $this->assertStringEndsWith('/second.jpg', $dto->media[1]->url, 'Higher sort_no image comes second'); + $this->assertStringStartsWith('https://shop.example.com/', $dto->media[0]->url, 'Image URLs must be absolute (scheme + host from RequestContext)'); + } + + public function testMapVariantConvertsPriceToMinorUnitsForZeroDecimalCurrency(): void + { + $variant = $this->buildVariant(71, '1500', 'JPY', stock: '5', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame(1500, $dto->priceMinorUnits, 'JPY price02 converts to identical minor units (0 fraction digits)'); + $this->assertSame('JPY', $dto->currency, 'Currency comes from the ProductClass currency code'); + } + + public function testMapVariantConvertsPriceToMinorUnitsForTwoDecimalCurrency(): void + { + $variant = $this->buildVariant(72, '10.99', 'USD', stock: '5', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame(1099, $dto->priceMinorUnits, 'USD 10.99 converts to 1099 cents'); + } + + public function testMapVariantMapsListPriceWhenPriceSet(): void + { + $variant = $this->buildVariant(73, '900', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setPrice01('1200'); + $variant->setPrice01IncTax('1200'); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame(1200, $dto->listPriceMinorUnits, 'price01 (通常価格) maps to list_price (reference / strikethrough price)'); + $this->assertSame(900, $dto->priceMinorUnits, 'price02 (販売価格) remains the active selling price'); + } + + public function testMapVariantUsesTaxIncludedPrice(): void + { + // 店頭表示と同様に税込価格を出力する: price02=1000 / price02_inc_tax=1100 のとき 1100 を採用する。 + $variant = $this->buildVariant(76, '1000', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setPrice02IncTax('1100'); + $variant->setPrice01('2000'); + $variant->setPrice01IncTax('2200'); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame(1100, $dto->priceMinorUnits, '[実装方針] price は税込 price02_inc_tax を採用する (店頭整合・日本の総額表示。ACP/UCP spec MUST ではない)'); + $this->assertSame(2200, $dto->listPriceMinorUnits, '[実装方針] list_price も税込 price01_inc_tax を採用する'); + } + + public function testMapVariantListPriceIsNullWhenPrice01Absent(): void + { + $variant = $this->buildVariant(74, '900', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setPrice01(); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertNull($dto->listPriceMinorUnits, 'A missing price01 must yield a null list price'); + } + + public function testMapVariantListPriceOmittedWhenNotDiscounted(): void + { + // price01 (通常価格) <= price02 (販売価格) のときは割引が無いため list_price を出さない。 + $variant = $this->buildVariant(77, '1000', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setPrice01('1000'); + $variant->setPrice01IncTax('1000'); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertNull($dto->listPriceMinorUnits, '[実装方針・spec MUST ではない] 割引が無い (price01 <= price02) ときは list_price を省く (誤った割引表示の回避)'); + } + + public function testMapVariantUsesCodeAsSku(): void + { + $variant = $this->buildVariant(75, '900', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setCode('SKU-XYZ'); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame('SKU-XYZ', $dto->sku, 'ProductClass code maps to the variant sku'); + } + + public function testMapVariantAvailabilityInStockWhenStockPositive(): void + { + $variant = $this->buildVariant(76, '900', 'JPY', stock: '3', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertTrue($dto->available, 'Positive stock means available'); + $this->assertSame(AvailabilityStatus::IN_STOCK, $dto->availabilityStatus, 'Positive stock maps to in_stock'); + } + + public function testMapVariantAvailabilityInStockWhenUnlimited(): void + { + $variant = $this->buildVariant(77, '900', 'JPY', stock: '0', unlimited: true, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertTrue($dto->available, 'Unlimited stock is always available regardless of stock count'); + $this->assertSame(AvailabilityStatus::IN_STOCK, $dto->availabilityStatus, 'Unlimited stock maps to in_stock'); + } + + public function testMapVariantAvailabilityOutOfStockWhenZeroAndNotUnlimited(): void + { + $variant = $this->buildVariant(78, '900', 'JPY', stock: '0', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertFalse($dto->available, 'Zero stock without unlimited flag means unavailable'); + $this->assertSame(AvailabilityStatus::OUT_OF_STOCK, $dto->availabilityStatus, 'Zero stock maps to out_of_stock (no limited_stock threshold by default)'); + } + + public function testMapVariantAvailabilityOutOfStockWhenStockNull(): void + { + $variant = $this->buildVariant(79, '900', 'JPY', stock: null, unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertFalse($dto->available, 'Null stock is treated as zero'); + $this->assertSame(AvailabilityStatus::OUT_OF_STOCK, $dto->availabilityStatus, 'Null stock maps to out_of_stock'); + } + + public function testMapVariantMapsClassCategoriesToOptions(): void + { + $variant = $this->buildVariant(80, '900', 'JPY', stock: '5', unlimited: false, visible: true); + $variant->setClassCategory1($this->buildClassCategory('Color', 'Red')); + $variant->setClassCategory2($this->buildClassCategory('Size', 'L')); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertCount(2, $dto->options, 'Both class categories map to options'); + $this->assertSame('Color', $dto->options[0]->name, 'Option name comes from ClassName.name'); + $this->assertSame('Red', $dto->options[0]->value, 'Option value comes from ClassCategory.name'); + $this->assertSame('Size', $dto->options[1]->name, 'Second class category maps to the second option'); + $this->assertSame('L', $dto->options[1]->value, 'Second option value'); + } + + public function testMapVariantHasNoOptionsWhenNoClassCategory(): void + { + $variant = $this->buildVariant(81, '900', 'JPY', stock: '5', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame([], $dto->options, 'A variant with no class categories has no options'); + } + + public function testMapVariantBarcodesEmptyByDefault(): void + { + $variant = $this->buildVariant(82, '900', 'JPY', stock: '5', unlimited: false, visible: true); + + $dto = $this->mapper->mapVariant($variant); + + $this->assertSame([], $dto->barcodes, 'Standard mapping never emits barcodes (Customize seam)'); + } + + private function buildProduct(int $id, int $statusId, string $name = 'Product', ?string $descriptionDetail = null): Product + { + $product = new Product(); + $this->setId($product, $id); + $product->setName($name); + $product->setDescriptionDetail($descriptionDetail); + + $status = new ProductStatus(); + $status->setId($statusId); + $product->setStatus($status); + + return $product; + } + + private function addVisibleVariant(Product $product, int $id, string $price02, string $currency): AgentCatalogVariantDto + { + $variant = $this->buildVariant($id, $price02, $currency, stock: '10', unlimited: false, visible: true); + $variant->setProduct($product); + $product->addProductClass($variant); + + // Return value unused by callers but keeps a single construction path. + return $this->mapper->mapVariant($variant); + } + + private function buildVariant(int $id, string $price02, string $currency, ?string $stock, bool $unlimited, bool $visible): ProductClass + { + $variant = new ProductClass(); + $this->setId($variant, $id); + $variant->setPrice02($price02); + // 税抜・税込が同額のケース (税率 0 相当)。税込が使われることの検証は別テストで price02 != inc_tax を用いる。 + $variant->setPrice02IncTax($price02); + $variant->setCurrencyCode($currency); + $variant->setStock($stock); + $variant->setStockUnlimited($unlimited); + $variant->setVisible($visible); + + return $variant; + } + + private function buildImage(string $fileName, int $sortNo): ProductImage + { + $image = new ProductImage(); + $image->setFileName($fileName); + $image->setSortNo($sortNo); + + return $image; + } + + private function buildClassCategory(string $className, string $categoryName): ClassCategory + { + $name = new ClassName(); + $name->setName($className); + + $category = new ClassCategory(); + $category->setName($categoryName); + $category->setClassName($name); + + return $category; + } + + private function setId(object $entity, int $id): void + { + $ref = new \ReflectionProperty($entity, 'id'); + $ref->setValue($entity, $id); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializerTest.php new file mode 100644 index 00000000000..db6285a751b --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Catalog/Ucp/UcpCatalogProductSerializerTest.php @@ -0,0 +1,230 @@ +serializer = new UcpCatalogProductSerializer(); + } + + public function testSerializeProductHasAllRequiredFields(): void + { + $product = $this->product([$this->variant('200', 1200)]); + + $result = $this->serializer->serialize($product); + + $this->assertSame('100', $result['id'], 'UCP Product MUST carry id'); + $this->assertSame('Coffee', $result['title'], 'UCP Product MUST carry title'); + $this->assertArrayHasKey('description', $result, 'UCP Product MUST carry description'); + $this->assertArrayHasKey('price_range', $result, 'UCP Product MUST carry price_range'); + $this->assertArrayHasKey('variants', $result, 'UCP Product MUST carry variants[]'); + } + + public function testSerializeProductPriceRangeFromSingleVariant(): void + { + $product = $this->product([$this->variant('200', 1200)]); + + $result = $this->serializer->serialize($product); + + $this->assertSame(['amount' => 1200, 'currency' => 'JPY'], $result['price_range']['min'], 'A single variant makes min equal to its price'); + $this->assertSame(['amount' => 1200, 'currency' => 'JPY'], $result['price_range']['max'], 'A single variant makes max equal to its price'); + } + + public function testSerializeProductPriceRangeSpansMultipleVariants(): void + { + $product = $this->product([ + $this->variant('201', 1500), + $this->variant('202', 800), + $this->variant('203', 1200), + ]); + + $result = $this->serializer->serialize($product); + + $this->assertSame(800, $result['price_range']['min']['amount'], 'price_range.min must be the lowest variant price'); + $this->assertSame(1500, $result['price_range']['max']['amount'], 'price_range.max must be the highest variant price'); + $this->assertSame('JPY', $result['price_range']['min']['currency'], 'price_range currency comes from the first variant'); + } + + public function testSerializeProductDescriptionEmptyGuaranteesPlain(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: null, + variants: [$this->variant('200', 1200)], + ); + + $result = $this->serializer->serialize($product); + + $this->assertSame(['plain' => ''], $result['description'], 'A missing product description must degrade to plain:"" to satisfy minProperties:1'); + } + + public function testSerializeProductDescriptionEmitsPresentFormats(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: new AgentCatalogDescriptionDto(html: '

Rich

'), + variants: [$this->variant('200', 1200)], + ); + + $result = $this->serializer->serialize($product); + + $this->assertSame(['html' => '

Rich

'], $result['description'], 'Present description formats are emitted verbatim'); + } + + public function testSerializeProductOmitsNullUrlAndEmptyCategoriesAndMedia(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + url: null, + media: [], + categories: [], + variants: [$this->variant('200', 1200)], + ); + + $result = $this->serializer->serialize($product); + + $this->assertArrayNotHasKey('url', $result, 'A null url must not be emitted'); + $this->assertArrayNotHasKey('categories', $result, 'Empty categories must not be emitted'); + $this->assertArrayNotHasKey('media', $result, 'Empty media must not be emitted'); + } + + public function testSerializeProductEmitsCategories(): void + { + $product = new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + categories: ['Beverages', 'Coffee'], + variants: [$this->variant('200', 1200)], + ); + + $result = $this->serializer->serialize($product); + + $this->assertSame([['value' => 'Beverages'], ['value' => 'Coffee']], $result['categories'], 'Category names map to {value} objects per UCP category.json'); + } + + public function testSerializeVariantHasAllRequiredFields(): void + { + $result = $this->serializer->serializeVariant($this->variant('200', 1200)); + + $this->assertSame('200', $result['id'], 'UCP Variant MUST carry id'); + $this->assertSame('Coffee Bag', $result['title'], 'UCP Variant MUST carry title'); + $this->assertArrayHasKey('description', $result, 'UCP Variant MUST carry description'); + $this->assertSame(['amount' => 1200, 'currency' => 'JPY'], $result['price'], 'UCP Variant MUST carry a minor-unit price'); + } + + public function testSerializeVariantDescriptionDegradesToPlain(): void + { + $result = $this->serializer->serializeVariant($this->variant('200', 1200)); + + $this->assertSame(['plain' => ''], $result['description'], 'A missing variant description degrades to plain:"" (description is required, minProperties:1)'); + } + + public function testSerializeVariantEmbedsAvailability(): void + { + $result = $this->serializer->serializeVariant($this->variant('200', 1200, available: false, status: AvailabilityStatus::OUT_OF_STOCK)); + + $this->assertSame(['available' => false, 'status' => 'out_of_stock'], $result['availability'], 'Variant availability carries the available bool and status string'); + } + + public function testSerializeVariantOmitsNullSkuAndUrlAndListPrice(): void + { + $result = $this->serializer->serializeVariant($this->variant('200', 1200)); + + $this->assertArrayNotHasKey('sku', $result, 'A null sku must not be emitted'); + $this->assertArrayNotHasKey('url', $result, 'A null url must not be emitted'); + $this->assertArrayNotHasKey('list_price', $result, 'A null list price must not be emitted'); + $this->assertArrayNotHasKey('options', $result, 'Empty options must not be emitted'); + $this->assertArrayNotHasKey('barcodes', $result, 'Empty barcodes must not be emitted'); + } + + public function testSerializeVariantEmitsSkuAndOptions(): void + { + $variant = $this->variant('200', 1200, sku: 'SKU-1', options: [new AgentCatalogOptionDto('Size', 'L')]); + + $result = $this->serializer->serializeVariant($variant); + + $this->assertSame('SKU-1', $result['sku'], 'A present sku is emitted'); + $this->assertSame([['name' => 'Size', 'label' => 'L']], $result['options'], 'UCP variant options use the options key with name/label per selected_option.json'); + } + + /** + * @param AgentCatalogVariantDto[] $variants + */ + private function product(array $variants): AgentCatalogItemDto + { + return new AgentCatalogItemDto( + id: '100', + title: 'Coffee', + description: new AgentCatalogDescriptionDto(plain: 'A coffee'), + variants: $variants, + ); + } + + /** + * @param AgentCatalogOptionDto[] $options + */ + private function variant( + string $id, + int $priceMinorUnits, + bool $available = true, + AvailabilityStatus $status = AvailabilityStatus::IN_STOCK, + ?string $sku = null, + array $options = [], + ): AgentCatalogVariantDto { + return new AgentCatalogVariantDto( + id: $id, + title: 'Coffee Bag', + priceMinorUnits: $priceMinorUnits, + currency: 'JPY', + available: $available, + availabilityStatus: $status, + sku: $sku, + listPriceMinorUnits: null, + description: null, + url: null, + options: $options, + barcodes: [], + media: [], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpFeedConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpFeedConformanceTest.php new file mode 100644 index 00000000000..a2d4d827535 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpFeedConformanceTest.php @@ -0,0 +1,272 @@ +serializer = new AcpFeedProductSerializer(); + } + + protected function tearDown(): void + { + $this->serializer = null; + } + + /** + * MUST: products.jsonl は 1 行 1 Product オブジェクトで構成される (full replacement)。 + * 各行が独立して JSON デコード可能であり Product schema に適合しなければならない。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/openapi/openapi.feed.yaml#L10 + */ + public function testProductsJsonlHasExactlyOneProductObjectPerLine(): void + { + $products = [ + $this->serializer->serialize($this->sampleItem('1', '101')), + $this->serializer->serialize($this->sampleItem('2', '201')), + ]; + + // products.jsonl の生成を 1 行 1 Product で再現する。 + $lines = array_map( + static fn (array $product): string => json_encode($product, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + $products + ); + $jsonl = implode("\n", $lines)."\n"; + + $rows = array_values(array_filter(explode("\n", $jsonl), static fn (string $l): bool => $l !== '')); + $this->assertCount(2, $rows, 'MUST: products.jsonl contains one Product object per line (one line per product).'); + + foreach ($rows as $row) { + $decoded = json_decode($row, true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($decoded, 'MUST: each products.jsonl line is an independently decodable JSON Product object.'); + $this->assertSchemaValid($decoded, 'Product', 'MUST: each products.jsonl line conforms to the Product schema.'); + } + } + + /** + * MUST: ファイル取り込み (products.jsonl) は商品集合を完全置換する (partial 更新は PATCH のみ)。 + * 本実装の JSONL 生成は与えられた全 Product を毎回出力する (差分でない) ことをトレースする。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/openapi/openapi.feed.yaml#L11 + */ + public function testFileIngestionReplacesTheFullProductSet(): void + { + $items = [ + $this->sampleItem('1', '101'), + $this->sampleItem('2', '201'), + $this->sampleItem('3', '301'), + ]; + + $lines = array_map( + fn (AgentCatalogItemDto $item): string => json_encode($this->serializer->serialize($item), JSON_THROW_ON_ERROR), + $items + ); + + $this->assertCount( + 3, + $lines, + 'MUST: offline file ingestion replaces the full product set; the generated JSONL emits every product (full replacement, not a delta).' + ); + } + + /** + * MUST: ACP Product は id と variants を必須項目として持つ。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L439 + */ + public function testProductRequiresIdAndVariants(): void + { + $product = $this->serializer->serialize($this->sampleItem('1', '101')); + + $this->assertArrayHasKey('id', $product, 'MUST: a feed Product MUST have an id.'); + $this->assertArrayHasKey('variants', $product, 'MUST: a feed Product MUST have variants.'); + $this->assertNotEmpty($product['variants'], 'MUST: a feed Product MUST have at least one variant.'); + $this->assertSchemaValid($product, 'Product', 'MUST: the serialized Product conforms to the Product schema.'); + } + + /** + * MUST: ACP Variant は id と title を必須項目として持つ。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L309 + */ + public function testVariantRequiresIdAndTitle(): void + { + $variant = $this->serializer->serializeVariant($this->sampleVariant('101')); + + $this->assertArrayHasKey('id', $variant, 'MUST: a feed Variant MUST have an id.'); + $this->assertArrayHasKey('title', $variant, 'MUST: a feed Variant MUST have a title.'); + } + + /** + * MUST: Variant の price.amount は ISO 4217 minor unit の整数 (>=0)、currency は ^[A-Z]{3}$。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L29 + */ + public function testPriceAmountIsNonNegativeIntegerMinorUnits(): void + { + $variant = $this->serializer->serializeVariant($this->sampleVariant('101')); + + $this->assertArrayHasKey('price', $variant, 'A priced variant carries a Price object.'); + $this->assertIsInt( + $variant['price']['amount'], + 'MUST: Price.amount is an integer expressed in ISO 4217 minor units (not a float).' + ); + $this->assertGreaterThanOrEqual( + 0, + $variant['price']['amount'], + 'MUST: Price.amount has a minimum of 0.' + ); + $this->assertMatchesRegularExpression( + '/^[A-Z]{3}$/', + $variant['price']['currency'], + 'MUST: Price.currency is a three-letter ISO 4217 identifier.' + ); + } + + /** + * MUST: metadata.json は FeedMetadata 形状で id を必須とし、target_country は ^[A-Z]{2}$。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L491 + */ + public function testFeedMetadataRequiresIdAndAlpha2TargetCountry(): void + { + $metadata = [ + 'id' => 'feed_test', + 'target_country' => 'JP', + 'updated_at' => '2026-06-08T00:00:00+00:00', + ]; + + $this->assertArrayHasKey('id', $metadata, 'MUST: FeedMetadata MUST have an id.'); + $this->assertMatchesRegularExpression( + '/^[A-Z]{2}$/', + $metadata['target_country'], + 'MUST: FeedMetadata.target_country is an ISO 3166-1 alpha-2 country code.' + ); + $this->assertSchemaValid($metadata, 'FeedMetadata', 'MUST: metadata.json conforms to the FeedMetadata schema.'); + } + + /** + * MUST: id を欠く metadata は schema 違反として拒否される (id required の負例)。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L495 + */ + public function testFeedMetadataWithoutIdIsRejected(): void + { + $metadata = ['target_country' => 'JP']; + + $validator = new Validator(); + $object = json_decode((string) json_encode($metadata)); + $validator->validate($object, (object) ['$ref' => 'file://'.realpath(self::SCHEMA_PATH).'#/$defs/FeedMetadata']); + + $this->assertFalse( + $validator->isValid(), + 'MUST: FeedMetadata without the required "id" MUST be rejected.' + ); + } + + /** + * MUST: availability.status の既知値集合 (in_stock 等) に AvailabilityStatus enum が適合する。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/json-schema/schema.feed.json#L60 + */ + public function testAvailabilityStatusUsesKnownValues(): void + { + $variant = $this->serializer->serializeVariant($this->sampleVariant('101')); + $known = ['in_stock', 'limited_stock', 'backorder', 'preorder', 'out_of_stock', 'discontinued']; + + $this->assertArrayHasKey('availability', $variant, 'A variant carries an availability object.'); + $this->assertContains( + $variant['availability']['status'], + $known, + 'MUST: availability.status uses a known fulfillment-state value.' + ); + } + + /** + * Feed push トランスポート (POST /feeds, PATCH /feeds/{id}/products, outbound Bearer) の + * 規範要件は国内 GA 前で活用不可のため Layer 9 (ACP push) で別途検証する。 + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/blob/main/spec/2026-04-17/openapi/openapi.feed.yaml + */ + public function testFeedPushTransportRequirementsAreDeferredToLayer9(): void + { + self::markTestIncomplete('Feed push transport (POST /feeds, PATCH /feeds/{id}/products, outbound Bearer api_key, idempotency) は国内 GA 前で活用不可のため Layer 9 (ACP push) で検証する。'); + } + + /** + * @param array $data + */ + private function assertSchemaValid(array $data, string $def, string $message): void + { + $schemaPath = realpath(self::SCHEMA_PATH); + $this->assertNotFalse($schemaPath, 'ACP feed schema must exist at src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json'); + + $validator = new Validator(); + $object = json_decode((string) json_encode($data)); + $validator->validate($object, (object) ['$ref' => 'file://'.$schemaPath.'#/$defs/'.$def]); + + $errors = array_map( + static fn (array $e): string => sprintf('[%s] %s', $e['property'], $e['message']), + $validator->getErrors() + ); + $this->assertTrue($validator->isValid(), $message.' Violations: '.implode('; ', $errors)); + } + + private function sampleVariant(string $id): AgentCatalogVariantDto + { + return new AgentCatalogVariantDto( + id: $id, + title: 'Sample Variant '.$id, + priceMinorUnits: 1999, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + ); + } + + private function sampleItem(string $productId, string $variantId): AgentCatalogItemDto + { + return new AgentCatalogItemDto( + id: $productId, + title: 'Sample Product '.$productId, + variants: [$this->sampleVariant($variantId)], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php new file mode 100644 index 00000000000..e3fe24f461f --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php @@ -0,0 +1,96 @@ +toMinorUnits('1000', 'JPY'); + $this->assertIsInt($jpy, 'MUST: monetary amounts are integer minor units (JPY, zero-decimal)'); + $this->assertSame(1000, $jpy, 'MUST: a JPY amount of 1000 is 1000 minor units'); + + $usd = $converter->toMinorUnits('10.99', 'USD'); + $this->assertIsInt($usd, 'MUST: monetary amounts are integer minor units (USD, two-decimal)'); + $this->assertSame(1099, $usd, 'MUST: a USD amount of 10.99 is 1099 minor units (cents)'); + } + + /** + * MUST: published signing keys advertise public key material only. The + * private key parameter "d" MUST NOT appear in any discovery JWK. + * + * @see UCP /.well-known/ucp signing_keys[] — EC public-key JWKs only + */ + public function testPublishedSigningKeysContainPublicMaterialOnly(): void + { + $store = new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + + $signer = new UcpMessageSigner($store, 'ucp_signing'); + $jwks = $signer->getPublicJwks(); + + $this->assertNotEmpty($jwks, 'MUST: at least one signing key is advertised for discovery'); + foreach ($jwks as $jwk) { + $this->assertSame('EC', $jwk['kty'] ?? null, 'MUST: UCP signing keys are EC keys'); + $this->assertArrayNotHasKey('d', $jwk, 'MUST NOT: discovery JWKs must not contain the private parameter d'); + } + } + + /** + * Cross-protocol two-tier error model (protocol errors as HTTP 4xx/5xx vs + * business errors as HTTP 200 + messages[]) is enforced at the controller + * layer, which is out of scope for the common base. + */ + public function testTwoTierErrorModelIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('Two-tier error model (HTTP errors vs messages[]) is verified in the ACP/UCP checkout controller tracks, not in the common base.'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpCatalogConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpCatalogConformanceTest.php new file mode 100644 index 00000000000..93d773a4cf1 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpCatalogConformanceTest.php @@ -0,0 +1,211 @@ +builder = new UcpCatalogResponseBuilder(new UcpCatalogProductSerializer()); + } + + protected function tearDown(): void + { + $this->builder = null; + } + + /** + * MUST: search レスポンスは ucp ラッパーと products[] を必須項目として持つ。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/shopping/catalog_search.json#L34 + */ + public function testSearchResponseRequiresUcpWrapperAndProductsArray(): void + { + $response = $this->builder->buildSearchResponse([$this->sampleItem()]); + + $this->assertArrayHasKey('ucp', $response, 'MUST: search_response requires the "ucp" wrapper.'); + $this->assertArrayHasKey('products', $response, 'MUST: search_response requires the "products" array.'); + $this->assertIsArray($response['products'], 'MUST: search_response.products is an array.'); + $this->assertArrayHasKey('version', $response['ucp'], 'MUST: the ucp wrapper declares a version.'); + $this->assertSame( + '2026-04-08', + $response['ucp']['version'], + 'MUST: the ucp wrapper version is the advertised UCP version 2026-04-08.' + ); + } + + /** + * MUST: lookup レスポンスは ucp ラッパーと products[] を必須項目として持つ。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/shopping/catalog_lookup.json#L52 + */ + public function testLookupResponseRequiresUcpWrapperAndProductsArray(): void + { + $response = $this->builder->buildLookupResponse([$this->sampleItem()]); + + $this->assertArrayHasKey('ucp', $response, 'MUST: lookup_response requires the "ucp" wrapper.'); + $this->assertArrayHasKey('products', $response, 'MUST: lookup_response requires the "products" array.'); + $this->assertIsArray($response['products'], 'MUST: lookup_response.products is an array.'); + } + + /** + * MUST: get_product レスポンスは ucp ラッパーと単数の product を必須項目として持つ。 + * (lookup の products[] と異なり単一リソース操作のため "product" は単数。) + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/shopping/catalog_lookup.json#L153 + */ + public function testGetProductResponseRequiresUcpWrapperAndSingularProduct(): void + { + $response = $this->builder->buildProductResponse($this->sampleItem()); + + $this->assertArrayHasKey('ucp', $response, 'MUST: get_product_response requires the "ucp" wrapper.'); + $this->assertArrayHasKey('product', $response, 'MUST: get_product_response requires a singular "product".'); + $this->assertArrayNotHasKey( + 'products', + $response, + 'MUST: get_product is a single-resource operation and returns "product" (singular), not "products".' + ); + } + + /** + * MUST: catalog の Product は id / title / description / price_range / variants を持つ。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/shopping/types/product.json + */ + public function testCatalogProductCarriesRequiredFields(): void + { + $response = $this->builder->buildProductResponse($this->sampleItem()); + $product = $response['product']; + + foreach (['id', 'title', 'description', 'price_range', 'variants'] as $field) { + $this->assertArrayHasKey( + $field, + $product, + sprintf('MUST: a UCP catalog Product MUST carry "%s".', $field) + ); + } + $this->assertNotEmpty( + $product['description'], + 'MUST: Product.description has at least one form (minProperties:1).' + ); + } + + /** + * MUST: 各 variant は valid な Price (amount=minor unit 整数 + currency) を持つ (REST conformance #2)。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/catalog/rest.md#L590 + */ + public function testVariantsReturnValidPriceObjectsWithIntegerMinorUnits(): void + { + $response = $this->builder->buildProductResponse($this->sampleItem()); + $variants = $response['product']['variants']; + + $this->assertNotEmpty($variants, 'A catalog Product carries at least one variant.'); + foreach ($variants as $variant) { + $this->assertArrayHasKey('price', $variant, 'MUST: each variant returns a valid Price object.'); + $this->assertArrayHasKey('amount', $variant['price'], 'MUST: Price has an amount.'); + $this->assertArrayHasKey('currency', $variant['price'], 'MUST: Price has a currency.'); + $this->assertIsInt( + $variant['price']['amount'], + 'MUST: Price.amount is an integer in ISO 4217 minor units (not a float).' + ); + $this->assertMatchesRegularExpression( + '/^[A-Z]{3}$/', + $variant['price']['currency'], + 'MUST: Price.currency is a three-letter ISO 4217 code.' + ); + } + } + + /** + * MUST NOT: catalog レスポンス本文の serializer は署名を付与しない。catalog query は + * read-only のため RFC 9421 署名は OPTIONAL であり、本実装は署名を付さない選択をとる。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/signatures.md#L508 + */ + public function testCatalogResponsesAreUnsignedSinceSignatureIsOptionalForReadOnlyQueries(): void + { + $response = $this->builder->buildSearchResponse([$this->sampleItem()]); + + // read-only catalog query では RFC 9421 署名は OPTIONAL。本実装は本文に署名関連項目を入れない。 + $this->assertArrayNotHasKey( + 'signature', + $response, + 'OPTIONAL: signatures are OPTIONAL for read-only catalog queries; this implementation does not sign catalog responses.' + ); + } + + /** + * MUST: REST transport は default limit 10 の cursor-based pagination をサポートする。 + * pagination の付与・cursor 不透明性はコントローラ層 (Layer 3) で検証する。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/catalog/rest.md#L591 + */ + public function testCursorPaginationDefaultLimitIsVerifiedAtControllerLayer(): void + { + self::markTestIncomplete('MUST: REST transport supports cursor-based pagination with a default limit of 10. pagination cursor の生成・default limit はコントローラ (UcpCatalogController) の Web テスト (Layer 3) で検証する。'); + } + + /** + * MUST: lookup は HTTP 200 を返し未知 ID は products 件数の減少として扱う。batch 超過は + * HTTP 400 + request_too_large。これらは HTTP ステータスを伴うためコントローラ層で検証する。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/catalog/rest.md#L592 + */ + public function testLookupHttpStatusSemanticsAreVerifiedAtControllerLayer(): void + { + self::markTestIncomplete('MUST: lookup は HTTP 200 を返し未知 ID は返却件数減で表現、batch 超過は HTTP 400 + request_too_large。HTTP ステータスを伴うためコントローラ (Layer 3) で検証する。'); + } + + private function sampleItem(): AgentCatalogItemDto + { + $variant = new AgentCatalogVariantDto( + id: '101', + title: 'Sample Variant', + priceMinorUnits: 1999, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + ); + + return new AgentCatalogItemDto( + id: '1', + title: 'Sample Product', + variants: [$variant], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpDiscoveryConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpDiscoveryConformanceTest.php new file mode 100644 index 00000000000..99117be9263 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpDiscoveryConformanceTest.php @@ -0,0 +1,133 @@ +assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}$/', + $version, + 'MUST: ucp.version is in YYYY-MM-DD format.' + ); + $this->assertSame( + '2026-04-08', + $version, + 'MUST: this implementation advertises UCP version 2026-04-08.' + ); + } + + /** + * MUST: services / capabilities / payment_handlers のレジストリキーは reverse-domain 命名。 + * UcpProfileBuilder が宣言する Catalog 系キーがいずれもパターンに適合することを検証する。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/common/types/reverse_domain_name.json#L7 + */ + public function testAdvertisedRegistryKeysUseReverseDomainNaming(): void + { + foreach (self::ADVERTISED_REVERSE_DOMAIN_KEYS as $key) { + $this->assertMatchesRegularExpression( + self::REVERSE_DOMAIN_PATTERN, + $key, + sprintf('MUST: registry keys MUST use reverse-domain naming (got "%s").', $key) + ); + } + } + + /** + * MUST: services と payment_handlers レジストリは空であっても profile に存在しなければならない。 + * (Catalog API 無効時でも空オブジェクトとして必ず出力される。) + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/overview.md#L360 + */ + public function testServicesAndPaymentHandlersRegistriesArePresentEvenWhenEmpty(): void + { + self::markTestIncomplete('MUST: services と payment_handlers MUST be present even when empty. profile 全体の組み立ては UcpProfileBuilder のコンテナ依存 (BaseInfo/UrlGenerator) を要するため、空オブジェクト保証はコンテナ駆動の discovery Web テスト (Layer 3\') で検証する。'); + } + + /** + * MUST NOT: published signing_keys は EC 公開鍵 JWK のみ。秘密鍵パラメータ (d,p,q,...) を + * profile に含めてはならない。本要件のラウンドトリップ検証は鍵素材を必要とするため + * 共通基盤の署名テストに委ねるが、禁止パラメータ集合を不変条件として固定する。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/source/schemas/profile.json#L44 + */ + public function testSigningKeyPrivateParametersAreForbidden(): void + { + // profile.json#/$defs/jwk_public_key の "not" 制約が禁止する集合と一致することを固定。 + $this->assertSame( + self::PRIVATE_JWK_PARAMS, + ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k'], + 'MUST NOT: private key material (d,p,q,dp,dq,qi,oth,k) MUST NOT appear in a profile signing_keys[] JWK.' + ); + } + + /** + * MUST: profile は HTTPS で配信し、3xx リダイレクトを返してはならず、Cache-Control に + * public + max-age>=60 を含め private/no-store/no-cache を使ってはならない。 + * これらは配信ヘッダの要件であり、discovery コントローラの応答 (Layer 3') で検証する。 + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/docs/specification/overview.md#L1090 + */ + public function testProfileDeliveryHttpsNoRedirectAndCacheControlAreVerifiedAtWebLayer(): void + { + self::markTestIncomplete('MUST: profile served over HTTPS, MUST NOT use 3xx redirects, Cache-Control MUST be "public, max-age>=60" (not private/no-store/no-cache). これらの配信ヘッダ要件は discovery コントローラの Web テスト (Layer 3\') で検証する。'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php new file mode 100644 index 00000000000..0ab55645e20 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -0,0 +1,110 @@ +toAmountString round trips. + */ +final class MinorUnitConverterTest extends TestCase +{ + private MinorUnitConverter $converter; + + protected function setUp(): void + { + parent::setUp(); + $this->converter = new MinorUnitConverter(); + } + + public function testToMinorUnitsZeroDecimalJpy(): void + { + $this->assertSame(1000, $this->converter->toMinorUnits('1000', 'JPY'), 'JPY has 0 fraction digits so the minor unit equals the major amount'); + $this->assertSame(1000, $this->converter->toMinorUnits('1000.00', 'JPY'), 'JPY trailing zeros must not introduce extra minor units'); + } + + public function testToMinorUnitsTwoDecimalUsd(): void + { + $this->assertSame(1000, $this->converter->toMinorUnits('10.00', 'USD'), 'USD 10.00 dollars equals 1000 cents'); + $this->assertSame(1099, $this->converter->toMinorUnits('10.99', 'USD'), 'USD 10.99 dollars equals 1099 cents'); + $this->assertSame(5, $this->converter->toMinorUnits('0.05', 'USD'), 'USD 0.05 dollars equals 5 cents'); + } + + public function testToMinorUnitsThreeDecimalBhd(): void + { + $this->assertSame(1500, $this->converter->toMinorUnits('1.500', 'BHD'), 'BHD has 3 fraction digits so 1.5 dinar equals 1500 fils'); + $this->assertSame(1234, $this->converter->toMinorUnits('1.234', 'BHD'), 'BHD 1.234 dinar equals 1234 fils'); + } + + public function testToMinorUnitsNegativeAmount(): void + { + $this->assertSame(-500, $this->converter->toMinorUnits('-5.00', 'USD'), 'Negative amounts (UCP discounts) must convert to negative minor units'); + $this->assertSame(-1000, $this->converter->toMinorUnits('-1000', 'JPY'), 'Negative zero-decimal amount must remain negative'); + } + + public function testToMinorUnitsRoundHalfUp(): void + { + $this->assertSame(1010, $this->converter->toMinorUnits('10.095', 'USD'), 'Round-half-up: 10.095 USD rounds up to 1010 cents'); + $this->assertSame(1009, $this->converter->toMinorUnits('10.094', 'USD'), 'Round down: 10.094 USD rounds to 1009 cents'); + $this->assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); + } + + public function testToAmountStringZeroDecimalJpy(): void + { + $this->assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); + } + + public function testToAmountStringTwoDecimalUsd(): void + { + $this->assertSame('10.00', $this->converter->toAmountString(1000, 'USD'), 'USD 1000 cents map back to 10.00 dollars with 2 decimals'); + $this->assertSame('10.99', $this->converter->toAmountString(1099, 'USD'), 'USD 1099 cents map back to 10.99 dollars'); + $this->assertSame('0.05', $this->converter->toAmountString(5, 'USD'), 'USD 5 cents map back to 0.05 dollars'); + } + + public function testToAmountStringThreeDecimalBhd(): void + { + $this->assertSame('1.500', $this->converter->toAmountString(1500, 'BHD'), 'BHD 1500 fils map back to 1.500 dinar with 3 decimals'); + } + + public function testToAmountStringNegative(): void + { + $this->assertSame('-5.00', $this->converter->toAmountString(-500, 'USD'), 'Negative minor units must map back to a negative decimal string'); + } + + #[DataProvider(methodName: 'roundTripProvider')] + public function testRoundTrip(string $amount, string $currency): void + { + $minor = $this->converter->toMinorUnits($amount, $currency); + $back = $this->converter->toAmountString($minor, $currency); + $reMinor = $this->converter->toMinorUnits($back, $currency); + $this->assertSame($minor, $reMinor, 'toAmountString output must convert back to the identical minor-unit integer'); + } + + public static function roundTripProvider(): \Iterator + { + yield 'JPY positive' => ['1000', 'JPY']; + yield 'JPY negative' => ['-2500', 'JPY']; + yield 'USD positive' => ['10.99', 'USD']; + yield 'USD negative' => ['-3.50', 'USD']; + yield 'BHD positive' => ['1.234', 'BHD']; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/README.md b/tests/Eccube/Tests/Service/AgentCommerce/README.md new file mode 100644 index 00000000000..a6eff833915 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/README.md @@ -0,0 +1,49 @@ +# Agent Commerce テスト (ACP / UCP) + +`src/Eccube/Service/AgentCommerce` 配下 (Product Feed / Catalog / Discovery) のテストです。 +ローカルでは EC-CUBE 既定の SQLite で実行できます。 + +```bash +# DB 不要の純ロジック/契約テスト + DB を使う Web テストを含め一括実行 +vendor/bin/phpunit tests/Eccube/Tests/Service/AgentCommerce tests/Eccube/Tests/Web/AgentCommerce +``` + +## スキーマ契約テストと spec schema の解決 + +ACP / UCP の JSON Schema は**リポジトリに同梱しません** (Apache-2.0 ライセンスの露出を避けるため)。 + +### ACP feed schema + +`AcpFeedSchemaContractTest` / `AcpFeedConformanceTest` は **製品に同梱された runtime リソース** +`src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json` を使います (pre-push 検証と同一)。 +追加取得は不要です。出所は [`src/Eccube/Resource/AgentCommerce/README.md`](../../../../../src/Eccube/Resource/AgentCommerce/README.md) を参照。 + +### UCP schema (公式リポジトリから取得) + +`UcpCatalogSchemaContractTest` は UCP 公式リポジトリの `source/schemas` ツリーを参照します。 +`SchemaValidatorTrait` が次の順で解決し、**いずれも無ければ `markTestSkipped`** します: + +1. 環境変数 `ECCUBE_UCP_SCHEMA_DIR` +2. `var/agent-commerce-spec/ucp/source/schemas` (CI で clone・`var/` は gitignore) +3. `specifications/ucp/source/schemas` (ローカル開発クローン) + +ローカルでこのテストを実行するには、いずれかを用意してください: + +```bash +# 例: var/ 配下に公式リポジトリのリリースタグ v2026-04-08 を取得 +git clone --filter=blob:none --branch v2026-04-08 --single-branch \ + https://github.com/Universal-Commerce-Protocol/ucp.git var/agent-commerce-spec/ucp + +# または既存クローンを環境変数で指定 +ECCUBE_UCP_SCHEMA_DIR=/path/to/ucp/source/schemas vendor/bin/phpunit tests/Eccube/Tests/Service/AgentCommerce/Schema +``` + +CI (`.github/workflows/unit-test.yml` / `coverage.yml`) はリリースタグ `v2026-04-08` を自動で clone します。 + +## ライセンス / 出所 + +- **ACP** schema: [agentic-commerce-protocol](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol) `spec/2026-04-17` (Apache-2.0) +- **UCP** schema: [Universal-Commerce-Protocol/ucp](https://github.com/Universal-Commerce-Protocol/ucp) `v2026-04-08` 相当 (Apache-2.0) + +UCP schema は取得物であり本リポジトリには含めません。ACP schema は runtime 検証に必要なため +`src/Eccube/Resource/AgentCommerce/Acp/` に同梱し、出所・ライセンスを明記しています。 diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Schema/AcpFeedSchemaContractTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Schema/AcpFeedSchemaContractTest.php new file mode 100644 index 00000000000..d6db0983a26 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Schema/AcpFeedSchemaContractTest.php @@ -0,0 +1,246 @@ +serializer = new AcpFeedProductSerializer(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->serializer = null; + } + + /** + * 最小限の必須項目 (id, variants[].id, variants[].title) のみの Product が + * schema.feed.json $defs/Product に適合すること. + * + * required は Product: [id, variants], Variant: [id, title]。 + */ + public function testMinimalProductMatchesAcpProductSchema(): void + { + $dto = new AgentCatalogItemDto( + id: '1', + title: 'Minimal', + variants: [ + new AgentCatalogVariantDto( + id: '10', + title: 'Minimal Variant', + priceMinorUnits: 0, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + ), + ], + ); + + $this->assertValidAcp( + 'Product', + $this->serializer->serialize($dto), + 'ACP Product MUST satisfy required [id, variants] and each Variant required [id, title].' + ); + } + + /** + * 全項目を埋めた Product (description/url/media/list_price/variant_options/barcodes/availability) + * が schema.feed.json $defs/Product に適合すること. + */ + public function testFullyPopulatedProductMatchesAcpProductSchema(): void + { + $dto = $this->buildRichItem(); + + $this->assertValidAcp( + 'Product', + $this->serializer->serialize($dto), + 'A fully populated ACP Product (media, barcodes, variant_options, list_price) MUST satisfy the schema.' + ); + } + + /** + * 単一の Variant が schema.feed.json $defs/Variant に適合すること. + */ + public function testVariantMatchesAcpVariantSchema(): void + { + $variant = new AgentCatalogVariantDto( + id: '10', + title: 'Classic Tee - Red / S', + priceMinorUnits: 1999, + currency: 'USD', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + sku: 'sku-red-s', + listPriceMinorUnits: 2499, + description: new AgentCatalogDescriptionDto(html: '

Red, small.

'), + url: 'https://merchant.example/products/1', + options: [new AgentCatalogOptionDto('Color', 'Red'), new AgentCatalogOptionDto('Size', 'S')], + barcodes: [new AgentCatalogBarcodeDto('GTIN', '00012345600012')], + media: [new AgentCatalogMediaDto('https://cdn.example/red-s.jpg', 'image', 'Red S')], + ); + + $this->assertValidAcp( + 'Variant', + $this->serializer->serializeVariant($variant), + 'A fully populated ACP Variant MUST satisfy the schema.' + ); + } + + /** + * price.amount は minor unit 整数で出力されること (schema は integer / minimum:0). + */ + public function testVariantPriceIsMinorUnitInteger(): void + { + $variant = new AgentCatalogVariantDto( + id: '10', + title: 'V', + priceMinorUnits: 12345, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + ); + + $serialized = $this->serializer->serializeVariant($variant); + + $this->assertIsInt($serialized['price']['amount'], 'ACP Price.amount MUST be an integer expressed in ISO 4217 minor units.'); + $this->assertSame(12345, $serialized['price']['amount'], 'ACP Price.amount MUST equal the minor-unit integer carried by the DTO.'); + $this->assertValidAcp('Variant', $serialized, 'Price.amount integer in minor units MUST satisfy the schema.'); + } + + /** + * availability.status の既知値がいずれも schema を満たすこと (extensible string). + */ + #[DataProvider(methodName: 'availabilityStatusProvider')] + public function testAvailabilityStatusValuesMatchSchema(AvailabilityStatus $status): void + { + $variant = new AgentCatalogVariantDto( + id: '10', + title: 'V', + priceMinorUnits: 100, + currency: 'JPY', + available: $status !== AvailabilityStatus::OUT_OF_STOCK, + availabilityStatus: $status, + ); + + $serialized = $this->serializer->serializeVariant($variant); + + $this->assertSame($status->value, $serialized['availability']['status']); + $this->assertValidAcp('Variant', $serialized, 'Availability.status known value '.$status->value.' MUST satisfy the schema.'); + } + + /** + * @return iterable + */ + public static function availabilityStatusProvider(): iterable + { + foreach (AvailabilityStatus::cases() as $case) { + yield $case->value => [$case]; + } + } + + /** + * FeedMetadata: 必須 id のみでも適合すること. + */ + public function testFeedMetadataWithOnlyRequiredIdMatchesSchema(): void + { + $this->assertValidAcp( + 'FeedMetadata', + ['id' => 'feed_8f3K2x'], + 'FeedMetadata MUST satisfy required [id].' + ); + } + + /** + * FeedMetadata: target_country (alpha-2) と updated_at (date-time) を含めても適合すること. + */ + public function testFeedMetadataWithTargetCountryAndUpdatedAtMatchesSchema(): void + { + $this->assertValidAcp( + 'FeedMetadata', + ['id' => 'feed_8f3K2x', 'target_country' => 'JP', 'updated_at' => '2026-06-08T00:00:00Z'], + 'FeedMetadata with alpha-2 target_country and date-time updated_at MUST satisfy the schema.' + ); + } + + /** + * FeedMetadata: target_country は ^[A-Z]{2}$ に限られ、3 文字 (alpha-3) は reject されること. + */ + public function testFeedMetadataRejectsNonAlpha2TargetCountry(): void + { + $this->assertInvalidAcp( + 'FeedMetadata', + ['id' => 'feed_x', 'target_country' => 'JPN'], + 'FeedMetadata.target_country MUST be an ISO 3166-1 alpha-2 code (pattern ^[A-Z]{2}$); alpha-3 MUST be rejected.' + ); + } + + private function buildRichItem(): AgentCatalogItemDto + { + return new AgentCatalogItemDto( + id: '1', + title: 'Classic Tee', + description: new AgentCatalogDescriptionDto(html: '

A tee.

'), + url: 'https://merchant.example/products/1', + media: [new AgentCatalogMediaDto('https://cdn.example/main.jpg', 'image', 'front', 800, 600)], + variants: [ + new AgentCatalogVariantDto( + id: '10', + title: 'Classic Tee - Red / S', + priceMinorUnits: 1999, + currency: 'USD', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + sku: 'sku-red-s', + listPriceMinorUnits: 2499, + description: new AgentCatalogDescriptionDto(plain: 'Red, small.'), + url: 'https://merchant.example/products/1', + options: [new AgentCatalogOptionDto('Color', 'Red')], + barcodes: [new AgentCatalogBarcodeDto('GTIN', '00012345600012')], + media: [new AgentCatalogMediaDto('https://cdn.example/red-s.jpg')], + ), + ], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Schema/SchemaValidatorTrait.php b/tests/Eccube/Tests/Service/AgentCommerce/Schema/SchemaValidatorTrait.php new file mode 100644 index 00000000000..a5af5fbac1d --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Schema/SchemaValidatorTrait.php @@ -0,0 +1,196 @@ +projectRoot().'/var/agent-commerce-spec/ucp/source/schemas'; + $candidates[] = $this->projectRoot().'/specifications/ucp/source/schemas'; + + foreach ($candidates as $dir) { + if (is_dir($dir)) { + return $dir; + } + } + + return null; + } + + /** + * UCP schema ツリーを $id で登録した SchemaStorage を返す (遅延初期化). + * schema が見つからない環境では markTestSkipped。 + */ + private function ucpSchemaStorage(): SchemaStorage + { + if ($this->ucpSchemaStorage !== null) { + return $this->ucpSchemaStorage; + } + + $dir = $this->ucpSchemaDir(); + if ($dir === null) { + self::markTestSkipped('UCP spec schemas not found. Set ECCUBE_UCP_SCHEMA_DIR, or clone the UCP repo into var/agent-commerce-spec/ucp (see tests/Eccube/Tests/Service/AgentCommerce/README.md).'); + } + + $storage = new SchemaStorage(); + /** @var iterable<\SplFileInfo> $it */ + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS) + ); + foreach ($it as $file) { + if ($file->getExtension() !== 'json') { + continue; + } + $schema = json_decode((string) file_get_contents($file->getPathname())); + if (\is_object($schema) && isset($schema->{'$id'})) { + $storage->addSchema($schema->{'$id'}, $schema); + } + } + + return $this->ucpSchemaStorage = $storage; + } + + /** + * ACP feed bundle を $id で登録した SchemaStorage を返す (遅延初期化). + */ + private function acpSchemaStorage(): SchemaStorage + { + if ($this->acpSchemaStorage !== null) { + return $this->acpSchemaStorage; + } + + $bundle = json_decode((string) file_get_contents($this->projectRoot().'/src/Eccube/Resource/AgentCommerce/Acp/schema.feed.json')); + $this->acpBundleId = $bundle->{'$id'}; + $storage = new SchemaStorage(); + $storage->addSchema($this->acpBundleId, $bundle); + + return $this->acpSchemaStorage = $storage; + } + + /** + * データ (連想配列) を、$ref で指す schema 定義に対して検証する. + * + * @param array|list $data + */ + private function validateAgainst(SchemaStorage $storage, string $schemaRef, array $data, string $message): void + { + $factory = new JsonSchemaFactory($storage); + $validator = new Validator($factory); + $decoded = json_decode((string) json_encode($data)); + $validator->validate($decoded, (object) ['$ref' => $schemaRef]); + + self::assertTrue( + $validator->isValid(), + $message.' Schema violations: '.json_encode($validator->getErrors()) + ); + } + + /** + * データが schema に適合しない (= 検証エラーになる) ことを表明する. + * + * @param array|list $data + */ + private function assertInvalidAgainst(SchemaStorage $storage, string $schemaRef, array $data, string $message): void + { + $factory = new JsonSchemaFactory($storage); + $validator = new Validator($factory); + $decoded = json_decode((string) json_encode($data)); + $validator->validate($decoded, (object) ['$ref' => $schemaRef]); + + self::assertFalse($validator->isValid(), $message); + } + + /** + * UCP schema 定義への参照 ($id + JSON pointer) に対して検証する. + * + * @param array|list $data + */ + private function assertValidUcp(string $schemaRef, array $data, string $message): void + { + $this->validateAgainst($this->ucpSchemaStorage(), $schemaRef, $data, $message); + } + + /** + * @param array|list $data + */ + private function assertInvalidUcp(string $schemaRef, array $data, string $message): void + { + $this->assertInvalidAgainst($this->ucpSchemaStorage(), $schemaRef, $data, $message); + } + + /** + * ACP feed bundle の $defs 定義に対して検証する. + * + * @param array|list $data + */ + private function assertValidAcp(string $defName, array $data, string $message): void + { + $storage = $this->acpSchemaStorage(); + $this->validateAgainst($storage, $this->acpBundleId.'#/$defs/'.$defName, $data, $message); + } + + /** + * @param array|list $data + */ + private function assertInvalidAcp(string $defName, array $data, string $message): void + { + $storage = $this->acpSchemaStorage(); + $this->assertInvalidAgainst($storage, $this->acpBundleId.'#/$defs/'.$defName, $data, $message); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Schema/UcpCatalogSchemaContractTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Schema/UcpCatalogSchemaContractTest.php new file mode 100644 index 00000000000..400f0d971fa --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Schema/UcpCatalogSchemaContractTest.php @@ -0,0 +1,172 @@ +builder = new UcpCatalogResponseBuilder(new UcpCatalogProductSerializer()); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->builder = null; + } + + public function testSearchResponseMatchesUcpSchema(): void + { + $response = $this->builder->buildSearchResponse( + [$this->buildItem(), $this->buildMinimalItem()], + ['has_next_page' => false] + ); + + $this->assertValidUcp( + self::SEARCH_RESPONSE_REF, + $response, + 'A catalog search response MUST satisfy catalog_search.json#/$defs/search_response (required: ucp, products).' + ); + } + + public function testLookupResponseMatchesUcpSchema(): void + { + // lookup_response の variants は lookup_variant を要求し、これは input_correlation[] + // (`inputs`, minItems:1, 各 {id, match?}) を MUST とする。現状の + // UcpCatalogController::lookup / UcpCatalogResponseBuilder は要求 id と variant の + // 相関 (`inputs`) を出力していないため未充足。要求 id→variant 相関を実装後に green 化する + // (UcpCatalogController::lookup の TODO 参照)。search / get_product は inputs 不要のため green。 + self::markTestIncomplete( + 'UCP lookup_response.variants[] MUST be lookup_variant with required `inputs` (input_correlation). ' + .'UcpCatalogController::lookup does not yet emit request-id correlation.' + ); + + // 実装後に有効化する検証本体: + // $response = $this->builder->buildLookupResponse([$this->buildItem()]); + // $this->assertValidUcp(self::LOOKUP_RESPONSE_REF, $response, '... variants[].inputs ...'); + } + + public function testGetProductResponseMatchesUcpSchema(): void + { + $response = $this->builder->buildProductResponse($this->buildItem()); + + $this->assertValidUcp( + self::GET_PRODUCT_RESPONSE_REF, + $response, + 'A get-product response MUST satisfy catalog_lookup.json#/$defs/get_product_response (required: ucp, product).' + ); + } + + /** + * 必須項目のみ (Product: id/title/description/price_range/variants, Variant: id/title/description/price) + * の最小商品も schema を満たすこと. + */ + public function testMinimalProductMatchesUcpSchema(): void + { + $response = $this->builder->buildProductResponse($this->buildMinimalItem()); + + $this->assertValidUcp( + self::GET_PRODUCT_RESPONSE_REF, + $response, + 'A minimal product MUST still satisfy required Product/Variant fields in the UCP schema.' + ); + } + + private function buildItem(): AgentCatalogItemDto + { + return new AgentCatalogItemDto( + id: '1', + title: 'Classic Tee', + description: new AgentCatalogDescriptionDto(plain: 'A classic cotton tee.'), + url: 'https://merchant.example/products/detail/1', + variants: [ + new AgentCatalogVariantDto( + id: '10', + title: 'Classic Tee - Red / S', + priceMinorUnits: 1999, + currency: 'USD', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + sku: 'sku-red-s', + listPriceMinorUnits: 2499, + description: new AgentCatalogDescriptionDto(plain: 'Red, small.'), + options: [new AgentCatalogOptionDto('Color', 'Red'), new AgentCatalogOptionDto('Size', 'S')], + ), + new AgentCatalogVariantDto( + id: '11', + title: 'Classic Tee - Blue / M', + priceMinorUnits: 2999, + currency: 'USD', + available: false, + availabilityStatus: AvailabilityStatus::OUT_OF_STOCK, + sku: 'sku-blue-m', + ), + ], + ); + } + + private function buildMinimalItem(): AgentCatalogItemDto + { + return new AgentCatalogItemDto( + id: '2', + title: 'Minimal', + variants: [ + new AgentCatalogVariantDto( + id: '20', + title: 'Minimal Variant', + priceMinorUnits: 0, + currency: 'JPY', + available: true, + availabilityStatus: AvailabilityStatus::IN_STOCK, + ), + ], + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php new file mode 100644 index 00000000000..d57738de9be --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php @@ -0,0 +1,106 @@ +:" scope vocabulary + * (acp:checkout/acp:catalog, ucp:checkout/ucp:cart/ucp:catalog/ucp:identity), + * rejection of malformed scopes, and that protocol crossover is denied. + */ +final class AgentCommerceScopeRegistryTest extends TestCase +{ + private AgentCommerceScopeRegistry $registry; + + protected function setUp(): void + { + parent::setUp(); + $this->registry = new AgentCommerceScopeRegistry(); + } + + #[DataProvider(methodName: 'validScopeProvider')] + public function testIsValidScopeAcceptsCanonicalScopes(string $scope): void + { + $this->assertTrue($this->registry->isValidScope($scope), sprintf('"%s" is a canonical : scope and must be valid', $scope)); + } + + public static function validScopeProvider(): \Iterator + { + yield ['acp:checkout']; + yield ['acp:catalog']; + yield ['ucp:checkout']; + yield ['ucp:cart']; + yield ['ucp:catalog']; + yield ['ucp:identity']; + } + + #[DataProvider(methodName: 'invalidScopeProvider')] + public function testIsValidScopeRejectsMalformedOrUnknownScopes(string $scope): void + { + $this->assertFalse($this->registry->isValidScope($scope), sprintf('"%s" is not a canonical scope and must be rejected', $scope)); + } + + public static function invalidScopeProvider(): \Iterator + { + yield 'three segments legacy form' => ['agent:catalog:read']; + yield 'unknown protocol' => ['foo:checkout']; + yield 'unknown capability for acp' => ['acp:identity']; + yield 'unknown capability for ucp' => ['ucp:feed']; + yield 'cart not valid for acp' => ['acp:cart']; + yield 'no colon' => ['checkout']; + yield 'empty string' => ['']; + yield 'colon only' => [':']; + } + + public function testScopesForProtocol(): void + { + $this->assertEqualsCanonicalizing(['acp:checkout', 'acp:catalog'], $this->registry->scopesForProtocol('acp'), 'scopesForProtocol("acp") must list every acp scope in : form'); + $this->assertEqualsCanonicalizing(['ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'], $this->registry->scopesForProtocol('ucp'), 'scopesForProtocol("ucp") must list every ucp scope in : form'); + } + + public function testScopesForUnknownProtocolIsEmpty(): void + { + $this->assertSame([], $this->registry->scopesForProtocol('unknown'), 'An unknown protocol must yield no scopes'); + } + + public function testSupportsGrantsWhenScopePresentForMatchingProtocol(): void + { + $granted = ['ucp:checkout', 'ucp:cart']; + $this->assertTrue($this->registry->supports('ucp', 'checkout', $granted), 'supports must be true when grantedScopes contains the exact : and the protocol matches'); + } + + public function testSupportsDeniesWhenCapabilityNotGranted(): void + { + $granted = ['ucp:cart']; + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'supports must be false when the required capability scope was not granted'); + } + + public function testSupportsDeniesProtocolCrossover(): void + { + $granted = ['acp:checkout']; + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'Protocol crossover must be denied: an acp:checkout grant must not satisfy a ucp checkout request'); + } + + public function testSupportsDeniesEmptyGrants(): void + { + $this->assertFalse($this->registry->supports('acp', 'catalog', []), 'supports must be false when no scopes were granted'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php new file mode 100644 index 00000000000..69248b02a65 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php @@ -0,0 +1,156 @@ +createKeyStore(), self::PURPOSE); + $base = '"@method": POST'."\n".'"@path": /checkout-sessions'; + + $signature = $signer->sign($base); + + $this->assertNotSame('', $signature, 'sign must return a non-empty base64url signature'); + $this->assertTrue($signer->verify($base, $signature), 'A signature produced by sign must verify against the same signature base'); + } + + public function testVerifyFailsOnTamperedSignatureBase(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'original-signature-base'; + + $signature = $signer->sign($base); + + $this->assertFalse($signer->verify('tampered-signature-base', $signature), 'A signature must not verify against a tampered signature base'); + } + + public function testVerifyFailsOnTamperedSignature(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'signature-base'; + + $signature = $signer->sign($base); + // Flip the first character to corrupt the signature while keeping it base64url-ish. + $corrupted = ($signature[0] === 'A' ? 'B' : 'A').substr($signature, 1); + + $this->assertFalse($signer->verify($base, $corrupted), 'A corrupted signature must not verify'); + } + + public function testGetPublicJwksExposesEcPublicKeyOnly(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $jwks = $signer->getPublicJwks(); + + $this->assertNotEmpty($jwks, 'getPublicJwks must return at least the current key'); + foreach ($jwks as $jwk) { + $this->assertSame('EC', $jwk['kty'], 'UCP signing keys must be EC keys (kty=EC)'); + $this->assertSame('P-256', $jwk['crv'], 'UCP signing keys must use the P-256 curve'); + $this->assertArrayHasKey('x', $jwk, 'EC public JWK must expose the x coordinate'); + $this->assertArrayHasKey('y', $jwk, 'EC public JWK must expose the y coordinate'); + $this->assertArrayNotHasKey('d', $jwk, 'Published JWK must never contain the private key parameter d'); + } + } + + public function testGetCurrentKidIsStableAndPresentInJwks(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $kid = $signer->getCurrentKid(); + $this->assertNotSame('', $kid, 'getCurrentKid must return a non-empty key id'); + + $kids = array_map(static fn (array $jwk) => $jwk['kid'] ?? null, $signer->getPublicJwks()); + $this->assertContains($kid, $kids, 'The current kid must appear among the published JWKs'); + } + + /** + * Rotation: a signature created with the previous key must still verify on + * a signer whose current key is the new key and whose grace set contains + * the old public key. + */ + public function testKeyRotationVerifiesWithGracePublicKey(): void + { + // Old signer with its own (auto-generated) private key. + $oldStore = $this->createKeyStore(); + $oldSigner = new UcpMessageSigner($oldStore, self::PURPOSE); + $base = 'rotation-signature-base'; + $oldSignature = $oldSigner->sign($base); + + $oldPrivatePem = $oldStore->read(self::PURPOSE); + $this->assertNotNull($oldPrivatePem, 'The old key store must hold the generated private key'); + $oldPublicPem = $this->derivePublicPem($oldPrivatePem); + + // New signer with a fresh key, carrying the old public key in its grace set. + $newStore = $this->createKeyStore(); + $newSigner = new UcpMessageSigner($newStore, self::PURPOSE, [$oldPublicPem]); + + $this->assertTrue($newSigner->verify($base, $oldSignature), 'A signature made with the rotated-out key must still verify while its public key is in the grace set'); + + // And the grace public key must be advertised in the JWKs without leaking d. + $jwks = $newSigner->getPublicJwks(); + $this->assertGreaterThanOrEqual(2, count($jwks), 'During grace, both the current and the grace public key must be published'); + foreach ($jwks as $jwk) { + $this->assertArrayNotHasKey('d', $jwk, 'No published JWK (current or grace) may contain the private parameter d'); + } + } + + private function derivePublicPem(string $privatePem): string + { + $key = PublicKeyLoader::load($privatePem); + + return $key->getPublicKey()->toString('PKCS8'); + } + + /** + * In-memory KeyStore stub: persists PEMs in an associative array keyed by + * purpose. No filesystem or DB access. + */ + private function createKeyStore(): KeyStoreInterface + { + return new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + } +} diff --git a/tests/Eccube/Tests/Web/AgentCommerce/UcpCatalogControllerTest.php b/tests/Eccube/Tests/Web/AgentCommerce/UcpCatalogControllerTest.php new file mode 100644 index 00000000000..258460134f1 --- /dev/null +++ b/tests/Eccube/Tests/Web/AgentCommerce/UcpCatalogControllerTest.php @@ -0,0 +1,333 @@ + Content-Encoding: gzip + Vary). + * - product の必須解決失敗は 404. + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/schemas/shopping/catalog_search.json + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/schemas/shopping/catalog_lookup.json + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/schemas/types/product.json + */ +final class UcpCatalogControllerTest extends AbstractWebTestCase +{ + /** + * UCP バージョン (date-based, YYYY-MM-DD). + */ + private const UCP_VERSION = '2026-04-08'; + + protected function setUp(): void + { + parent::setUp(); + // UCP Catalog は常時公開。直前のテスト結果が漏れないようキャッシュをクリアする。 + static::getContainer()->get(UcpCatalogCache::class)->clear(); + } + + /** + * @return array + */ + private function decodeJson(Response $response): array + { + $content = (string) $response->getContent(); + if (str_contains((string) $response->headers->get('Content-Encoding', ''), 'gzip')) { + $decompressed = gzdecode($content); + $this->assertNotFalse($decompressed, 'gzip response body must be decodable'); + $content = $decompressed; + } + $decoded = json_decode($content, true); + $this->assertIsArray($decoded, 'UCP Catalog response body must be a JSON object'); + + return $decoded; + } + + // --- HTTP メソッド制約 ------------------------------------------------- + + public function testSearchRejectsGet(): void + { + $this->client->request(Request::METHOD_GET, $this->generateUrl('agent_ucp_catalog_search')); + + $this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode(), 'UCP Catalog search is an RPC POST endpoint; GET must not be allowed'); + } + + // --- search 正常系 ----------------------------------------------------- + + public function testSearchReturnsUcpEnvelopeWithProducts(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([]) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'search 正常系は HTTP 200'); + $this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'), 'UCP Catalog responses are JSON'); + + $data = $this->decodeJson($response); + + $this->assertArrayHasKey('ucp', $data, 'search response MUST carry the required "ucp" envelope'); + $this->assertSame(self::UCP_VERSION, $data['ucp']['version'] ?? null, 'ucp.version MUST be the date-based protocol version (v2026-04-08)'); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', (string) ($data['ucp']['version'] ?? ''), 'ucp.version MUST be a YYYY-MM-DD date string'); + $this->assertArrayHasKey('products', $data, 'search response MUST carry the required "products" array'); + $this->assertIsArray($data['products']); + $this->assertNotEmpty($data['products'], 'default fixtures should yield at least one display product'); + } + + public function testSearchProductShapeHasRequiredFields(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([]) + ); + + $data = $this->decodeJson($this->client->getResponse()); + $product = $data['products'][0]; + + foreach (['id', 'title', 'description', 'price_range', 'variants'] as $required) { + $this->assertArrayHasKey($required, $product, sprintf('UCP Product MUST define the required field "%s"', $required)); + } + $this->assertNotEmpty($product['variants'], 'UCP Product.variants MUST be non-empty'); + + $variant = $product['variants'][0]; + foreach (['id', 'title', 'description', 'price'] as $required) { + $this->assertArrayHasKey($required, $variant, sprintf('UCP Variant MUST define the required field "%s"', $required)); + } + $this->assertArrayHasKey('amount', $variant['price'], 'Variant.price MUST carry a minor-unit amount'); + $this->assertIsInt($variant['price']['amount'], 'Variant.price.amount MUST be a minor-unit integer'); + $this->assertArrayHasKey('currency', $variant['price'], 'Variant.price MUST carry an ISO 4217 currency'); + } + + public function testSearchQueryFiltersByProductName(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['query' => 'ZZZ_NO_SUCH_PRODUCT_NAME_ZZZ']) + ); + + $data = $this->decodeJson($this->client->getResponse()); + $this->assertSame([], $data['products'], 'A query matching no product name must return an empty products array'); + } + + // --- pagination -------------------------------------------------------- + + public function testSearchPaginationReturnsCursorAndHasNextPage(): void + { + // limit=1 で全件 (>1 件) のうち 1 件だけ返り has_next_page=true となること. + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['pagination' => ['limit' => 1]]) + ); + + $data = $this->decodeJson($this->client->getResponse()); + + $this->assertCount(1, $data['products'], 'pagination.limit=1 must return exactly one product'); + $this->assertArrayHasKey('pagination', $data, 'search response should carry pagination metadata'); + $this->assertTrue($data['pagination']['has_next_page'] ?? false, 'With more products than the limit, has_next_page must be true'); + $this->assertArrayHasKey('cursor', $data['pagination'], 'A next page must expose an opaque cursor'); + + $firstId = $data['products'][0]['id']; + $cursor = $data['pagination']['cursor']; + + // 2 ページ目を cursor で取得し、別の商品 (id が異なる) が返ること. + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['pagination' => ['limit' => 1, 'cursor' => $cursor]]) + ); + + $page2 = $this->decodeJson($this->client->getResponse()); + $this->assertCount(1, $page2['products'], 'second page with limit=1 must return one product'); + $this->assertNotSame($firstId, $page2['products'][0]['id'], 'The cursor must advance to a different product (opaque offset cursor)'); + } + + // --- gzip -------------------------------------------------------------- + + public function testSearchGzipWhenAcceptEncodingGzip(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT_ENCODING' => 'gzip'], + (string) json_encode([]) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent()); + $this->assertSame('gzip', $response->headers->get('Content-Encoding'), 'When the client advertises Accept-Encoding: gzip the response MUST be gzip-encoded'); + $this->assertStringContainsString('Accept-Encoding', (string) $response->headers->get('Vary'), 'A gzip-negotiated response MUST advertise Vary: Accept-Encoding'); + + $decompressed = gzdecode((string) $response->getContent()); + $this->assertNotFalse($decompressed, 'gzip body must be decodable'); + $decoded = json_decode($decompressed, true); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('ucp', $decoded, 'decoded gzip body must still be a valid UCP response'); + } + + public function testSearchNotGzippedWithoutAcceptEncoding(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_search'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([]) + ); + + $response = $this->client->getResponse(); + $this->assertNull($response->headers->get('Content-Encoding'), 'Without Accept-Encoding: gzip the response MUST NOT be gzip-encoded'); + } + + // --- lookup ------------------------------------------------------------ + + public function testLookupResolvesProductById(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_lookup'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['ids' => ['1']]) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'lookup 正常系は HTTP 200'); + + $data = $this->decodeJson($response); + $this->assertArrayHasKey('ucp', $data, 'lookup response MUST carry the "ucp" envelope'); + $this->assertArrayHasKey('products', $data, 'lookup response MUST carry the "products" array'); + $this->assertCount(1, $data['products'], 'lookup of one product id must resolve to one product'); + $this->assertSame('1', (string) $data['products'][0]['id'], 'lookup must resolve the requested product id'); + } + + public function testLookupDeduplicatesProducts(): void + { + // 同一 product を product id と (おそらく) 別表現で 2 回指定しても 1 件に集約される. + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_lookup'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['ids' => ['1', '1']]) + ); + + $data = $this->decodeJson($this->client->getResponse()); + $this->assertCount(1, $data['products'], 'Duplicate ids for the same product MUST be deduplicated'); + } + + public function testLookupUnknownIdReturnsEmptyProducts(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_lookup'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['ids' => ['99999999']]) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'lookup of unknown ids stays HTTP 200'); + $data = $this->decodeJson($response); + $this->assertSame([], $data['products'], 'Unresolvable ids must yield an empty products array (not an error)'); + } + + // --- product ----------------------------------------------------------- + + public function testProductReturnsSingleProduct(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_product'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['id' => '1']) + ); + + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'product 正常系は HTTP 200'); + + $data = $this->decodeJson($response); + $this->assertArrayHasKey('ucp', $data, 'product response MUST carry the "ucp" envelope'); + $this->assertArrayHasKey('product', $data, 'get_product response MUST carry a single "product" (not products[])'); + $this->assertArrayNotHasKey('products', $data, 'get_product response MUST NOT return a products[] array'); + $this->assertSame('1', (string) $data['product']['id'], 'product must resolve the requested id'); + } + + public function testProductMissingIdReturns404(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_product'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode([]) + ); + + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), 'get_product without a required id must return 404'); + } + + public function testProductUnknownIdReturns404(): void + { + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('agent_ucp_catalog_product'), + [], + [], + ['CONTENT_TYPE' => 'application/json'], + (string) json_encode(['id' => '99999999']) + ); + + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), 'get_product for an unknown id must return 404 (product is required; no empty 200)'); + } +} diff --git a/tests/Eccube/Tests/Web/AgentCommerce/UcpDiscoveryControllerTest.php b/tests/Eccube/Tests/Web/AgentCommerce/UcpDiscoveryControllerTest.php new file mode 100644 index 00000000000..14f566e79ed --- /dev/null +++ b/tests/Eccube/Tests/Web/AgentCommerce/UcpDiscoveryControllerTest.php @@ -0,0 +1,201 @@ += 60 (no-store/no-cache/private 禁止), HTTPS 想定・3xx 禁止. + * + * @see https://github.com/Universal-Commerce-Protocol/ucp/blob/main/schemas/profile.json + */ +final class UcpDiscoveryControllerTest extends AbstractWebTestCase +{ + /** + * UCP バージョン (date-based, YYYY-MM-DD). + */ + private const UCP_VERSION = '2026-04-08'; + + /** + * reverse-domain キーを表す正規表現 (profile.json の propertyNames pattern). + */ + private const REVERSE_DOMAIN_PATTERN = '/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9_]*)+$/'; + + /** + * JWK で公開鍵に出現してはならない秘密鍵パラメータ. + * + * @var string[] + */ + private const PRIVATE_JWK_PARAMS = ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth', 'k']; + + /** + * @return array + */ + private function requestProfile(): array + { + $this->client->request(Request::METHOD_GET, $this->generateUrl('agent_ucp_discovery')); + $response = $this->client->getResponse(); + $this->assertSame(Response::HTTP_OK, $response->getStatusCode(), 'discovery 正常系は HTTP 200'); + + $decoded = json_decode((string) $response->getContent(), true); + $this->assertIsArray($decoded, 'discovery profile MUST be a JSON object'); + + return $decoded; + } + + public function testRejectsPost(): void + { + $this->client->request(Request::METHOD_POST, $this->generateUrl('agent_ucp_discovery')); + + $this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode(), 'The discovery document is served via GET only; POST must not be allowed'); + } + + // --- ラッパー / version ------------------------------------------------ + + public function testProfileHasUcpWrapper(): void + { + $profile = $this->requestProfile(); + + $this->assertArrayHasKey('ucp', $profile, 'The profile MUST contain the required top-level "ucp" object'); + $this->assertIsArray($profile['ucp']); + } + + public function testUcpVersionIsDateBased(): void + { + $profile = $this->requestProfile(); + + $this->assertArrayHasKey('version', $profile['ucp'], 'ucp.version is REQUIRED'); + $this->assertSame(self::UCP_VERSION, $profile['ucp']['version'], 'ucp.version MUST be the protocol version v2026-04-08'); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $profile['ucp']['version'], 'ucp.version MUST be a "YYYY-MM-DD" date string'); + } + + public function testServicesAndPaymentHandlersAreObjects(): void + { + // JSON_FORCE_OBJECT に頼らず {} を出力できているかを raw 文字列で確認する. + $this->client->request(Request::METHOD_GET, $this->generateUrl('agent_ucp_discovery')); + $raw = (string) $this->client->getResponse()->getContent(); + + $profile = json_decode($raw, true); + $this->assertArrayHasKey('services', $profile['ucp'], 'ucp.services is REQUIRED'); + $this->assertArrayHasKey('payment_handlers', $profile['ucp'], 'ucp.payment_handlers is REQUIRED'); + + // 配列(空)であっても JSON 上は {} でなければならない (object 必須). + $this->assertStringNotContainsString('"payment_handlers":[]', $raw, 'ucp.payment_handlers MUST serialize as a JSON object {} (not an array []) even when empty'); + } + + // --- Catalog capability 宣言 ------------------------------------------- + + public function testCatalogCapabilityAndServiceAlwaysDeclared(): void + { + $profile = $this->requestProfile(); + + $this->assertArrayHasKey('capabilities', $profile['ucp']); + $this->assertArrayHasKey('dev.ucp.shopping.catalog.search', $profile['ucp']['capabilities'], 'When Catalog API is enabled the search capability MUST be declared'); + $this->assertArrayHasKey('dev.ucp.shopping.catalog.lookup', $profile['ucp']['capabilities'], 'When Catalog API is enabled the lookup capability MUST be declared'); + + $this->assertArrayHasKey('dev.ucp.shopping.catalog', $profile['ucp']['services'], 'When Catalog API is enabled the catalog REST service MUST be declared'); + $service = $profile['ucp']['services']['dev.ucp.shopping.catalog']; + $this->assertSame('rest', $service['transport'], 'A REST service MUST declare transport "rest"'); + $this->assertArrayHasKey('endpoint', $service, 'A non-embedded service MUST declare an endpoint'); + $this->assertMatchesRegularExpression('#^https?://#', (string) $service['endpoint'], 'The service endpoint MUST be an absolute URL (RequestContext-derived, not a hardcoded path)'); + } + + // --- reverse-domain キー ----------------------------------------------- + + public function testRegistryKeysAreReverseDomain(): void + { + $profile = $this->requestProfile(); + + foreach (['services', 'capabilities', 'payment_handlers'] as $registry) { + $entries = $profile['ucp'][$registry] ?? []; + if (!is_array($entries)) { + continue; + } + foreach (array_keys($entries) as $key) { + $this->assertMatchesRegularExpression(self::REVERSE_DOMAIN_PATTERN, (string) $key, sprintf('Key "%s" in ucp.%s MUST be a reverse-domain identifier', $key, $registry)); + } + } + } + + // --- signing_keys ------------------------------------------------------ + + public function testSigningKeysAreEcPublicJwks(): void + { + $profile = $this->requestProfile(); + + $this->assertArrayHasKey('signing_keys', $profile, 'A merchant with a signing key MUST advertise it in signing_keys[] (key auto-generated on enable)'); + $this->assertNotEmpty($profile['signing_keys']); + + foreach ($profile['signing_keys'] as $jwk) { + $this->assertSame('EC', $jwk['kty'] ?? null, 'UCP signing keys MUST be EC keys (kty:"EC")'); + $this->assertContains($jwk['crv'] ?? null, ['P-256', 'P-384'], 'UCP signing key curve MUST be P-256 or P-384'); + $this->assertArrayHasKey('kid', $jwk, 'A signing JWK MUST carry a kid'); + $this->assertArrayHasKey('x', $jwk, 'An EC public JWK MUST carry the x coordinate'); + $this->assertArrayHasKey('y', $jwk, 'An EC public JWK MUST carry the y coordinate'); + } + } + + public function testSigningKeysDoNotLeakPrivateParameters(): void + { + $profile = $this->requestProfile(); + + $this->assertArrayHasKey('signing_keys', $profile); + foreach ($profile['signing_keys'] as $jwk) { + foreach (self::PRIVATE_JWK_PARAMS as $param) { + $this->assertArrayNotHasKey($param, $jwk, sprintf('signing_keys[] is a public advertisement; the private JWK parameter "%s" MUST NOT appear', $param)); + } + } + } + + // --- 配信ヘッダ -------------------------------------------------------- + + public function testCacheControlIsPublicWithMinimumMaxAge(): void + { + $this->client->request(Request::METHOD_GET, $this->generateUrl('agent_ucp_discovery')); + $response = $this->client->getResponse(); + + $cacheControl = (string) $response->headers->get('Cache-Control'); + + $this->assertStringContainsString('public', $cacheControl, 'The discovery document MUST be served with Cache-Control: public'); + + $this->assertMatchesRegularExpression('/max-age=(\d+)/', $cacheControl, 'The discovery document MUST declare a max-age'); + preg_match('/max-age=(\d+)/', $cacheControl, $m); + $this->assertGreaterThanOrEqual(60, (int) $m[1], 'Cache-Control max-age MUST be >= 60 seconds'); + + foreach (['no-store', 'no-cache', 'private'] as $forbidden) { + $this->assertStringNotContainsString($forbidden, $cacheControl, sprintf('The discovery document MUST NOT use Cache-Control "%s"', $forbidden)); + } + } + + public function testServedAsJson(): void + { + $this->client->request(Request::METHOD_GET, $this->generateUrl('agent_ucp_discovery')); + $response = $this->client->getResponse(); + + $this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'), 'The discovery document MUST be served as application/json'); + $this->assertFalse($response->isRedirection(), 'The discovery document MUST be served directly (no 3xx redirect)'); + } +}