1 line
85 KiB
JSON
1 line
85 KiB
JSON
{"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"}}}} |