Files

1 line
85 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "charsen/moo-scaffold", "files": {"tests/Feature/Designer/DesignerControllerTest.php": {"2ae00c387f87c79d9ea619bf81885aabbbf9ca02": {"t": 1785746155.0397985, "n": 9944, "h": "008684109e327860", "c": "<?php declare(strict_types=1);\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Illuminate\\Support\\Facades\\Http;\nuse Mooeen\\Scaffold\\Designer\\TranslationService;\nuse Mooeen\\Scaffold\\Http\\Middleware\\EnforceScaffoldWritable;\nuse Mooeen\\Scaffold\\Http\\Middleware\\ScaffoldAuthenticate;\nuse Mooeen\\Scaffold\\Tests\\Feature\\Designer\\Support\\FixtureSchema;\n\n/**\n * DesignerController HTTP feature test。绕 ScaffoldAuthenticate / VerifyCsrfToken /\n * EnforceScaffoldWritable 中间件(测试只关心 controller 自身行为),只测 readonly +\n * 校验路径,不真写 yaml(避免污染 production fixture)。\n *\n * 真写路径(save / createSchema / createTable / deleteTable / migrate)已经在\n * Playwright e2e 测过端到端 round-trip。\n */\nbeforeEach(function () {\n $this->withoutMiddleware([\n ScaffoldAuthenticate::class,\n VerifyCsrfToken::class,\n EnforceScaffoldWritable::class,\n ]);\n});\n\n// ─── GET endpoints(readonly) ──────────────────────────────────\n\nit('GET /scaffold/db/designer renders index view with 200', function () {\n $r = $this->get('/scaffold/db/designer');\n $r->assertOk();\n});\n\nit('GET /scaffold/db/designer/{schema} renders schema page with 200', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA);\n $r->assertOk();\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('字段表带 简洁/完整列 切换按钮(精度/format 默认按本表用量显隐,2026-06-11)', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '?table=' . FixtureSchema::TABLE);\n $r->assertOk();\n $r->assertSee('toggleAdvancedCols'); // 切换按钮(行内可编辑之后)\n $r->assertSee('x-show=\"showAdvancedCols\"', false); // 精度/format 列挂显隐绑定\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET /scaffold/db/designer/{schema}/preview returns JSON with full shape', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/preview');\n $r->assertOk();\n $r->assertJsonStructure([\n 'data' => [\n 'schema',\n 'is_empty',\n 'summary' => ['tables_changed', 'tables_created', 'tables_dropped'],\n 'tables',\n ],\n ]);\n expect($r->json('data.schema'))->toBe(FixtureSchema::SCHEMA);\n expect($r->json('data.is_empty'))->toBeBool();\n // plan 36 砍 parser_warnings 字段\n expect($r->json('data'))->not->toHaveKey('parser_warnings');\n // plan 39 砍 commit_message 字段\n expect($r->json('data'))->not->toHaveKey('commit_message');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET preview reports is_empty=true on schema with fresh snapshot (plan 36 / P1-1 fixture)', function () {\n // plan-37 P1-1:用 fixture schema 隔绝 production yaml,baseline ↔ source 字节对齐\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/preview');\n $r->assertOk();\n expect($r->json('data.is_empty'))->toBeTrue();\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content returns 400 on missing filename', function () {\n $r = $this->get('/scaffold/db/designer/Platform/migration-content');\n $r->assertStatus(400);\n expect($r->json('error.code'))->toBe('INVALID_FILENAME');\n});\n\nit('GET migration-content rejects path traversal', function () {\n $r = $this->get('/scaffold/db/designer/Platform/migration-content?file=../../etc/passwd');\n $r->assertStatus(400);\n});\n\nit('GET migration-content returns 404 on non-existent file', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/migration-content?file=does_not_exist_xyz.php');\n $r->assertStatus(404);\n expect($r->json('error.code'))->toBe('NOT_FOUND');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content returns php_code for real migration file', function () {\n // 需要该 schema 真实 migration 文件;bundled fixture 不带 migrations,testbench 也无 →\n // 无文件时 graceful skip(php_code happy-path 由 designer.spec.ts e2e 覆盖),不让套件失败。\n $dir = database_path('migrations');\n $first = is_dir($dir)\n ? collect(scandir($dir))->first(fn ($f) => str_ends_with($f, '.php') && str_contains($f, '_demo_') && ! is_dir(\"{$dir}/{$f}\"))\n : null;\n if ($first === null) {\n $this->markTestSkipped('no Demo migration file in test env — php_code happy-path covered by e2e');\n }\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . \"/migration-content?file={$first}\");\n $r->assertOk();\n expect($r->json('data.filename'))->toBe($first);\n expect($r->json('data.php_code'))->toBeString()->toContain('<?php');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content rejects file not belonging to schema (plan-37 P1)', function () {\n // 拿 Order 的 migration 试图通过 Platform endpoint 读 → 应 404\n $dir = database_path('migrations');\n $orderFile = collect(scandir($dir))\n ->first(fn ($f) => str_ends_with($f, '.php') && str_contains($f, '_order_') && ! is_dir(\"{$dir}/{$f}\"));\n if ($orderFile === null) {\n $this->markTestSkipped('no order_* migration to test cross-schema rejection');\n }\n $r = $this->get(\"/scaffold/db/designer/Platform/migration-content?file={$orderFile}\");\n $r->assertStatus(404);\n expect($r->json('error.code'))->toBe('NOT_FOUND');\n});\n\n// ─── POST translate(用 Http::fake 拦 DeepSeek)─────────────\n\nit('POST /scaffold/db/designer/translate proxies DeepSeek + returns valid results', function () {\n // TranslationService 从 AiSettingStore 读配置,test 时直接 instance 替换成有 key 的 service\n // (DeepSeek HTTP 仍走 Http::fake 拦截)。显式传非默认 temperature / maxTokens 验证参数透传到请求体。\n app()->instance(TranslationService::class, new TranslationService(\n baseUrl: 'https://api.deepseek.com',\n apiKey: 'sk-test',\n model: 'deepseek-chat',\n timeout: 30,\n temperature: 0.2,\n maxTokens: 4096,\n ));\n Http::fake(['*/chat/completions' => Http::response([\n 'choices' => [['message' => ['content' => json_encode([\n 'results' => [['input' => '头像', 'output' => 'user_avatar', 'type' => 'varchar', 'size' => 64]],\n ])]]],\n ], 200)]);\n\n $r = $this->postJson('/scaffold/db/designer/translate', [\n 'scene' => 'fields',\n 'table' => 'demo_users',\n 'prefix' => 'user',\n 'existing_fields' => [],\n 'inputs' => ['头像'],\n ]);\n $r->assertOk();\n expect($r->json('data.results.0.valid'))->toBeTrue();\n expect($r->json('data.results.0.output'))->toBe('user_avatar');\n // 传输 / 生成参数(借鉴 moo-scaffold-cloud)真进了 DeepSeek 请求体\n Http::assertSent(fn ($req) => $req['temperature'] === 0.2 && $req['max_tokens'] === 4096);\n});\n\nit('POST translate returns 503 AI_NOT_CONFIGURED when apiKey empty', function () {\n // AI 配置改从 AiSettingStore 读(不再走 config('scaffold.ai.*'))。显式注入空 key 的\n // TranslationService → assertConfigured 抛 AiNotConfiguredException → 503。\n app()->instance(TranslationService::class, new TranslationService(\n baseUrl: 'https://api.deepseek.com/v1',\n apiKey: '',\n model: 'deepseek-chat',\n timeout: 10,\n ));\n $r = $this->postJson('/scaffold/db/designer/translate', [\n 'scene' => 'fields',\n 'table' => 'demo_users',\n 'prefix' => 'user',\n 'inputs' => ['头像'],\n ]);\n $r->assertStatus(503);\n expect($r->json('error.code'))->toBe('AI_NOT_CONFIGURED');\n});\n\nit('POST translate returns 422 on missing scene', function () {\n $r = $this->postJson('/scaffold/db/designer/translate', []);\n $r->assertStatus(422);\n});\n\n// ─── POST createSchema / createTable validation ─────────────\n\nit('POST /scaffold/db/designer/schemas returns 422 on missing required fields', function () {\n $r = $this->postJson('/scaffold/db/designer/schemas', []);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['schema', 'name']);\n});\n\nit('POST createSchema returns 422 on schema name not PascalCase', function () {\n // plan-40 §五 F4:controller validate regex 已早于 SchemaLoader throw,走 Laravel 默认 422 validation shape\n $r = $this->postJson('/scaffold/db/designer/schemas', [\n 'schema' => 'lower_case_invalid',\n 'name' => 'x',\n ]);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['schema']);\n});\n\n// plan-40 §五 F4 regression\nit('POST createTable returns 422 on table_key not snake_case', function () {\n $r = $this->postJson('/scaffold/db/designer/Platform/tables', [\n 'table_key' => 'BadCamelCase',\n 'name' => 'x',\n ]);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['table_key']);\n});\n\nit('POST createTable returns 422 on missing required fields', function () {\n $r = $this->postJson('/scaffold/db/designer/Platform/tables', []);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['table_key', 'name']);\n});\n\n// ─── DELETE deleteTable validation ─────────────────────────\n\nit('DELETE /scaffold/db/designer/{schema}/tables/{table} returns 422 on confirm_key mismatch', function () {\n $r = $this->deleteJson('/scaffold/db/designer/Platform/tables/platform_regions', [\n 'confirm_key' => 'wrong_key',\n ]);\n $r->assertStatus(422);\n expect($r->json('error.code'))->toBe('CONFIRM_MISMATCH');\n});\n\nit('DELETE deleteTable returns 422 on missing confirm_key', function () {\n $r = $this->deleteJson('/scaffold/db/designer/Platform/tables/platform_regions', []);\n $r->assertStatus(422);\n});\n"}, "9f4a1e53d1c9be30ea0eae35287f1c42837682a6": {"t": 1785760253.0850496, "n": 9944, "h": "008684109e327860", "c": "<?php declare(strict_types=1);\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Illuminate\\Support\\Facades\\Http;\nuse Mooeen\\Scaffold\\Designer\\TranslationService;\nuse Mooeen\\Scaffold\\Http\\Middleware\\EnforceScaffoldWritable;\nuse Mooeen\\Scaffold\\Http\\Middleware\\ScaffoldAuthenticate;\nuse Mooeen\\Scaffold\\Tests\\Feature\\Designer\\Support\\FixtureSchema;\n\n/**\n * DesignerController HTTP feature test。绕 ScaffoldAuthenticate / VerifyCsrfToken /\n * EnforceScaffoldWritable 中间件(测试只关心 controller 自身行为),只测 readonly +\n * 校验路径,不真写 yaml(避免污染 production fixture)。\n *\n * 真写路径(save / createSchema / createTable / deleteTable / migrate)已经在\n * Playwright e2e 测过端到端 round-trip。\n */\nbeforeEach(function () {\n $this->withoutMiddleware([\n ScaffoldAuthenticate::class,\n VerifyCsrfToken::class,\n EnforceScaffoldWritable::class,\n ]);\n});\n\n// ─── GET endpoints(readonly) ──────────────────────────────────\n\nit('GET /scaffold/db/designer renders index view with 200', function () {\n $r = $this->get('/scaffold/db/designer');\n $r->assertOk();\n});\n\nit('GET /scaffold/db/designer/{schema} renders schema page with 200', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA);\n $r->assertOk();\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('字段表带 简洁/完整列 切换按钮(精度/format 默认按本表用量显隐,2026-06-11)', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '?table=' . FixtureSchema::TABLE);\n $r->assertOk();\n $r->assertSee('toggleAdvancedCols'); // 切换按钮(行内可编辑之后)\n $r->assertSee('x-show=\"showAdvancedCols\"', false); // 精度/format 列挂显隐绑定\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET /scaffold/db/designer/{schema}/preview returns JSON with full shape', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/preview');\n $r->assertOk();\n $r->assertJsonStructure([\n 'data' => [\n 'schema',\n 'is_empty',\n 'summary' => ['tables_changed', 'tables_created', 'tables_dropped'],\n 'tables',\n ],\n ]);\n expect($r->json('data.schema'))->toBe(FixtureSchema::SCHEMA);\n expect($r->json('data.is_empty'))->toBeBool();\n // plan 36 砍 parser_warnings 字段\n expect($r->json('data'))->not->toHaveKey('parser_warnings');\n // plan 39 砍 commit_message 字段\n expect($r->json('data'))->not->toHaveKey('commit_message');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET preview reports is_empty=true on schema with fresh snapshot (plan 36 / P1-1 fixture)', function () {\n // plan-37 P1-1:用 fixture schema 隔绝 production yaml,baseline ↔ source 字节对齐\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/preview');\n $r->assertOk();\n expect($r->json('data.is_empty'))->toBeTrue();\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content returns 400 on missing filename', function () {\n $r = $this->get('/scaffold/db/designer/Platform/migration-content');\n $r->assertStatus(400);\n expect($r->json('error.code'))->toBe('INVALID_FILENAME');\n});\n\nit('GET migration-content rejects path traversal', function () {\n $r = $this->get('/scaffold/db/designer/Platform/migration-content?file=../../etc/passwd');\n $r->assertStatus(400);\n});\n\nit('GET migration-content returns 404 on non-existent file', function () {\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . '/migration-content?file=does_not_exist_xyz.php');\n $r->assertStatus(404);\n expect($r->json('error.code'))->toBe('NOT_FOUND');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content returns php_code for real migration file', function () {\n // 需要该 schema 真实 migration 文件;bundled fixture 不带 migrations,testbench 也无 →\n // 无文件时 graceful skip(php_code happy-path 由 designer.spec.ts e2e 覆盖),不让套件失败。\n $dir = database_path('migrations');\n $first = is_dir($dir)\n ? collect(scandir($dir))->first(fn ($f) => str_ends_with($f, '.php') && str_contains($f, '_demo_') && ! is_dir(\"{$dir}/{$f}\"))\n : null;\n if ($first === null) {\n $this->markTestSkipped('no Demo migration file in test env — php_code happy-path covered by e2e');\n }\n $orig = FixtureSchema::activate(app());\n try {\n $r = $this->get('/scaffold/db/designer/' . FixtureSchema::SCHEMA . \"/migration-content?file={$first}\");\n $r->assertOk();\n expect($r->json('data.filename'))->toBe($first);\n expect($r->json('data.php_code'))->toBeString()->toContain('<?php');\n } finally {\n FixtureSchema::deactivate(app(), $orig);\n }\n});\n\nit('GET migration-content rejects file not belonging to schema (plan-37 P1)', function () {\n // 拿 Order 的 migration 试图通过 Platform endpoint 读 → 应 404\n $dir = database_path('migrations');\n $orderFile = collect(scandir($dir))\n ->first(fn ($f) => str_ends_with($f, '.php') && str_contains($f, '_order_') && ! is_dir(\"{$dir}/{$f}\"));\n if ($orderFile === null) {\n $this->markTestSkipped('no order_* migration to test cross-schema rejection');\n }\n $r = $this->get(\"/scaffold/db/designer/Platform/migration-content?file={$orderFile}\");\n $r->assertStatus(404);\n expect($r->json('error.code'))->toBe('NOT_FOUND');\n});\n\n// ─── POST translate(用 Http::fake 拦 DeepSeek)─────────────\n\nit('POST /scaffold/db/designer/translate proxies DeepSeek + returns valid results', function () {\n // TranslationService 从 AiSettingStore 读配置,test 时直接 instance 替换成有 key 的 service\n // (DeepSeek HTTP 仍走 Http::fake 拦截)。显式传非默认 temperature / maxTokens 验证参数透传到请求体。\n app()->instance(TranslationService::class, new TranslationService(\n baseUrl: 'https://api.deepseek.com',\n apiKey: 'sk-test',\n model: 'deepseek-chat',\n timeout: 30,\n temperature: 0.2,\n maxTokens: 4096,\n ));\n Http::fake(['*/chat/completions' => Http::response([\n 'choices' => [['message' => ['content' => json_encode([\n 'results' => [['input' => '头像', 'output' => 'user_avatar', 'type' => 'varchar', 'size' => 64]],\n ])]]],\n ], 200)]);\n\n $r = $this->postJson('/scaffold/db/designer/translate', [\n 'scene' => 'fields',\n 'table' => 'demo_users',\n 'prefix' => 'user',\n 'existing_fields' => [],\n 'inputs' => ['头像'],\n ]);\n $r->assertOk();\n expect($r->json('data.results.0.valid'))->toBeTrue();\n expect($r->json('data.results.0.output'))->toBe('user_avatar');\n // 传输 / 生成参数(借鉴 moo-scaffold-cloud)真进了 DeepSeek 请求体\n Http::assertSent(fn ($req) => $req['temperature'] === 0.2 && $req['max_tokens'] === 4096);\n});\n\nit('POST translate returns 503 AI_NOT_CONFIGURED when apiKey empty', function () {\n // AI 配置改从 AiSettingStore 读(不再走 config('scaffold.ai.*'))。显式注入空 key 的\n // TranslationService → assertConfigured 抛 AiNotConfiguredException → 503。\n app()->instance(TranslationService::class, new TranslationService(\n baseUrl: 'https://api.deepseek.com/v1',\n apiKey: '',\n model: 'deepseek-chat',\n timeout: 10,\n ));\n $r = $this->postJson('/scaffold/db/designer/translate', [\n 'scene' => 'fields',\n 'table' => 'demo_users',\n 'prefix' => 'user',\n 'inputs' => ['头像'],\n ]);\n $r->assertStatus(503);\n expect($r->json('error.code'))->toBe('AI_NOT_CONFIGURED');\n});\n\nit('POST translate returns 422 on missing scene', function () {\n $r = $this->postJson('/scaffold/db/designer/translate', []);\n $r->assertStatus(422);\n});\n\n// ─── POST createSchema / createTable validation ─────────────\n\nit('POST /scaffold/db/designer/schemas returns 422 on missing required fields', function () {\n $r = $this->postJson('/scaffold/db/designer/schemas', []);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['schema', 'name']);\n});\n\nit('POST createSchema returns 422 on schema name not PascalCase', function () {\n // plan-40 §五 F4:controller validate regex 已早于 SchemaLoader throw,走 Laravel 默认 422 validation shape\n $r = $this->postJson('/scaffold/db/designer/schemas', [\n 'schema' => 'lower_case_invalid',\n 'name' => 'x',\n ]);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['schema']);\n});\n\n// plan-40 §五 F4 regression\nit('POST createTable returns 422 on table_key not snake_case', function () {\n $r = $this->postJson('/scaffold/db/designer/Platform/tables', [\n 'table_key' => 'BadCamelCase',\n 'name' => 'x',\n ]);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['table_key']);\n});\n\nit('POST createTable returns 422 on missing required fields', function () {\n $r = $this->postJson('/scaffold/db/designer/Platform/tables', []);\n $r->assertStatus(422);\n $r->assertJsonValidationErrors(['table_key', 'name']);\n});\n\n// ─── DELETE deleteTable validation ─────────────────────────\n\nit('DELETE /scaffold/db/designer/{schema}/tables/{table} returns 422 on confirm_key mismatch', function () {\n $r = $this->deleteJson('/scaffold/db/designer/Platform/tables/platform_regions', [\n 'confirm_key' => 'wrong_key',\n ]);\n $r->assertStatus(422);\n expect($r->json('error.code'))->toBe('CONFIRM_MISMATCH');\n});\n\nit('DELETE deleteTable returns 422 on missing confirm_key', function () {\n $r = $this->deleteJson('/scaffold/db/designer/Platform/tables/platform_regions', []);\n $r->assertStatus(422);\n});\n"}}, "tests/Feature/Support/AiSettingStoreTest.php": {"2ae00c387f87c79d9ea619bf81885aabbbf9ca02": {"t": 1785746158.262269, "n": 3792, "h": "1de3cd29eabf1771", "c": "<?php declare(strict_types=1);\n\nuse Mooeen\\Scaffold\\Support\\AiSettingStore;\nuse Mooeen\\Scaffold\\Support\\ConfigWriteForbiddenException;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * AiSettingStore 单测:默认值合并 + api_key 留空保持 + .gitignore 自我保护 + readonly 硬拒。\n *\n * AI 配置从 env 迁到 scaffold/ai.yaml(2026-06,入 git、随仓同步)。api_key 明文存储,\n * 被掩码回显冲掉 / 类型转换错都是事故 —— 这几条锁住语义。\n *\n * sandbox:setBasePath 到 temp dir,ai.yaml 落 sandbox/,跑完整目录删。\n */\nbeforeEach(function () {\n $this->sandbox = sys_get_temp_dir() . '/scaffold_ai_' . uniqid();\n @mkdir($this->sandbox, 0755, true);\n $this->origBase = base_path();\n app()->setBasePath($this->sandbox);\n config(['scaffold.ai.yaml_path' => 'ai.yaml']); // → sandbox/ai.yaml(入 git 位置)\n $this->store = app(AiSettingStore::class);\n});\n\nafterEach(function () {\n app()->setBasePath($this->origBase);\n @unlink($this->sandbox . '/ai.yaml');\n @rmdir($this->sandbox);\n});\n\nit('load() 无文件时返回默认值(DeepSeek)', function () {\n $ai = $this->store->load();\n expect($ai['base_url'])->toBe('https://api.deepseek.com/v1');\n expect($ai['api_key'])->toBe('');\n expect($ai['model'])->toBe('deepseek-chat');\n expect($ai['timeout'])->toBe(10);\n // 传输 / 生成参数(借鉴 moo-scaffold-cloud)\n expect($ai['connect_timeout'])->toBe(8);\n expect($ai['max_tokens'])->toBe(8192);\n expect($ai['temperature'])->toBe(0.2);\n});\n\nit('save() 持久化传输 / 生成参数并强转类型', function () {\n $this->store->save(['temperature' => '0.5', 'max_tokens' => '4096', 'connect_timeout' => '5']);\n $ai = $this->store->load();\n expect($ai['temperature'])->toBe(0.5); // float\n expect($ai['max_tokens'])->toBe(4096); // int\n expect($ai['connect_timeout'])->toBe(5); // int\n});\n\nit('temperature 钳到 [0, 2]', function () {\n $this->store->save(['temperature' => '9']);\n expect($this->store->load()['temperature'])->toBe(2.0);\n $this->store->save(['temperature' => '-3']);\n expect($this->store->load()['temperature'])->toBe(0.0);\n});\n\nit('save() 写 yaml 并能被 load() 读回', function () {\n $this->store->save(['base_url' => 'https://x.test/v1', 'api_key' => 'sk-abc', 'model' => 'gpt-x', 'timeout' => 30]);\n $ai = $this->store->load();\n expect($ai['api_key'])->toBe('sk-abc');\n expect($ai['base_url'])->toBe('https://x.test/v1');\n expect($ai['model'])->toBe('gpt-x');\n expect($ai['timeout'])->toBe(30);\n});\n\nit('api_key 留空保持原值(不被掩码回显冲掉)', function () {\n $this->store->save(['api_key' => 'sk-keep']);\n // 第二次保存只改 model,api_key 传空 → 应保持 sk-keep\n $this->store->save(['model' => 'deepseek-coder', 'api_key' => '']);\n $ai = $this->store->load();\n expect($ai['api_key'])->toBe('sk-keep');\n expect($ai['model'])->toBe('deepseek-coder');\n});\n\nit('read() 不回显明文 key,只给 api_key_set 布尔', function () {\n $this->store->save(['api_key' => 'sk-hidden']);\n $view = $this->store->read();\n expect($view)->not->toHaveKey('api_key');\n expect($view['api_key_set'])->toBeTrue();\n expect($view['defaults']['base_url'])->toBe('https://api.deepseek.com/v1');\n});\n\nit('base_url / model 存空时兜底回默认', function () {\n $this->store->save(['base_url' => '', 'model' => '']);\n $ai = $this->store->load();\n expect($ai['base_url'])->toBe('https://api.deepseek.com/v1');\n expect($ai['model'])->toBe('deepseek-chat');\n});\n\nit('readonly 模式下 save() 硬拒(ConfigWriteForbiddenException)', function () {\n config(['scaffold.config_ui.readonly' => true]);\n expect(fn () => $this->store->save(['api_key' => 'sk-x']))\n ->toThrow(ConfigWriteForbiddenException::class);\n});\n\nit('yaml 文件嵌套在 ai: 键下', function () {\n $this->store->save(['api_key' => 'sk-nested']);\n $parsed = Yaml::parse(file_get_contents($this->sandbox . '/ai.yaml'));\n expect($parsed)->toHaveKey('ai');\n expect($parsed['ai']['api_key'])->toBe('sk-nested');\n});\n"}, "9f4a1e53d1c9be30ea0eae35287f1c42837682a6": {"t": 1785760255.470119, "n": 3792, "h": "1de3cd29eabf1771", "c": "<?php declare(strict_types=1);\n\nuse Mooeen\\Scaffold\\Support\\AiSettingStore;\nuse Mooeen\\Scaffold\\Support\\ConfigWriteForbiddenException;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * AiSettingStore 单测:默认值合并 + api_key 留空保持 + .gitignore 自我保护 + readonly 硬拒。\n *\n * AI 配置从 env 迁到 scaffold/ai.yaml(2026-06,入 git、随仓同步)。api_key 明文存储,\n * 被掩码回显冲掉 / 类型转换错都是事故 —— 这几条锁住语义。\n *\n * sandbox:setBasePath 到 temp dir,ai.yaml 落 sandbox/,跑完整目录删。\n */\nbeforeEach(function () {\n $this->sandbox = sys_get_temp_dir() . '/scaffold_ai_' . uniqid();\n @mkdir($this->sandbox, 0755, true);\n $this->origBase = base_path();\n app()->setBasePath($this->sandbox);\n config(['scaffold.ai.yaml_path' => 'ai.yaml']); // → sandbox/ai.yaml(入 git 位置)\n $this->store = app(AiSettingStore::class);\n});\n\nafterEach(function () {\n app()->setBasePath($this->origBase);\n @unlink($this->sandbox . '/ai.yaml');\n @rmdir($this->sandbox);\n});\n\nit('load() 无文件时返回默认值(DeepSeek)', function () {\n $ai = $this->store->load();\n expect($ai['base_url'])->toBe('https://api.deepseek.com/v1');\n expect($ai['api_key'])->toBe('');\n expect($ai['model'])->toBe('deepseek-chat');\n expect($ai['timeout'])->toBe(10);\n // 传输 / 生成参数(借鉴 moo-scaffold-cloud)\n expect($ai['connect_timeout'])->toBe(8);\n expect($ai['max_tokens'])->toBe(8192);\n expect($ai['temperature'])->toBe(0.2);\n});\n\nit('save() 持久化传输 / 生成参数并强转类型', function () {\n $this->store->save(['temperature' => '0.5', 'max_tokens' => '4096', 'connect_timeout' => '5']);\n $ai = $this->store->load();\n expect($ai['temperature'])->toBe(0.5); // float\n expect($ai['max_tokens'])->toBe(4096); // int\n expect($ai['connect_timeout'])->toBe(5); // int\n});\n\nit('temperature 钳到 [0, 2]', function () {\n $this->store->save(['temperature' => '9']);\n expect($this->store->load()['temperature'])->toBe(2.0);\n $this->store->save(['temperature' => '-3']);\n expect($this->store->load()['temperature'])->toBe(0.0);\n});\n\nit('save() 写 yaml 并能被 load() 读回', function () {\n $this->store->save(['base_url' => 'https://x.test/v1', 'api_key' => 'sk-abc', 'model' => 'gpt-x', 'timeout' => 30]);\n $ai = $this->store->load();\n expect($ai['api_key'])->toBe('sk-abc');\n expect($ai['base_url'])->toBe('https://x.test/v1');\n expect($ai['model'])->toBe('gpt-x');\n expect($ai['timeout'])->toBe(30);\n});\n\nit('api_key 留空保持原值(不被掩码回显冲掉)', function () {\n $this->store->save(['api_key' => 'sk-keep']);\n // 第二次保存只改 model,api_key 传空 → 应保持 sk-keep\n $this->store->save(['model' => 'deepseek-coder', 'api_key' => '']);\n $ai = $this->store->load();\n expect($ai['api_key'])->toBe('sk-keep');\n expect($ai['model'])->toBe('deepseek-coder');\n});\n\nit('read() 不回显明文 key,只给 api_key_set 布尔', function () {\n $this->store->save(['api_key' => 'sk-hidden']);\n $view = $this->store->read();\n expect($view)->not->toHaveKey('api_key');\n expect($view['api_key_set'])->toBeTrue();\n expect($view['defaults']['base_url'])->toBe('https://api.deepseek.com/v1');\n});\n\nit('base_url / model 存空时兜底回默认', function () {\n $this->store->save(['base_url' => '', 'model' => '']);\n $ai = $this->store->load();\n expect($ai['base_url'])->toBe('https://api.deepseek.com/v1');\n expect($ai['model'])->toBe('deepseek-chat');\n});\n\nit('readonly 模式下 save() 硬拒(ConfigWriteForbiddenException)', function () {\n config(['scaffold.config_ui.readonly' => true]);\n expect(fn () => $this->store->save(['api_key' => 'sk-x']))\n ->toThrow(ConfigWriteForbiddenException::class);\n});\n\nit('yaml 文件嵌套在 ai: 键下', function () {\n $this->store->save(['api_key' => 'sk-nested']);\n $parsed = Yaml::parse(file_get_contents($this->sandbox . '/ai.yaml'));\n expect($parsed)->toHaveKey('ai');\n expect($parsed['ai']['api_key'])->toBe('sk-nested');\n});\n"}}, "src/Support/AiSettingStore.php": {"2ae00c387f87c79d9ea619bf81885aabbbf9ca02": {"t": 1785746170.0532382, "n": 7340, "h": "e31811924b86928c", "c": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Mooeen\\Scaffold\\Support;\n\nuse Illuminate\\Contracts\\Config\\Repository;\nuse Illuminate\\Filesystem\\Filesystem;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * Designer AI 翻译配置存储(plan 19 → 2026-06 GUI 化)。\n *\n * 原本走 SCAFFOLD_AI_* env,改为在 /scaffold/config/ai 可视化编辑,存\n * scaffold/ai.yaml(跟 accounts.yaml 一样**入 git**,随仓同步到各机器/部署):\n * - 运行时读 yaml(不走 env)→ config:cache 安全;改完即时生效,无需重启 worker。\n * - ⚠ api_key 以**明文**存储(用户 2026-06-10 明确选定 plaintext-in-git,已知会进 git\n * history → 请确保本仓私有)。要换 secret-safe 存法见 git log 当时的方案对比。\n *\n * 写入策略:每次整体重写(不增量 patch),历史回溯走 git。\n * 删文件即恢复默认(api_key 清空 = AI 翻译关闭)。production / 强制只读时硬拒。\n */\nclass AiSettingStore\n{\n /**\n * 默认上游(DeepSeek)。api_key 默认空 = 未配置(翻译关闭)。\n *\n * 传输 / 生成参数(temperature / max_tokens / connect_timeout)借鉴 moo-scaffold-cloud\n * 的 AiConfig::DEFAULTS:\n * - temperature 0.2 —— 中文→snake_case 是确定性任务,低温让同字段多次翻译稳定不飘\n * (不传时 DeepSeek 默认 1.0 偏高;DeepSeek 官方建议 coding 类 ~0.0)。\n * - max_tokens 8192 —— 单次生成上限;批量字段翻译够用,留足。\n * - connect_timeout 8 —— 连接超时,区别于总 timeout。\n * 类型即来源:这里写 int/float,load() 据此强转。\n */\n private const DEFAULTS = [\n 'base_url' => 'https://api.deepseek.com/v1',\n 'api_key' => '',\n 'model' => 'deepseek-chat',\n 'timeout' => 10,\n 'connect_timeout' => 8,\n 'max_tokens' => 8192,\n 'temperature' => 0.2,\n ];\n\n /** 最近一次 load() 是否遇到坏 yaml(解析失败回退默认时置真,供页面黄条提示)。 */\n private bool $yamlBroken = false;\n\n public function __construct(\n private readonly Repository $config,\n private readonly Filesystem $fs,\n ) {}\n\n public function path(): string\n {\n $rel = (string) $this->config->get('scaffold.ai.yaml_path', 'scaffold/ai.yaml');\n\n return $this->basePath($rel);\n }\n\n public function exists(): bool\n {\n return $this->fs->exists($this->path());\n }\n\n /**\n * 合并默认值 + yaml 文件,返回归一后的 base_url / api_key / model / timeout。\n * TranslationService binding 直接吃这个。\n *\n * @return array{base_url:string, api_key:string, model:string, timeout:int}\n */\n public function load(): array\n {\n $data = self::DEFAULTS;\n $this->yamlBroken = false;\n\n if ($this->exists()) {\n // ai.yaml 入 git 随仓多机同步,冲突标记(<<<<<<<)/手改坏形是现实事件。\n // 裸 parse 抛异常会 500 掉 AI 配置页(修复入口自身)+ designer 翻译 → 死锁,\n // 只能上磁盘修文件。解析失败回退默认 + 页面黄条提示(2026-06-10 修);\n // 此时从 UI 保存一次即用合法内容重写、自动修复文件。\n try {\n $parsed = Yaml::parse($this->fs->get($this->path())) ?: [];\n } catch (\\Throwable) {\n $parsed = [];\n $this->yamlBroken = true;\n }\n // 容错:既支持嵌套 `ai:` 也支持平铺\n $ai = (array) ($parsed['ai'] ?? $parsed);\n foreach (array_keys(self::DEFAULTS) as $k) {\n if (array_key_exists($k, $ai) && $ai[$k] !== null && $ai[$k] !== '') {\n $data[$k] = $ai[$k];\n }\n }\n }\n\n return [\n 'base_url' => (string) $data['base_url'],\n 'api_key' => (string) $data['api_key'],\n 'model' => (string) $data['model'],\n 'timeout' => max(1, (int) $data['timeout']),\n 'connect_timeout' => max(1, (int) $data['connect_timeout']),\n 'max_tokens' => max(1, (int) $data['max_tokens']),\n 'temperature' => min(2.0, max(0.0, (float) $data['temperature'])),\n ];\n }\n\n /**\n * UI 展示用:load() 基础上加 api_key_set(是否已配置)+ defaults(占位提示用)。\n * api_key 本身**不回显明文**(密钥)。\n */\n public function read(): array\n {\n $data = $this->load();\n $data['api_key_set'] = $data['api_key'] !== '';\n unset($data['api_key']);\n $data['defaults'] = self::DEFAULTS;\n $data['yaml_broken'] = $this->yamlBroken;\n\n return $data;\n }\n\n /**\n * 保存。base_url / model / timeout 直接覆盖;api_key 空 = 保持原值\n * (跟 AccountStore 密码\"留空不改\"同语义 —— 避免 UI 掩码回显把真 key 冲掉)。\n * 要清空 key 请删 scaffold/ai.yaml。\n *\n * @return array{base_url:string, api_key:string, model:string, timeout:int}\n */\n public function save(array $payload): array\n {\n $this->assertWritable();\n\n $next = $this->load();\n\n foreach (['base_url', 'model'] as $k) {\n if (array_key_exists($k, $payload)) {\n $next[$k] = trim((string) $payload[$k]);\n }\n }\n // 整数传输 / 生成参数:空 / null 跳过(保持原值),否则强转 + 下限保护\n foreach (['timeout', 'connect_timeout', 'max_tokens'] as $k) {\n if (array_key_exists($k, $payload) && $payload[$k] !== null && $payload[$k] !== '') {\n $next[$k] = max(1, (int) $payload[$k]);\n }\n }\n // temperature:float,钳到 [0, 2]\n if (array_key_exists('temperature', $payload) && $payload['temperature'] !== null && $payload['temperature'] !== '') {\n $next['temperature'] = min(2.0, max(0.0, (float) $payload['temperature']));\n }\n // api_key:空保持原值(不清空);非空更新\n if (array_key_exists('api_key', $payload)) {\n $newKey = trim((string) $payload['api_key']);\n if ($newKey !== '') {\n $next['api_key'] = $newKey;\n }\n }\n // 空兜底回默认(否则 base_url='' 让翻译指向相对路径、model='' 让上游 400)\n if ($next['base_url'] === '') {\n $next['base_url'] = self::DEFAULTS['base_url'];\n }\n if ($next['model'] === '') {\n $next['model'] = self::DEFAULTS['model'];\n }\n\n $this->writeYaml($next);\n\n return $next;\n }\n\n /**\n * production / 强制只读时硬拒(跟 ConfigManager / AccountStore 字面一致)。\n */\n public function assertWritable(): void\n {\n if (function_exists('app') && app()->environment('production')) {\n throw new ConfigWriteForbiddenException('生产环境禁止修改 AI 配置');\n }\n if ((bool) $this->config->get('scaffold.config_ui.readonly', false)) {\n throw new ConfigWriteForbiddenException('当前为强制只读模式(SCAFFOLD_CONFIG_READONLY)');\n }\n }\n\n public function isReadonly(): bool\n {\n if (function_exists('app') && app()->environment('production')) {\n return true;\n }\n\n return (bool) $this->config->get('scaffold.config_ui.readonly', false);\n }\n\n // -------------------------------------------------------------------------\n\n private function writeYaml(array $ai): void\n {\n $path = $this->path();\n $this->ensureDir(dirname($path));\n\n $body = \"# Scaffold Designer AI 翻译配置(plan 19)\\n\"\n . \"# 由 /scaffold/config/ai 维护,**入 git**(随仓同步到各机器/部署,跟 accounts.yaml 一致)。\\n\"\n . \"# ⚠ api_key 以明文存储(用户选定 plaintext-in-git)—— 请确保本仓私有。\\n\"\n . \"# 删本文件即恢复默认(api_key 清空 = AI 翻译关闭)。\\n\\n\"\n . Yaml::dump(['ai' => $ai], 4, 4, Yaml::DUMP_OBJECT_AS_MAP);\n\n $this->fs->put($path, $body);\n }\n\n private function ensureDir(string $dir): void\n {\n if (! $this->fs->isDirectory($dir)) {\n $this->fs->makeDirectory($dir, 0755, true);\n }\n }\n\n private function basePath(string $relative): string\n {\n return function_exists('base_path') ? base_path($relative) : $relative;\n }\n}\n"}, "9f4a1e53d1c9be30ea0eae35287f1c42837682a6": {"t": 1785760251.9320097, "n": 7340, "h": "e31811924b86928c", "c": "<?php\n\ndeclare(strict_types=1);\n\nnamespace Mooeen\\Scaffold\\Support;\n\nuse Illuminate\\Contracts\\Config\\Repository;\nuse Illuminate\\Filesystem\\Filesystem;\nuse Symfony\\Component\\Yaml\\Yaml;\n\n/**\n * Designer AI 翻译配置存储(plan 19 → 2026-06 GUI 化)。\n *\n * 原本走 SCAFFOLD_AI_* env,改为在 /scaffold/config/ai 可视化编辑,存\n * scaffold/ai.yaml(跟 accounts.yaml 一样**入 git**,随仓同步到各机器/部署):\n * - 运行时读 yaml(不走 env)→ config:cache 安全;改完即时生效,无需重启 worker。\n * - ⚠ api_key 以**明文**存储(用户 2026-06-10 明确选定 plaintext-in-git,已知会进 git\n * history → 请确保本仓私有)。要换 secret-safe 存法见 git log 当时的方案对比。\n *\n * 写入策略:每次整体重写(不增量 patch),历史回溯走 git。\n * 删文件即恢复默认(api_key 清空 = AI 翻译关闭)。production / 强制只读时硬拒。\n */\nclass AiSettingStore\n{\n /**\n * 默认上游(DeepSeek)。api_key 默认空 = 未配置(翻译关闭)。\n *\n * 传输 / 生成参数(temperature / max_tokens / connect_timeout)借鉴 moo-scaffold-cloud\n * 的 AiConfig::DEFAULTS:\n * - temperature 0.2 —— 中文→snake_case 是确定性任务,低温让同字段多次翻译稳定不飘\n * (不传时 DeepSeek 默认 1.0 偏高;DeepSeek 官方建议 coding 类 ~0.0)。\n * - max_tokens 8192 —— 单次生成上限;批量字段翻译够用,留足。\n * - connect_timeout 8 —— 连接超时,区别于总 timeout。\n * 类型即来源:这里写 int/float,load() 据此强转。\n */\n private const DEFAULTS = [\n 'base_url' => 'https://api.deepseek.com/v1',\n 'api_key' => '',\n 'model' => 'deepseek-chat',\n 'timeout' => 10,\n 'connect_timeout' => 8,\n 'max_tokens' => 8192,\n 'temperature' => 0.2,\n ];\n\n /** 最近一次 load() 是否遇到坏 yaml(解析失败回退默认时置真,供页面黄条提示)。 */\n private bool $yamlBroken = false;\n\n public function __construct(\n private readonly Repository $config,\n private readonly Filesystem $fs,\n ) {}\n\n public function path(): string\n {\n $rel = (string) $this->config->get('scaffold.ai.yaml_path', 'scaffold/ai.yaml');\n\n return $this->basePath($rel);\n }\n\n public function exists(): bool\n {\n return $this->fs->exists($this->path());\n }\n\n /**\n * 合并默认值 + yaml 文件,返回归一后的 base_url / api_key / model / timeout。\n * TranslationService binding 直接吃这个。\n *\n * @return array{base_url:string, api_key:string, model:string, timeout:int}\n */\n public function load(): array\n {\n $data = self::DEFAULTS;\n $this->yamlBroken = false;\n\n if ($this->exists()) {\n // ai.yaml 入 git 随仓多机同步,冲突标记(<<<<<<<)/手改坏形是现实事件。\n // 裸 parse 抛异常会 500 掉 AI 配置页(修复入口自身)+ designer 翻译 → 死锁,\n // 只能上磁盘修文件。解析失败回退默认 + 页面黄条提示(2026-06-10 修);\n // 此时从 UI 保存一次即用合法内容重写、自动修复文件。\n try {\n $parsed = Yaml::parse($this->fs->get($this->path())) ?: [];\n } catch (\\Throwable) {\n $parsed = [];\n $this->yamlBroken = true;\n }\n // 容错:既支持嵌套 `ai:` 也支持平铺\n $ai = (array) ($parsed['ai'] ?? $parsed);\n foreach (array_keys(self::DEFAULTS) as $k) {\n if (array_key_exists($k, $ai) && $ai[$k] !== null && $ai[$k] !== '') {\n $data[$k] = $ai[$k];\n }\n }\n }\n\n return [\n 'base_url' => (string) $data['base_url'],\n 'api_key' => (string) $data['api_key'],\n 'model' => (string) $data['model'],\n 'timeout' => max(1, (int) $data['timeout']),\n 'connect_timeout' => max(1, (int) $data['connect_timeout']),\n 'max_tokens' => max(1, (int) $data['max_tokens']),\n 'temperature' => min(2.0, max(0.0, (float) $data['temperature'])),\n ];\n }\n\n /**\n * UI 展示用:load() 基础上加 api_key_set(是否已配置)+ defaults(占位提示用)。\n * api_key 本身**不回显明文**(密钥)。\n */\n public function read(): array\n {\n $data = $this->load();\n $data['api_key_set'] = $data['api_key'] !== '';\n unset($data['api_key']);\n $data['defaults'] = self::DEFAULTS;\n $data['yaml_broken'] = $this->yamlBroken;\n\n return $data;\n }\n\n /**\n * 保存。base_url / model / timeout 直接覆盖;api_key 空 = 保持原值\n * (跟 AccountStore 密码\"留空不改\"同语义 —— 避免 UI 掩码回显把真 key 冲掉)。\n * 要清空 key 请删 scaffold/ai.yaml。\n *\n * @return array{base_url:string, api_key:string, model:string, timeout:int}\n */\n public function save(array $payload): array\n {\n $this->assertWritable();\n\n $next = $this->load();\n\n foreach (['base_url', 'model'] as $k) {\n if (array_key_exists($k, $payload)) {\n $next[$k] = trim((string) $payload[$k]);\n }\n }\n // 整数传输 / 生成参数:空 / null 跳过(保持原值),否则强转 + 下限保护\n foreach (['timeout', 'connect_timeout', 'max_tokens'] as $k) {\n if (array_key_exists($k, $payload) && $payload[$k] !== null && $payload[$k] !== '') {\n $next[$k] = max(1, (int) $payload[$k]);\n }\n }\n // temperature:float,钳到 [0, 2]\n if (array_key_exists('temperature', $payload) && $payload['temperature'] !== null && $payload['temperature'] !== '') {\n $next['temperature'] = min(2.0, max(0.0, (float) $payload['temperature']));\n }\n // api_key:空保持原值(不清空);非空更新\n if (array_key_exists('api_key', $payload)) {\n $newKey = trim((string) $payload['api_key']);\n if ($newKey !== '') {\n $next['api_key'] = $newKey;\n }\n }\n // 空兜底回默认(否则 base_url='' 让翻译指向相对路径、model='' 让上游 400)\n if ($next['base_url'] === '') {\n $next['base_url'] = self::DEFAULTS['base_url'];\n }\n if ($next['model'] === '') {\n $next['model'] = self::DEFAULTS['model'];\n }\n\n $this->writeYaml($next);\n\n return $next;\n }\n\n /**\n * production / 强制只读时硬拒(跟 ConfigManager / AccountStore 字面一致)。\n */\n public function assertWritable(): void\n {\n if (function_exists('app') && app()->environment('production')) {\n throw new ConfigWriteForbiddenException('生产环境禁止修改 AI 配置');\n }\n if ((bool) $this->config->get('scaffold.config_ui.readonly', false)) {\n throw new ConfigWriteForbiddenException('当前为强制只读模式(SCAFFOLD_CONFIG_READONLY)');\n }\n }\n\n public function isReadonly(): bool\n {\n if (function_exists('app') && app()->environment('production')) {\n return true;\n }\n\n return (bool) $this->config->get('scaffold.config_ui.readonly', false);\n }\n\n // -------------------------------------------------------------------------\n\n private function writeYaml(array $ai): void\n {\n $path = $this->path();\n $this->ensureDir(dirname($path));\n\n $body = \"# Scaffold Designer AI 翻译配置(plan 19)\\n\"\n . \"# 由 /scaffold/config/ai 维护,**入 git**(随仓同步到各机器/部署,跟 accounts.yaml 一致)。\\n\"\n . \"# ⚠ api_key 以明文存储(用户选定 plaintext-in-git)—— 请确保本仓私有。\\n\"\n . \"# 删本文件即恢复默认(api_key 清空 = AI 翻译关闭)。\\n\\n\"\n . Yaml::dump(['ai' => $ai], 4, 4, Yaml::DUMP_OBJECT_AS_MAP);\n\n $this->fs->put($path, $body);\n }\n\n private function ensureDir(string $dir): void\n {\n if (! $this->fs->isDirectory($dir)) {\n $this->fs->makeDirectory($dir, 0755, true);\n }\n }\n\n private function basePath(string $relative): string\n {\n return function_exists('base_path') ? base_path($relative) : $relative;\n }\n}\n"}}, "tests/Feature/Http/ConfigControllerTest.php": {"2ae00c387f87c79d9ea619bf81885aabbbf9ca02": {"t": 1785746176.3086805, "n": 10679, "h": "1ac1e3a0f95a5385", "c": "<?php declare(strict_types=1);\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Mooeen\\Scaffold\\Http\\Middleware\\EnforceScaffoldWritable;\nuse Mooeen\\Scaffold\\Http\\Middleware\\ScaffoldAuthenticate;\n\n/**\n * ConfigController HTTP Feature 测试。\n *\n * GET /scaffold/config 与 /scaffold/config/env 冒烟已被 ScaffoldRoutesTest 覆盖。\n * 这里补:\n * - 读类:show 旧书签 302 到锚点 / 未知 group 404 / env 镜像渲染。\n * - 写类:EnforceScaffoldWritable 被 withoutMiddleware 绕过(测控制器自身逻辑),\n * 验证 ConfigController::update 的三条分支:\n * a) 无变更提交 → flash_message \"没有变更被写入\"(不碰文件);\n * b) readonly 模式 → ConfigManager::write throw ConfigWriteForbiddenException → flash_error;\n * c) `->where('group', '[a-z0-9_-]+')` 路由约束 → 大写 group 404。\n *\n * 不实际写 config_path('scaffold.php')/.env(Testbench 环境无该文件,且写盘有副作用):\n * 改 file/env 字段会 throw 进 flash_error,这条 catch 路径用 (b) 等价覆盖。\n */\nbeforeEach(function () {\n $this->withoutMiddleware([\n ScaffoldAuthenticate::class,\n VerifyCsrfToken::class,\n EnforceScaffoldWritable::class,\n ]);\n config(['scaffold.config_ui.readonly' => false]);\n});\n\n// ─── 读类 ────────────────────────────────────────────────────────────────\n\nit('GET /scaffold/config/{group} 旧书签 302 到主页锚点', function () {\n $r = $this->get('/scaffold/config/basic');\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n});\n\nit('GET /scaffold/config/{unknown-group} → 404', function () {\n // group 合法 regex [a-z0-9_-]+ 但 ConfigManager::read 返回 null → abort(404)\n $this->get('/scaffold/config/nope')->assertNotFound();\n});\n\nit('GET /scaffold/config/{GROUP-大写} 被路由 where 约束 404', function () {\n // ->where('group', '[a-z0-9_-]+') 不匹配大写 → Laravel 直接 404,不进 controller\n $this->get('/scaffold/config/Basic')->assertNotFound();\n});\n\nit('GET /scaffold/config/env 渲染 env 镜像页', function () {\n $this->get('/scaffold/config/env')->assertOk();\n});\n\nit('config_ui.enabled=false → 配置 UI 整体不可访问(404,2026-06-09 接上原 dead 字段)', function () {\n config(['scaffold.config_ui.enabled' => false]);\n $this->get('/scaffold/config')->assertNotFound();\n $this->get('/scaffold/config/env')->assertNotFound();\n});\n\n// ─── 写类:update 逻辑 ───────────────────────────────────────────────────\n\nit('POST /scaffold/config/{group} 无变更 → flash_message 没有变更被写入', function () {\n // 不提交 fields(或空)→ ConfigManager::write 写 0 处 → controller flash \"没有变更被写入\"\n $r = $this->post('/scaffold/config/basic', ['fields' => []]);\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_group', 'basic');\n});\n\nit('POST /scaffold/config/{group} readonly 模式 → flash_error(写被拒)', function () {\n config(['scaffold.config_ui.readonly' => true]);\n // 提交一个会被判定为\"有变更\"的 author 值 → write 先 assertWritable() throw → catch 进 flash_error\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => 'changed-author-' . uniqid()],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n $r->assertSessionMissing('flash_message');\n});\n\nit('POST /scaffold/config/{group} fields 超长值 → skipped 中文提示,不写盘(cap 下沉到 write,2026-06-10)', function () {\n // 原 'fields.*' => 'string|max:2000' HTTP 规则会把 map 嵌套数组整组拒掉(hosts 保存全坏),\n // cap 改在 ConfigManager::write cast 后按类型执行:超长 → skipped + 没有变更被写入。\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => str_repeat('x', 2100)],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_skipped');\n $r->assertSessionMissing('flash_error');\n expect(implode(' ', session('flash_skipped')))->toContain('过长');\n});\n\nit('POST hosts map(嵌套数组)保存成功 —— plan-40 验证规则曾把它整组拒掉(2026-06-10 修)', function () {\n // 真实 happy path:造最小 config/scaffold.php(PhpFileEditor 只更新已有 key)\n $cfgFile = config_path('scaffold.php');\n expect(is_file($cfgFile))->toBeFalse(); // testbench 骨架无此文件,放心创建/删除\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'hosts' => [\\n '开发' => 'http://wn.test',\\n ],\\n];\\n\");\n config(['scaffold.hosts' => ['开发' => 'http://wn.test']]);\n\n try {\n $r = $this->post('/scaffold/config/hosts', [\n 'fields' => ['hosts' => [\n 'r0' => ['k' => '开发', 'v' => 'http://wn.test'],\n 'r1' => ['k' => '新环境', 'v' => 'http://new.test'],\n '__present' => '1',\n ]],\n ]);\n $r->assertRedirect();\n // bug 版本:flash_error \"The fields.hosts field must be a string.\",零写入\n $r->assertSessionMissing('flash_error');\n $r->assertSessionHas('flash_message');\n expect((string) session('flash_message'))->toContain('已写入');\n expect(file_get_contents($cfgFile))->toContain('new.test');\n } finally {\n @unlink($cfgFile);\n @unlink(base_path('scaffold/.local/config-env-map.json'));\n }\n});\n\nit('POST int 字段清空 → 跳过不写(原 (int)\"\" = 0 把 ttl/timeout 写成毒药 0,2026-06-10 修)', function () {\n $before = config('scaffold.proxy.timeout');\n $r = $this->post('/scaffold/config/proxy', [\n 'fields' => ['proxy.timeout' => ''],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入'); // bug 版本:试图写 0(testbench 无文件 → flash_error)\n $r->assertSessionMissing('flash_error');\n expect(config('scaffold.proxy.timeout'))->toBe($before);\n});\n\nit('POST 标量字段被喂数组 → 按未动处理,不 500 不报错', function () {\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => ['evil' => 'array']],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionMissing('flash_error');\n});\n\nit('POST /scaffold/config/{GROUP-大写} 写路由被 where 约束 404', function () {\n $this->post('/scaffold/config/Basic', ['fields' => []])->assertNotFound();\n});\n\n// ─── AI 配置页(/scaffold/config/ai,2026-06)──────────────────────────────\n\nit('GET /scaffold/config/ai 渲染 AI 配置页(200 而非被 {group} 通配吞掉 302)', function () {\n // 路由顺序回归锁:/config/ai 必须注册在 /config/{group} 通配之前,\n // 否则 `ai` 匹配 {group} regex [a-z0-9_-]+ → show() 302 到 #group-ai,页面消失。\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('AI 配置');\n $r->assertSee('base_url');\n});\n\nit('POST /scaffold/config/ai 保存写 yaml + flash_message', function () {\n // sandbox yaml(相对 base_path),避免污染 testbench skeleton 的 scaffold/ 目录\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n try {\n $r = $this->post('/scaffold/config/ai', [\n 'base_url' => 'https://x.test/v1', 'api_key' => 'sk-http-test', 'temperature' => '0.3',\n ]);\n $r->assertRedirect(route('scaffold.config.ai'));\n $r->assertSessionHas('flash_message');\n expect(is_file(base_path($rel)))->toBeTrue();\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['api_key'])->toBe('sk-http-test');\n expect($ai['temperature'])->toBe(0.3);\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai readonly 模式 → flash_error 不写盘', function () {\n config(['scaffold.config_ui.readonly' => true]);\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n $r = $this->post('/scaffold/config/ai', ['api_key' => 'sk-should-not-write']);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n\nit('env 字段保存后运行时 config 即时生效(原 re-require 后 env() 旧值 → 回显旧值像没存上,2026-06-10 修)', function () {\n // 完整 env 链路沙箱:config/scaffold.php(scanner 识别 env 来源)+ .env(EnvFileEditor 写入)\n $cfgFile = config_path('scaffold.php');\n $envFile = base_path('.env');\n $mapCache = base_path('scaffold/.local/config-env-map.json');\n // Testbench base_path 落在持久 vendor 目录里(夹具坑),这两文件可能已被别的用途占用。\n // 备份原内容、跑完还原:既不误删用户预存的 .env,也不受预存文件影响——\n // 替代原「断言不存在」的脆前置(一遇持久 base_path 里的残留 .env 就误红)。\n $cfgBak = is_file($cfgFile) ? file_get_contents($cfgFile) : null;\n $envBak = is_file($envFile) ? file_get_contents($envFile) : null;\n @unlink($mapCache); // 防同套件先行测试在同一秒留下的 scanner 缓存\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'author' => env('SCAFFOLD_AUTHOR', ''),\\n];\\n\");\n file_put_contents($envFile, \"SCAFFOLD_AUTHOR=old_author\\n\");\n config(['scaffold.author' => 'old_author']); // 模拟 boot 时 env 已载入的运行时值\n\n try {\n $r = $this->post('/scaffold/config/basic', ['fields' => ['author' => 'new_author']]);\n $r->assertRedirect();\n $r->assertSessionMissing('flash_error');\n expect((string) session('flash_message'))->toContain('已写入');\n\n // 持久层:.env 已更新\n expect(file_get_contents($envFile))->toContain('SCAFFOLD_AUTHOR=new_author');\n // 运行时:bug 版本 reloadConfig 重新 require,env() 返回进程旧值 → config 仍 old_author\n expect(config('scaffold.author'))->toBe('new_author');\n } finally {\n // 还原:有原内容写回,无则删掉本测试造的文件\n $cfgBak === null ? @unlink($cfgFile) : file_put_contents($cfgFile, $cfgBak);\n $envBak === null ? @unlink($envFile) : file_put_contents($envFile, $envBak);\n @unlink($mapCache);\n }\n});\n\nit('坏 ai.yaml(git 冲突标记)→ AI 配置页仍 200,回退默认 + 黄条提示(2026-06-10 修)', function () {\n $rel = 'scaffold/ai-broken-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n file_put_contents(base_path($rel), \"ai:\\n<<<<<<< HEAD\\n model: a\\n=======\\n model: b\\n>>>>>>> other\\n\");\n\n try {\n // bug 版本:Yaml::parse 抛 ParseException → 500,修复入口自身死锁\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('ai.yaml 解析失败');\n // load() 回退默认而非半残数据\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['model'])->toBe('deepseek-chat');\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai base_url 非法 URL → flash_error 拒写(url:http,https)', function () {\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n // 无 scheme 裸域名 + javascript: scheme 都该被 validate 拦(ValidationException → catch → flash_error)\n foreach (['api.deepseek.com/v1', 'javascript:alert(1)'] as $bad) {\n $r = $this->post('/scaffold/config/ai', ['base_url' => $bad]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n // 中文校验消息(2026-06-10 修):原先英文验证原文直接糊给用户\n expect((string) session('flash_error'))->toContain('上游地址');\n expect((string) session('flash_error'))->not->toContain('The ');\n }\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n"}, "432f1557a4675fec51c9c3f7d14143d461009fcb": {"t": 1785760253.2961235, "n": 10679, "h": "1ac1e3a0f95a5385", "c": "<?php declare(strict_types=1);\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Mooeen\\Scaffold\\Http\\Middleware\\EnforceScaffoldWritable;\nuse Mooeen\\Scaffold\\Http\\Middleware\\ScaffoldAuthenticate;\n\n/**\n * ConfigController HTTP Feature 测试。\n *\n * GET /scaffold/config 与 /scaffold/config/env 冒烟已被 ScaffoldRoutesTest 覆盖。\n * 这里补:\n * - 读类:show 旧书签 302 到锚点 / 未知 group 404 / env 镜像渲染。\n * - 写类:EnforceScaffoldWritable 被 withoutMiddleware 绕过(测控制器自身逻辑),\n * 验证 ConfigController::update 的三条分支:\n * a) 无变更提交 → flash_message \"没有变更被写入\"(不碰文件);\n * b) readonly 模式 → ConfigManager::write throw ConfigWriteForbiddenException → flash_error;\n * c) `->where('group', '[a-z0-9_-]+')` 路由约束 → 大写 group 404。\n *\n * 不实际写 config_path('scaffold.php')/.env(Testbench 环境无该文件,且写盘有副作用):\n * 改 file/env 字段会 throw 进 flash_error,这条 catch 路径用 (b) 等价覆盖。\n */\nbeforeEach(function () {\n $this->withoutMiddleware([\n ScaffoldAuthenticate::class,\n VerifyCsrfToken::class,\n EnforceScaffoldWritable::class,\n ]);\n config(['scaffold.config_ui.readonly' => false]);\n});\n\n// ─── 读类 ────────────────────────────────────────────────────────────────\n\nit('GET /scaffold/config/{group} 旧书签 302 到主页锚点', function () {\n $r = $this->get('/scaffold/config/basic');\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n});\n\nit('GET /scaffold/config/{unknown-group} → 404', function () {\n // group 合法 regex [a-z0-9_-]+ 但 ConfigManager::read 返回 null → abort(404)\n $this->get('/scaffold/config/nope')->assertNotFound();\n});\n\nit('GET /scaffold/config/{GROUP-大写} 被路由 where 约束 404', function () {\n // ->where('group', '[a-z0-9_-]+') 不匹配大写 → Laravel 直接 404,不进 controller\n $this->get('/scaffold/config/Basic')->assertNotFound();\n});\n\nit('GET /scaffold/config/env 渲染 env 镜像页', function () {\n $this->get('/scaffold/config/env')->assertOk();\n});\n\nit('config_ui.enabled=false → 配置 UI 整体不可访问(404,2026-06-09 接上原 dead 字段)', function () {\n config(['scaffold.config_ui.enabled' => false]);\n $this->get('/scaffold/config')->assertNotFound();\n $this->get('/scaffold/config/env')->assertNotFound();\n});\n\n// ─── 写类:update 逻辑 ───────────────────────────────────────────────────\n\nit('POST /scaffold/config/{group} 无变更 → flash_message 没有变更被写入', function () {\n // 不提交 fields(或空)→ ConfigManager::write 写 0 处 → controller flash \"没有变更被写入\"\n $r = $this->post('/scaffold/config/basic', ['fields' => []]);\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_group', 'basic');\n});\n\nit('POST /scaffold/config/{group} readonly 模式 → flash_error(写被拒)', function () {\n config(['scaffold.config_ui.readonly' => true]);\n // 提交一个会被判定为\"有变更\"的 author 值 → write 先 assertWritable() throw → catch 进 flash_error\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => 'changed-author-' . uniqid()],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n $r->assertSessionMissing('flash_message');\n});\n\nit('POST /scaffold/config/{group} fields 超长值 → skipped 中文提示,不写盘(cap 下沉到 write,2026-06-10)', function () {\n // 原 'fields.*' => 'string|max:2000' HTTP 规则会把 map 嵌套数组整组拒掉(hosts 保存全坏),\n // cap 改在 ConfigManager::write cast 后按类型执行:超长 → skipped + 没有变更被写入。\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => str_repeat('x', 2100)],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_skipped');\n $r->assertSessionMissing('flash_error');\n expect(implode(' ', session('flash_skipped')))->toContain('过长');\n});\n\nit('POST hosts map(嵌套数组)保存成功 —— plan-40 验证规则曾把它整组拒掉(2026-06-10 修)', function () {\n // 真实 happy path:造最小 config/scaffold.php(PhpFileEditor 只更新已有 key)\n $cfgFile = config_path('scaffold.php');\n expect(is_file($cfgFile))->toBeFalse(); // testbench 骨架无此文件,放心创建/删除\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'hosts' => [\\n '开发' => 'http://wn.test',\\n ],\\n];\\n\");\n config(['scaffold.hosts' => ['开发' => 'http://wn.test']]);\n\n try {\n $r = $this->post('/scaffold/config/hosts', [\n 'fields' => ['hosts' => [\n 'r0' => ['k' => '开发', 'v' => 'http://wn.test'],\n 'r1' => ['k' => '新环境', 'v' => 'http://new.test'],\n '__present' => '1',\n ]],\n ]);\n $r->assertRedirect();\n // bug 版本:flash_error \"The fields.hosts field must be a string.\",零写入\n $r->assertSessionMissing('flash_error');\n $r->assertSessionHas('flash_message');\n expect((string) session('flash_message'))->toContain('已写入');\n expect(file_get_contents($cfgFile))->toContain('new.test');\n } finally {\n @unlink($cfgFile);\n @unlink(base_path('scaffold/.local/config-env-map.json'));\n }\n});\n\nit('POST int 字段清空 → 跳过不写(原 (int)\"\" = 0 把 ttl/timeout 写成毒药 0,2026-06-10 修)', function () {\n $before = config('scaffold.proxy.timeout');\n $r = $this->post('/scaffold/config/proxy', [\n 'fields' => ['proxy.timeout' => ''],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入'); // bug 版本:试图写 0(testbench 无文件 → flash_error)\n $r->assertSessionMissing('flash_error');\n expect(config('scaffold.proxy.timeout'))->toBe($before);\n});\n\nit('POST 标量字段被喂数组 → 按未动处理,不 500 不报错', function () {\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => ['evil' => 'array']],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionMissing('flash_error');\n});\n\nit('POST /scaffold/config/{GROUP-大写} 写路由被 where 约束 404', function () {\n $this->post('/scaffold/config/Basic', ['fields' => []])->assertNotFound();\n});\n\n// ─── AI 配置页(/scaffold/config/ai,2026-06)──────────────────────────────\n\nit('GET /scaffold/config/ai 渲染 AI 配置页(200 而非被 {group} 通配吞掉 302)', function () {\n // 路由顺序回归锁:/config/ai 必须注册在 /config/{group} 通配之前,\n // 否则 `ai` 匹配 {group} regex [a-z0-9_-]+ → show() 302 到 #group-ai,页面消失。\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('AI 配置');\n $r->assertSee('base_url');\n});\n\nit('POST /scaffold/config/ai 保存写 yaml + flash_message', function () {\n // sandbox yaml(相对 base_path),避免污染 testbench skeleton 的 scaffold/ 目录\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n try {\n $r = $this->post('/scaffold/config/ai', [\n 'base_url' => 'https://x.test/v1', 'api_key' => 'sk-http-test', 'temperature' => '0.3',\n ]);\n $r->assertRedirect(route('scaffold.config.ai'));\n $r->assertSessionHas('flash_message');\n expect(is_file(base_path($rel)))->toBeTrue();\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['api_key'])->toBe('sk-http-test');\n expect($ai['temperature'])->toBe(0.3);\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai readonly 模式 → flash_error 不写盘', function () {\n config(['scaffold.config_ui.readonly' => true]);\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n $r = $this->post('/scaffold/config/ai', ['api_key' => 'sk-should-not-write']);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n\nit('env 字段保存后运行时 config 即时生效(原 re-require 后 env() 旧值 → 回显旧值像没存上,2026-06-10 修)', function () {\n // 完整 env 链路沙箱:config/scaffold.php(scanner 识别 env 来源)+ .env(EnvFileEditor 写入)\n $cfgFile = config_path('scaffold.php');\n $envFile = base_path('.env');\n $mapCache = base_path('scaffold/.local/config-env-map.json');\n // Testbench base_path 落在持久 vendor 目录里(夹具坑),这两文件可能已被别的用途占用。\n // 备份原内容、跑完还原:既不误删用户预存的 .env,也不受预存文件影响——\n // 替代原「断言不存在」的脆前置(一遇持久 base_path 里的残留 .env 就误红)。\n $cfgBak = is_file($cfgFile) ? file_get_contents($cfgFile) : null;\n $envBak = is_file($envFile) ? file_get_contents($envFile) : null;\n @unlink($mapCache); // 防同套件先行测试在同一秒留下的 scanner 缓存\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'author' => env('SCAFFOLD_AUTHOR', ''),\\n];\\n\");\n file_put_contents($envFile, \"SCAFFOLD_AUTHOR=old_author\\n\");\n config(['scaffold.author' => 'old_author']); // 模拟 boot 时 env 已载入的运行时值\n\n try {\n $r = $this->post('/scaffold/config/basic', ['fields' => ['author' => 'new_author']]);\n $r->assertRedirect();\n $r->assertSessionMissing('flash_error');\n expect((string) session('flash_message'))->toContain('已写入');\n\n // 持久层:.env 已更新\n expect(file_get_contents($envFile))->toContain('SCAFFOLD_AUTHOR=new_author');\n // 运行时:bug 版本 reloadConfig 重新 require,env() 返回进程旧值 → config 仍 old_author\n expect(config('scaffold.author'))->toBe('new_author');\n } finally {\n // 还原:有原内容写回,无则删掉本测试造的文件\n $cfgBak === null ? @unlink($cfgFile) : file_put_contents($cfgFile, $cfgBak);\n $envBak === null ? @unlink($envFile) : file_put_contents($envFile, $envBak);\n @unlink($mapCache);\n }\n});\n\nit('坏 ai.yaml(git 冲突标记)→ AI 配置页仍 200,回退默认 + 黄条提示(2026-06-10 修)', function () {\n $rel = 'scaffold/ai-broken-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n file_put_contents(base_path($rel), \"ai:\\n<<<<<<< HEAD\\n model: a\\n=======\\n model: b\\n>>>>>>> other\\n\");\n\n try {\n // bug 版本:Yaml::parse 抛 ParseException → 500,修复入口自身死锁\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('ai.yaml 解析失败');\n // load() 回退默认而非半残数据\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['model'])->toBe('deepseek-chat');\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai base_url 非法 URL → flash_error 拒写(url:http,https)', function () {\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n // 无 scheme 裸域名 + javascript: scheme 都该被 validate 拦(ValidationException → catch → flash_error)\n foreach (['api.deepseek.com/v1', 'javascript:alert(1)'] as $bad) {\n $r = $this->post('/scaffold/config/ai', ['base_url' => $bad]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n // 中文校验消息(2026-06-10 修):原先英文验证原文直接糊给用户\n expect((string) session('flash_error'))->toContain('上游地址');\n expect((string) session('flash_error'))->not->toContain('The ');\n }\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n"}, "9f4a1e53d1c9be30ea0eae35287f1c42837682a6": {"t": 1785760254.5604374, "n": 10316, "h": "935d7e0200415383", "c": "<?php declare(strict_types=1);\n\nuse Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken;\nuse Mooeen\\Scaffold\\Http\\Middleware\\EnforceScaffoldWritable;\nuse Mooeen\\Scaffold\\Http\\Middleware\\ScaffoldAuthenticate;\n\n/**\n * ConfigController HTTP Feature 测试。\n *\n * GET /scaffold/config 与 /scaffold/config/env 冒烟已被 ScaffoldRoutesTest 覆盖。\n * 这里补:\n * - 读类:show 旧书签 302 到锚点 / 未知 group 404 / env 镜像渲染。\n * - 写类:EnforceScaffoldWritable 被 withoutMiddleware 绕过(测控制器自身逻辑),\n * 验证 ConfigController::update 的三条分支:\n * a) 无变更提交 → flash_message \"没有变更被写入\"(不碰文件);\n * b) readonly 模式 → ConfigManager::write throw ConfigWriteForbiddenException → flash_error;\n * c) `->where('group', '[a-z0-9_-]+')` 路由约束 → 大写 group 404。\n *\n * 不实际写 config_path('scaffold.php')/.env(Testbench 环境无该文件,且写盘有副作用):\n * 改 file/env 字段会 throw 进 flash_error,这条 catch 路径用 (b) 等价覆盖。\n */\nbeforeEach(function () {\n $this->withoutMiddleware([\n ScaffoldAuthenticate::class,\n VerifyCsrfToken::class,\n EnforceScaffoldWritable::class,\n ]);\n config(['scaffold.config_ui.readonly' => false]);\n});\n\n// ─── 读类 ────────────────────────────────────────────────────────────────\n\nit('GET /scaffold/config/{group} 旧书签 302 到主页锚点', function () {\n $r = $this->get('/scaffold/config/basic');\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n});\n\nit('GET /scaffold/config/{unknown-group} → 404', function () {\n // group 合法 regex [a-z0-9_-]+ 但 ConfigManager::read 返回 null → abort(404)\n $this->get('/scaffold/config/nope')->assertNotFound();\n});\n\nit('GET /scaffold/config/{GROUP-大写} 被路由 where 约束 404', function () {\n // ->where('group', '[a-z0-9_-]+') 不匹配大写 → Laravel 直接 404,不进 controller\n $this->get('/scaffold/config/Basic')->assertNotFound();\n});\n\nit('GET /scaffold/config/env 渲染 env 镜像页', function () {\n $this->get('/scaffold/config/env')->assertOk();\n});\n\nit('config_ui.enabled=false → 配置 UI 整体不可访问(404,2026-06-09 接上原 dead 字段)', function () {\n config(['scaffold.config_ui.enabled' => false]);\n $this->get('/scaffold/config')->assertNotFound();\n $this->get('/scaffold/config/env')->assertNotFound();\n});\n\n// ─── 写类:update 逻辑 ───────────────────────────────────────────────────\n\nit('POST /scaffold/config/{group} 无变更 → flash_message 没有变更被写入', function () {\n // 不提交 fields(或空)→ ConfigManager::write 写 0 处 → controller flash \"没有变更被写入\"\n $r = $this->post('/scaffold/config/basic', ['fields' => []]);\n $r->assertRedirect();\n expect($r->headers->get('Location'))->toContain('#group-basic');\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_group', 'basic');\n});\n\nit('POST /scaffold/config/{group} readonly 模式 → flash_error(写被拒)', function () {\n config(['scaffold.config_ui.readonly' => true]);\n // 提交一个会被判定为\"有变更\"的 author 值 → write 先 assertWritable() throw → catch 进 flash_error\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => 'changed-author-' . uniqid()],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n $r->assertSessionMissing('flash_message');\n});\n\nit('POST /scaffold/config/{group} fields 超长值 → skipped 中文提示,不写盘(cap 下沉到 write,2026-06-10)', function () {\n // 原 'fields.*' => 'string|max:2000' HTTP 规则会把 map 嵌套数组整组拒掉(hosts 保存全坏),\n // cap 改在 ConfigManager::write cast 后按类型执行:超长 → skipped + 没有变更被写入。\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => str_repeat('x', 2100)],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionHas('flash_skipped');\n $r->assertSessionMissing('flash_error');\n expect(implode(' ', session('flash_skipped')))->toContain('过长');\n});\n\nit('POST hosts map(嵌套数组)保存成功 —— plan-40 验证规则曾把它整组拒掉(2026-06-10 修)', function () {\n // 真实 happy path:造最小 config/scaffold.php(PhpFileEditor 只更新已有 key)\n $cfgFile = config_path('scaffold.php');\n expect(is_file($cfgFile))->toBeFalse(); // testbench 骨架无此文件,放心创建/删除\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'hosts' => [\\n '开发' => 'http://wn.test',\\n ],\\n];\\n\");\n config(['scaffold.hosts' => ['开发' => 'http://wn.test']]);\n\n try {\n $r = $this->post('/scaffold/config/hosts', [\n 'fields' => ['hosts' => [\n 'r0' => ['k' => '开发', 'v' => 'http://wn.test'],\n 'r1' => ['k' => '新环境', 'v' => 'http://new.test'],\n '__present' => '1',\n ]],\n ]);\n $r->assertRedirect();\n // bug 版本:flash_error \"The fields.hosts field must be a string.\",零写入\n $r->assertSessionMissing('flash_error');\n $r->assertSessionHas('flash_message');\n expect((string) session('flash_message'))->toContain('已写入');\n expect(file_get_contents($cfgFile))->toContain('new.test');\n } finally {\n @unlink($cfgFile);\n @unlink(base_path('scaffold/.local/config-env-map.json'));\n }\n});\n\nit('POST int 字段清空 → 跳过不写(原 (int)\"\" = 0 把 ttl/timeout 写成毒药 0,2026-06-10 修)', function () {\n $before = config('scaffold.proxy.timeout');\n $r = $this->post('/scaffold/config/proxy', [\n 'fields' => ['proxy.timeout' => ''],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入'); // bug 版本:试图写 0(testbench 无文件 → flash_error)\n $r->assertSessionMissing('flash_error');\n expect(config('scaffold.proxy.timeout'))->toBe($before);\n});\n\nit('POST 标量字段被喂数组 → 按未动处理,不 500 不报错', function () {\n $r = $this->post('/scaffold/config/basic', [\n 'fields' => ['author' => ['evil' => 'array']],\n ]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_message', '没有变更被写入');\n $r->assertSessionMissing('flash_error');\n});\n\nit('POST /scaffold/config/{GROUP-大写} 写路由被 where 约束 404', function () {\n $this->post('/scaffold/config/Basic', ['fields' => []])->assertNotFound();\n});\n\n// ─── AI 配置页(/scaffold/config/ai,2026-06)──────────────────────────────\n\nit('GET /scaffold/config/ai 渲染 AI 配置页(200 而非被 {group} 通配吞掉 302)', function () {\n // 路由顺序回归锁:/config/ai 必须注册在 /config/{group} 通配之前,\n // 否则 `ai` 匹配 {group} regex [a-z0-9_-]+ → show() 302 到 #group-ai,页面消失。\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('AI 配置');\n $r->assertSee('base_url');\n});\n\nit('POST /scaffold/config/ai 保存写 yaml + flash_message', function () {\n // sandbox yaml(相对 base_path),避免污染 testbench skeleton 的 scaffold/ 目录\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n try {\n $r = $this->post('/scaffold/config/ai', [\n 'base_url' => 'https://x.test/v1', 'api_key' => 'sk-http-test', 'temperature' => '0.3',\n ]);\n $r->assertRedirect(route('scaffold.config.ai'));\n $r->assertSessionHas('flash_message');\n expect(is_file(base_path($rel)))->toBeTrue();\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['api_key'])->toBe('sk-http-test');\n expect($ai['temperature'])->toBe(0.3);\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai readonly 模式 → flash_error 不写盘', function () {\n config(['scaffold.config_ui.readonly' => true]);\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n $r = $this->post('/scaffold/config/ai', ['api_key' => 'sk-should-not-write']);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n\nit('env 字段保存后运行时 config 即时生效(原 re-require 后 env() 旧值 → 回显旧值像没存上,2026-06-10 修)', function () {\n // 完整 env 链路沙箱:config/scaffold.php(scanner 识别 env 来源)+ .env(EnvFileEditor 写入)\n $cfgFile = config_path('scaffold.php');\n $envFile = base_path('.env');\n $mapCache = base_path('scaffold/.local/config-env-map.json');\n expect(is_file($cfgFile))->toBeFalse();\n expect(is_file($envFile))->toBeFalse();\n @unlink($mapCache); // 防同套件先行测试在同一秒留下的 scanner 缓存\n file_put_contents($cfgFile, \"<?php\\n\\nreturn [\\n 'author' => env('SCAFFOLD_AUTHOR', ''),\\n];\\n\");\n file_put_contents($envFile, \"SCAFFOLD_AUTHOR=old_author\\n\");\n config(['scaffold.author' => 'old_author']); // 模拟 boot 时 env 已载入的运行时值\n\n try {\n $r = $this->post('/scaffold/config/basic', ['fields' => ['author' => 'new_author']]);\n $r->assertRedirect();\n $r->assertSessionMissing('flash_error');\n expect((string) session('flash_message'))->toContain('已写入');\n\n // 持久层:.env 已更新\n expect(file_get_contents($envFile))->toContain('SCAFFOLD_AUTHOR=new_author');\n // 运行时:bug 版本 reloadConfig 重新 require,env() 返回进程旧值 → config 仍 old_author\n expect(config('scaffold.author'))->toBe('new_author');\n } finally {\n @unlink($cfgFile);\n @unlink($envFile);\n @unlink($mapCache);\n }\n});\n\nit('坏 ai.yaml(git 冲突标记)→ AI 配置页仍 200,回退默认 + 黄条提示(2026-06-10 修)', function () {\n $rel = 'scaffold/ai-broken-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n file_put_contents(base_path($rel), \"ai:\\n<<<<<<< HEAD\\n model: a\\n=======\\n model: b\\n>>>>>>> other\\n\");\n\n try {\n // bug 版本:Yaml::parse 抛 ParseException → 500,修复入口自身死锁\n $r = $this->get('/scaffold/config/ai');\n $r->assertOk();\n $r->assertSee('ai.yaml 解析失败');\n // load() 回退默认而非半残数据\n $ai = app(\\Mooeen\\Scaffold\\Support\\AiSettingStore::class)->load();\n expect($ai['model'])->toBe('deepseek-chat');\n } finally {\n @unlink(base_path($rel));\n }\n});\n\nit('POST /scaffold/config/ai base_url 非法 URL → flash_error 拒写(url:http,https)', function () {\n $rel = 'scaffold/ai-test-' . uniqid() . '.yaml';\n config(['scaffold.ai.yaml_path' => $rel]);\n\n // 无 scheme 裸域名 + javascript: scheme 都该被 validate 拦(ValidationException → catch → flash_error)\n foreach (['api.deepseek.com/v1', 'javascript:alert(1)'] as $bad) {\n $r = $this->post('/scaffold/config/ai', ['base_url' => $bad]);\n $r->assertRedirect();\n $r->assertSessionHas('flash_error');\n // 中文校验消息(2026-06-10 修):原先英文验证原文直接糊给用户\n expect((string) session('flash_error'))->toContain('上游地址');\n expect((string) session('flash_error'))->not->toContain('The ');\n }\n expect(is_file(base_path($rel)))->toBeFalse();\n});\n"}}}}