'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]); DB::purge('sqlite'); $this->withoutMiddleware(); foreach (['2025_06_30_105931_create_course_content_evaluation_asks_table.php', '2025_06_30_105932_create_course_content_evaluation_forms_table.php', '2025_07_03_093224_create_course_content_evaluations_table.php', '2026_01_13_094952_add_dimensions_to_course_content_evaluation_asks_table.php', '2026_10_09_000001_extend_survey_questions_and_snapshots.php', '2026_10_12_000001_add_survey_audience.php'] as $migration) { (require database_path('migrations/' . $migration))->up(); } foreach (['users', 'course_contents', 'teachers'] as $name) { Schema::create($name, function (Blueprint $table) { $table->id(); $table->softDeletes(); }); } $user = new User(); $user->forceFill(['id' => 200, 'mobile' => '13800000000']); Auth::guard('mobile')->setUser($user); $admin = new Admin(); $admin->forceFill(['id' => 1, 'department_id' => 1]); Auth::guard('admin')->setUser($admin); // The existing audit logger queries MySQL column comments; keep this integration test SQLite-only. $this->app->bind(CourseContentEvaluationAskController::class, fn () => new class extends CourseContentEvaluationAskController { protected function saveLogs($original, $model) {} }); $this->app->bind(CourseContentEvaluationsController::class, fn () => new class extends CourseContentEvaluationsController { protected function saveLogs($original, $model) {} }); DB::table('course_content_evaluations')->insert(['id' => 10, 'title' => '培训问卷', 'status' => 1]); } protected function tearDown(): void { Auth::guard('mobile')->forgetUser(); Auth::guard('admin')->forgetUser(); DB::disconnect('sqlite'); parent::tearDown(); } private function createQuestion(): int { $response = $this->postJson('/api/admin/course-content-evaluation-ask/save', [ 'course_content_evaluation_id' => 10, 'name' => '融资对接', 'field' => 'finance', 'edit_input' => 'multi_dimension', 'sort' => 2, 'dimensions' => [ ['field' => 'count', 'name' => '意向数量', 'edit_input' => 'number', 'need_fill' => true, 'config' => ['integer' => true, 'min' => 0]], ['field' => 'follow', 'name' => '需要跟进', 'edit_input' => 'radio', 'need_fill' => true, 'select_item' => [['key' => 'yes', 'value' => '是'], ['key' => 'no', 'value' => '否']]], ], ])->assertOk()->assertJsonPath('name', '融资对接'); return $response->json('id'); } public function testFileAnswersValidateOwnershipAndReturnNames(): void { Schema::create('uploads', function (Blueprint $table) { $table->id(); $table->string('original_name'); $table->string('folder'); $table->string('name'); $table->string('extension'); $table->integer('size'); $table->integer('creator_id'); $table->timestamp('created_at')->nullable(); }); \Illuminate\Support\Facades\Storage::fake('public'); $upload = $this->post('/api/mobile/upload-file', ['file' => \Illuminate\Http\UploadedFile::fake()->create('tmp.pdf', 10, 'application/pdf'), 'original_name' => '原始材料.pdf'])->assertOk()->assertJsonPath('original_name', '原始材料.pdf')->assertJsonPath('creator_id', 200); \Illuminate\Support\Facades\Storage::disk('public')->assertExists('files/'.$upload->json('name')); $this->post('/api/mobile/upload-file', [])->assertJsonPath('errmsg', '请选择有效文件'); DB::table('uploads')->insert([ ['id' => 81, 'original_name' => '调研附件.pdf', 'folder' => 'storage/files', 'name' => '81.pdf', 'extension' => 'pdf', 'size' => 100, 'creator_id' => 200], ['id' => 82, 'original_name' => '他人附件.pdf', 'folder' => 'storage/files', 'name' => '82.pdf', 'extension' => 'pdf', 'size' => 100, 'creator_id' => 201], ]); $question = ['course_content_evaluation_id' => 10, 'name' => '上传附件', 'field' => 'attachment', 'edit_input' => 'file', 'need_fill' => true, 'config' => ['max_files' => 1, 'allowed_extensions' => ['pdf']]]; $id = $this->postJson('/api/admin/course-content-evaluation-ask/save', $question)->assertJsonPath('edit_input', 'file')->json('id'); $this->post('/api/mobile/upload-file', ['file' => \Illuminate\Http\UploadedFile::fake()->create('blocked.docx', 10), 'survey_question_id' => $id])->assertJsonPath('errmsg', '只允许上传 pdf 格式'); $this->assertSame(3, DB::table('uploads')->count()); $allowedUpload = $this->post('/api/mobile/upload-file', ['file' => \Illuminate\Http\UploadedFile::fake()->create('ALLOWED.PDF', 10), 'survey_question_id' => $id])->assertJsonPath('extension', 'pdf'); DB::table('uploads')->insert(['id' => 90, 'original_name' => '文件.docx', 'folder' => 'storage/files', 'name' => '90.docx', 'extension' => 'docx', 'size' => 100, 'creator_id' => 200]); $payload = ['course_content_evaluation_id' => 10, 'answer_version' => 2, 'data' => [['id' => $id, 'value' => []]]]; $url = '/api/mobile/course/course-content-form'; $this->postJson($url, $payload)->assertJsonPath('errmsg', '上传附件:请填写此项'); foreach ([[['id' => 90]], [['id' => 82]], [['id' => 999]], [['id' => 81], ['id' => 82]]] as $invalid) { $payload['data'][0]['value'] = $invalid; $this->assertNotEmpty($this->postJson($url, $payload)->json('errmsg')); $this->assertSame(0, DB::table('course_content_evaluation_forms')->count()); } $payload['data'][0]['value'] = [['id' => 81, 'original_name' => 'fake', 'url' => 'https://evil.invalid']]; $replyId = $this->postJson($url, $payload)->assertJsonPath('data.0.value.0.original_name', '调研附件.pdf')->json('id'); $this->getJson('/api/admin/course-content-evaluation-form/show?id='.$replyId)->assertJsonPath('questions.0.display_value', '调研附件.pdf')->assertJsonPath('questions.0.value.0.id', 81); $download = '/api/admin/course-content-evaluation-form/show?id='.$replyId.'&download_file_id='; $this->get($download.'81')->assertStatus(404)->assertJsonPath('errmsg', '服务器上的附件文件不存在,请检查文件是否已同步'); \Illuminate\Support\Facades\Storage::disk('public')->put('files/81.pdf', 'example attachment'); $this->get($download.'81')->assertOk()->assertStreamedContent('example attachment'); $this->get($download.'82')->assertStatus(403); $this->getJson('/api/admin/course-content-evaluation-form/text-answers?course_content_evaluation_id=10&question_id='.$id)->assertJsonPath('data.0.files.0.original_name', '调研附件.pdf'); $this->getJson('/api/mobile/course/evaluation-detail?course_content_evaluation_id=10')->assertJsonPath('my_response.questions.0.value.0.id', 81); $this->postJson($url, $payload)->assertJsonPath('id', $replyId); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); } public function testAudienceAndDeadlineProtectEntryAndSubmission(): void { Schema::create('courses', function (Blueprint $table) { $table->id(); $table->softDeletes(); }); Schema::create('course_signs', function (Blueprint $table) { $table->id(); $table->integer('user_id'); $table->integer('course_id'); $table->integer('status'); $table->softDeletes(); }); DB::table('courses')->insert([['id' => 11], ['id' => 12]]); $id = $this->createQuestion(); $detail = '/api/mobile/course/evaluation-detail?course_content_evaluation_id=10'; $submit = '/api/mobile/course/course-content-form'; $payload = ['course_content_evaluation_id' => 10, 'answer_version' => 2, 'data' => [['id' => $id, 'value' => ['count' => 0, 'follow' => 'no']]]]; $this->getJson($detail)->assertJsonMissing(['access_error' => '您还未报名相关课程']); $this->postJson('/api/admin/course-content-evaluations/save', ['id' => 10, 'audience_course_ids' => [11, 12]])->assertJsonPath('audience_course_ids', [11, 12]); foreach ([null, 0, 2, 4] as $status) { DB::table('course_signs')->delete(); if ($status !== null) DB::table('course_signs')->insert(['user_id' => 200, 'course_id' => 12, 'status' => $status]); $this->getJson($detail)->assertJsonPath('access_error', '您还未报名相关课程')->assertJsonMissing(['schema_hash']); $this->postJson($submit, $payload)->assertJsonPath('access_error', '您还未报名相关课程'); $this->assertSame(0, DB::table('course_content_evaluation_forms')->count()); } DB::table('course_signs')->update(['status' => 1]); $this->assertNotEmpty($this->getJson($detail)->json('schema_hash')); $replyId = $this->postJson($submit, $payload)->assertOk()->json('id'); $this->assertNotNull($replyId); DB::table('course_content_evaluations')->where('id', 10)->update(['end_time' => '2000-01-01 00:00:00']); $this->getJson($detail)->assertJsonPath('access_error', '当前问卷已截止'); $this->postJson($submit, $payload)->assertJsonPath('access_error', '当前问卷已截止'); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); DB::table('course_content_evaluations')->where('id', 10)->update(['end_time' => null]); $this->postJson('/api/admin/course-content-evaluations/save', ['id' => 10, 'audience_course_ids' => []])->assertJsonPath('audience_course_ids', []); DB::table('course_signs')->delete(); $this->assertNotEmpty($this->getJson($detail)->json('schema_hash')); $this->postJson('/api/admin/course-content-evaluations/save', ['id' => 10, 'audience_course_ids' => [999]])->assertJsonPath('errmsg', '限定人群中存在无效课程'); } public function testConditionsRejectBreakingEditsAndRollBack(): void { $base = '/api/admin/course-content-evaluation-ask/'; $parent = ['course_content_evaluation_id' => 10, 'name' => '保险', 'field' => 'insurance', 'edit_input' => 'radio', 'sort' => 1, 'select_item' => [['key' => 'yes', 'value' => '需要'], ['key' => 'no', 'value' => '不需要']]]; $parent['id'] = $this->postJson($base.'save', $parent)->assertOk()->json('id'); $child = ['course_content_evaluation_id' => 10, 'name' => '身份证', 'field' => 'idcard', 'edit_input' => 'text', 'sort' => 2, 'config' => ['visible_when' => ['field' => 'insurance', 'value' => 'yes']]]; $id = $this->postJson($base.'save', $child)->assertOk()->assertJsonPath('config.visible_when.value', 'yes')->json('id'); $parent['select_item'] = [['key' => 'no', 'value' => '不需要']]; $this->postJson($base.'save', $parent)->assertJsonPath('errmsg', '身份证:显示条件引用的选项不存在,请先调整条件'); $this->assertStringContainsString('yes', DB::table('course_content_evaluation_asks')->where('id', $parent['id'])->value('select_item')); $this->getJson($base.'destroy?id='.$parent['id'])->assertJsonPath('errmsg', '身份证:显示条件只能引用本题之前的单选或多选题,请先调整条件'); $this->assertNull(DB::table('course_content_evaluation_asks')->where('id', $parent['id'])->value('deleted_at')); $this->postJson($base.'reorder', ['course_content_evaluation_id' => 10, 'ids' => [$id, $parent['id']]])->assertJsonPath('errmsg', '身份证:显示条件只能引用本题之前的单选或多选题,请先调整条件'); $this->assertSame(1, (int) DB::table('course_content_evaluation_asks')->where('id', $parent['id'])->value('sort')); $child['id'] = $id; $child['config'] = []; $this->postJson($base.'save', $child)->assertOk()->assertJsonPath('id', $id); $this->getJson($base.'destroy?id='.$parent['id'])->assertOk(); $this->assertNotNull(DB::table('course_content_evaluation_asks')->where('id', $parent['id'])->value('deleted_at')); } public function testFullSubmissionStatisticsSnapshotAndStructureLock(): void { $id = $this->createQuestion(); $definition = $this->getJson('/api/mobile/course/evaluation-detail?course_content_evaluation_id=10')->assertOk(); $hash = $definition->json('schema_hash'); $this->assertNotEmpty($hash); $payload = ['course_content_evaluation_id' => 10, 'answer_version' => 2, 'schema_hash' => $hash, 'data' => [['id' => $id, 'name' => '伪造标题', 'value' => ['count' => 0, 'follow' => 'no']]]]; $reply = $this->postJson('/api/mobile/course/course-content-form', $payload)->assertOk() ->assertJsonPath('user_id', 200)->assertJsonPath('snapshot.version', 2) ->assertJsonPath('snapshot.questions.0.name', '融资对接'); $replyId = $reply->json('id'); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('id', $replyId); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); $this->getJson('/api/admin/course-content-evaluation-form/statistics?course_content_evaluation_id=10') ->assertJsonPath('responses', 1)->assertJsonPath('questions.0.dimensions.0.stats.valid_count', 1) ->assertJsonPath('questions.0.dimensions.0.stats.sum', 0)->assertJsonPath('questions.0.dimensions.1.stats.options.1.count', 1); $this->getJson('/api/admin/course-content-evaluation-form/show?id=' . $replyId) ->assertJsonPath('questions.0.dimensions.0.display_value', '0') ->assertJsonPath('questions.0.dimensions.1.display_value', '否'); $this->postJson('/api/admin/course-content-evaluation-ask/save', ['id' => $id, 'name' => '改名'])->assertJsonPath('errmsg', '已有答卷,不能修改题目结构;请新建问卷'); $this->getJson('/api/admin/course-content-evaluation-ask/destroy?id=' . $id)->assertJsonPath('errmsg', '已有答卷,不能删除题目'); $this->postJson('/api/admin/course-content-evaluation-ask/reorder', ['course_content_evaluation_id' => 10, 'ids' => [$id]])->assertJsonPath('errmsg', '已有答卷,不能调整题目顺序'); $this->postJson('/api/admin/course-content-evaluations/save', ['id' => 10, 'title' => '改标题'])->assertJsonPath('errmsg', '已有答卷,仅可调整开放时间与发布状态;请新建问卷修改内容'); $this->postJson('/api/admin/course-content-evaluations/save', ['id' => 10, 'status' => 0])->assertJsonPath('status', 0); // Even an out-of-band definition change cannot rewrite an existing reply. DB::table('course_content_evaluation_asks')->where('id', $id)->update(['name' => '数据库修改']); $this->getJson('/api/admin/course-content-evaluation-form/show?id=' . $replyId)->assertJsonPath('questions.0.name', '融资对接'); } public function testMobileReentryReturnsOnlyOwnSnapshot(): void { $id = $this->createQuestion(); $url = '/api/mobile/course/evaluation-detail?course_content_evaluation_id=10'; $this->getJson($url)->assertJsonPath('my_response', null); $this->postJson('/api/mobile/course/course-content-form', [ 'course_content_evaluation_id' => 10, 'answer_version' => 2, 'data' => [['id' => $id, 'value' => ['count' => 0, 'follow' => 'no']]], ])->assertOk(); DB::table('course_content_evaluations')->where('id', 10)->update(['title' => '新标题', 'status' => 0]); DB::table('course_content_evaluation_asks')->where('id', $id)->update(['name' => '新题目']); $this->getJson($url)->assertJsonPath('my_response.title', '培训问卷') ->assertJsonPath('my_response.questions.0.name', '融资对接') ->assertJsonPath('my_response.questions.0.value.count', 0) ->assertJsonPath('my_response.questions.0.dimensions.1.selected_keys.0', 'no'); $other = new User(); $other->forceFill(['id' => 201]); Auth::guard('mobile')->setUser($other); $this->getJson($url)->assertJsonPath('my_response', null); } public function testResubmissionUpdatesSameReplyAndRequiresBoundProfile(): void { $id = $this->createQuestion(); $payload = ['course_content_evaluation_id' => 10, 'answer_version' => 2, 'data' => [['id' => $id, 'value' => ['count' => 0, 'follow' => 'no']]]]; $replyId = $this->postJson('/api/mobile/course/course-content-form', $payload)->assertOk()->json('id'); $payload['data'][0]['value'] = ['count' => 8, 'follow' => 'yes']; $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('id', $replyId)->assertJsonPath('data.0.value.count', 8); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); $this->getJson('/api/admin/course-content-evaluation-form/statistics?course_content_evaluation_id=10')->assertJsonPath('responses', 1)->assertJsonPath('questions.0.dimensions.0.stats.sum', 8); $payload['data'][0]['value']['count'] = -1; $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('errcode', 10001); $this->getJson('/api/mobile/course/evaluation-detail?course_content_evaluation_id=10')->assertJsonPath('my_response.questions.0.value.count', 8); DB::table('course_content_evaluations')->where('id', 10)->update(['end_time' => '2020-01-01 00:00:00']); $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('access_error', '当前问卷已截止'); DB::table('course_content_evaluations')->where('id', 10)->update(['end_time' => null]); $guest = new User(); $guest->forceFill(['id' => 201]); Auth::guard('mobile')->setUser($guest); $this->getJson('/api/mobile/course/evaluation-detail?course_content_evaluation_id=10')->assertJsonPath('requires_profile', true)->assertJsonPath('my_response', null); $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('errmsg', '请先绑定或注册'); $this->assertSame(1, DB::table('course_content_evaluation_forms')->count()); } public function testProfileFieldConfigurationIsValidatedAndStored(): void { $payload = ['course_content_evaluation_id' => 10, 'name' => '姓名', 'field' => 'student_name', 'edit_input' => 'text', 'config' => ['user_field' => 'name']]; $this->postJson('/api/admin/course-content-evaluation-ask/save', $payload)->assertJsonPath('config.user_field', 'name'); $payload['field'] = 'invalid_field'; $payload['config']['user_field'] = 'password'; $this->postJson('/api/admin/course-content-evaluation-ask/save', $payload)->assertJsonPath('errcode', 10001); } public function testInvalidQuantityAndStaleSchemaDoNotCreateReplies(): void { $id = $this->createQuestion(); $payload = ['course_content_evaluation_id' => 10, 'answer_version' => 2, 'data' => [['id' => $id, 'value' => ['count' => -1, 'follow' => 'no']]]]; $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('errcode', 10001); $payload['schema_hash'] = 'stale'; $this->postJson('/api/mobile/course/course-content-form', $payload)->assertJsonPath('errmsg', '问卷已更新,请刷新后重新填写'); $this->assertSame(0, DB::table('course_content_evaluation_forms')->count()); } public function testAtomicReorderRequiresCompleteList(): void { $id = $this->createQuestion(); $chapter = $this->postJson('/api/admin/course-content-evaluation-ask/save', ['course_content_evaluation_id' => 10, 'name' => '第五部分', 'field' => 'section', 'edit_input' => 'section', 'sort' => 1])->assertOk()->json('id'); $this->postJson('/api/admin/course-content-evaluation-ask/reorder', ['course_content_evaluation_id' => 10, 'ids' => [$id, $id]])->assertJsonPath('errmsg', '题目列表已变更,请刷新'); $this->postJson('/api/admin/course-content-evaluation-ask/reorder', ['course_content_evaluation_id' => 10, 'ids' => [$chapter, $id]])->assertJsonPath('msg', '排序已保存'); $this->assertSame([$chapter, $id], DB::table('course_content_evaluation_asks')->orderBy('sort')->pluck('id')->all()); } public function testTextAnswersArePaginatedAndZeroRemainsVisible(): void { for ($i = 0; $i < 22; $i++) DB::table('course_content_evaluation_forms')->insert(['course_content_evaluation_id' => 10, 'data' => json_encode([['id' => 5, 'value' => $i === 0 ? '0' : '建议' . $i]])]); $response = $this->getJson('/api/admin/course-content-evaluation-form/text-answers?course_content_evaluation_id=10&question_id=5&page=2&page_size=20')->assertJsonPath('total', 22); $this->assertCount(2, $response->json('data')); $this->assertSame('0', $response->json('data.1.text')); } public function testNewSurveyCanBeCreatedAndMigrationRolledBack(): void { $this->postJson('/api/admin/course-content-evaluations/save', ['title' => '新问卷', 'status' => 0])->assertJsonPath('title', '新问卷'); (require database_path('migrations/2026_10_09_000001_extend_survey_questions_and_snapshots.php'))->down(); $this->assertFalse(Schema::hasColumn('course_content_evaluation_forms', 'snapshot')); $this->assertFalse(Schema::hasColumn('course_content_evaluation_asks', 'config')); } }