'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]); DB::purge('sqlite'); $this->withoutMiddleware(); (require database_path('migrations/2024_07_29_100821_create_course_forms_table.php'))->up(); Schema::create('course_signs', function (Blueprint $table) { $table->id(); $table->integer('course_id'); $table->integer('user_id'); $table->integer('status')->default(0); $table->integer('fee_status')->default(0); $table->json('data')->nullable(); $table->json('change_data')->nullable(); $table->integer('is_change')->default(0); $table->json('fee_file_ids')->nullable(); $table->softDeletes(); $table->timestamps(); }); (require database_path('migrations/2026_10_30_000001_extend_course_forms_and_sign_snapshots.php'))->up(); Schema::create('courses', function (Blueprint $table) { $table->id(); $table->softDeletes(); }); DB::table('courses')->insert([['id' => 10], ['id' => 11]]); $admin = new Admin(); $admin->forceFill(['id' => 1, 'department_id' => 1]); Auth::guard('admin')->setUser($admin); $user = new User(); $user->forceFill(['id' => 200, 'name' => '原姓名', 'mobile' => '13800000000']); Auth::guard('mobile')->setUser($user); } protected function tearDown(): void { Auth::guard('admin')->forgetUser(); Auth::guard('mobile')->forgetUser(); DB::disconnect('sqlite'); parent::tearDown(); } public function testRegistrationArchiveIncludesCurrentAndLegacyFilesAndListsMissingFiles(): void { \Illuminate\Support\Facades\Storage::fake('public'); Schema::create('uploads', function (Blueprint $table) { $table->id(); $table->string('folder'); $table->string('name'); $table->string('original_name'); }); DB::table('uploads')->insert(['id' => 8, 'folder' => 'storage/files', 'name' => 'example.pdf', 'original_name' => '材料.pdf']); \Illuminate\Support\Facades\Storage::disk('public')->put('files/example.pdf', 'test-pdf'); $rows = [['id' => 1, 'user' => ['name' => '张三'], 'data' => [ ['field' => 'docs', 'name' => '材料', 'edit_input' => 'files', 'fileList' => [['response' => ['id' => 8]]]], ['field' => 'legacy', 'name' => '历史材料', 'edit_input' => 'files', 'value' => 'https://example.com/storage/files/example.pdf'], ['field' => 'missing', 'name' => '缺失材料', 'edit_input' => 'files', 'fileList' => [['id' => 99, 'original_name' => '失效.pdf']]], ['field' => 'hidden', 'name' => '隐藏', 'edit_input' => 'files', 'hidden' => true, 'fileList' => [['id' => 8]]], ]]]; $items = \App\Services\Course\CourseRegistrationArchive::attachments($rows); $this->assertCount(3, $items); $path = \App\Services\Course\CourseRegistrationArchive::build(new CourseRegistrationExport($rows, []), $items); try { $zip = new \ZipArchive(); $this->assertTrue($zip->open($path)); $this->assertNotFalse($zip->locateName('报名信息.xlsx')); $this->assertSame('test-pdf', $zip->getFromName('附件/1_张三/docs_材料/1_材料.pdf')); $this->assertSame('test-pdf', $zip->getFromName('附件/1_张三/legacy_历史材料/2_材料.pdf')); $this->assertStringContainsString('文件缺失,未打包', $zip->getFromName('附件清单.csv')); $this->assertSame(4, $zip->numFiles); $zip->close(); } finally { @unlink($path); } $this->assertSame([], \App\Services\Course\CourseRegistrationArchive::attachments([['data' => [['field' => 'x', 'edit_input' => 'files', 'value' => '']]]])); } private function fields(): array { return [ ['id' => 1, 'field' => 'finance', 'name' => '融资', 'edit_input' => 'radio', 'rule' => 'required', 'sort' => 1, 'select_item' => [['key' => 'later', 'value' => '事后对接'], ['key' => 'road', 'value' => '参加路演'], ['key' => 'no', 'value' => '不需要']]], ['id' => 2, 'field' => 'venues', 'name' => '会场', 'edit_input' => 'checkbox', 'sort' => 2, 'select_item' => [['key' => 'tax', 'value' => '财税'], ['key' => 'legal', 'value' => '法律'], ['key' => 'ip', 'value' => '知识产权']], 'config' => ['min_choices' => 2, 'max_choices' => 2, 'option_notes' => ['tax' => ['required' => true, 'placeholder' => '咨询问题']], 'visible_when' => ['field' => 'finance', 'values' => ['road', 'later'], 'required_values' => ['road']]]], ]; } public function testLegacyLabelsAndNewKeysRoundTripWithConditionalNotesAndZero(): void { $fields = $this->fields(); $fields[] = ['id' => 3, 'field' => 'count', 'name' => '数量', 'edit_input' => 'number']; $result = CourseFormSchema::normalize($fields, [ ['field' => 'finance', 'value' => '参加路演', 'name' => '伪造字段名'], ['field' => 'venues', 'selected_keys' => ['tax', 'legal'], 'value' => '客户端值', 'option_texts' => ['tax' => '税务筹划', 'ip' => '未选中应删除']], ['field' => 'count', 'value' => 0], ]); $this->assertSame('融资', $result[0]['name']); $this->assertSame('参加路演', $result[0]['value']); $this->assertSame('财税,法律', $result[1]['value']); $this->assertSame(['tax', 'legal'], $result[1]['selected_keys']); $this->assertSame(['tax' => '税务筹划'], $result[1]['option_texts']); $this->assertSame(0.0, $result[2]['value']); $this->assertSame('财税:税务筹划', CourseFormSchema::display($result)[1]['supplement_text']); $this->assertSame($result, CourseFormSchema::normalize($fields, $result)); } public function testHiddenFieldsDoNotRequireValuesOrUpdateProfiles(): void { $fields = $this->fields(); $fields[1]['belong_user'] = 1; $fields[1]['belong_user_table'] = 'company_type'; $result = CourseFormSchema::normalize($fields, [['field' => 'finance', 'value' => '不需要'], ['field' => 'venues', 'value' => '已删除的旧选项', 'option_texts' => ['tax' => '旧说明']]]); $this->assertTrue($result[1]['hidden']); $this->assertNull($result[1]['value']); $this->assertSame([], $result[1]['option_texts']); $this->assertSame([], CourseFormSchema::changes($result, (object) [])); $optional = CourseFormSchema::normalize($fields, [['field' => 'finance', 'value' => '事后对接']]); $this->assertFalse($optional[1]['need_fill']); $this->assertSame('', $optional[1]['value']); } public function testVisibleMinimumMaximumRequiredNotesAndOptionalPartialAnswersAreEnforced(): void { foreach ([ [['field' => 'finance', 'value' => '参加路演']], [['field' => 'finance', 'value' => '事后对接'], ['field' => 'venues', 'value' => '法律']], [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律']], [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律,知识产权']], ] as $invalid) { try { CourseFormSchema::normalize($this->fields(), $invalid); $this->fail('invalid answer accepted'); } catch (\InvalidArgumentException $error) { $this->assertNotSame('', $error->getMessage()); } } } public function testQuestionnaireAndRegistrationChoiceSettingsShareDefaultsAndBounds(): void { $field = $this->fields()[1]; unset($field['config']); $this->assertSame(1, CourseFormSchema::validateDefinition($field)['config']['min_choices']); $this->assertSame(1, SurveySchema::question($field)['config']['min_choices']); foreach ([['min_choices' => 0], ['min_choices' => 4], ['min_choices' => 2, 'max_choices' => 1], ['min_choices' => 1.5]] as $config) { foreach ([CourseFormSchema::class, SurveySchema::class] as $schema) { try { $schema === CourseFormSchema::class ? $schema::validateDefinition($field + ['config' => $config]) : $schema::question($field + ['config' => $config]); $this->fail('invalid limits accepted'); } catch (\InvalidArgumentException $error) { $this->assertNotSame('', $error->getMessage()); } } } $field['config'] = ['min_choices' => 2, 'max_choices' => 0]; $this->assertSame(0, CourseFormSchema::validateDefinition($field)['config']['max_choices']); } public function testFieldSettingsCloneAndHistoricalStructureProtection(): void { $url = '/api/admin/course-forms/save'; $field = $this->fields()[0]; unset($field['id']); $field['course_id'] = 10; $id = $this->postJson($url, $field)->assertOk()->json('id'); $this->assertNotNull($id); $child = $this->fields()[1]; unset($child['id']); $child['course_id'] = 10; $childId = $this->postJson($url, $child)->assertJsonPath('config.min_choices', 2)->json('id'); $this->getJson('/api/admin/course-forms/clone?from_course_id=10&to_course_id=11')->assertOk(); $this->assertSame(2, DB::table('course_forms')->where('course_id', 11)->count()); $copy = \App\Models\CourseForm::where('course_id', 11)->where('field', 'venues')->first(); $this->assertSame(['road', 'later'], $copy->config['visible_when']['values']); $this->assertNotSame($childId, $copy->id); DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 200]); $this->postJson($url, ['id' => $id, 'field' => 'changed'])->assertJsonPath('errcode', 10001); $this->getJson('/api/admin/course-forms/destroy?id='.$id)->assertJsonPath('errmsg', '已有报名,不能删除字段;请使用新课程或新表单'); $this->getJson('/api/admin/course-forms/clone?from_course_id=11&to_course_id=10')->assertJsonPath('errmsg', '当前课程已有人员报名,不可克隆覆盖'); $this->assertSame(2, DB::table('course_forms')->where('course_id', 10)->count()); } public function testExistingRegistrationsAllowAddingAndReorderingOptionsWithoutChangingHistory(): void { $url = '/api/admin/course-forms/save'; $field = $this->fields()[0]; unset($field['id']); $field['course_id'] = 10; $id = $this->postJson($url, $field)->assertOk()->json('id'); $child = $this->fields()[1]; unset($child['id']); $child['course_id'] = 10; $this->postJson($url, $child)->assertOk(); $definitions = CourseFormSchema::definitions(10); $answers = CourseFormSchema::normalize($definitions, [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '原咨询']]]); $replyId = DB::table('course_signs')->insertGetId(['course_id' => 10, 'user_id' => 200, 'data' => json_encode($answers), 'form_snapshot' => json_encode(['version' => 2, 'fields' => $definitions])]); $before = DB::table('course_signs')->where('id', $replyId)->first(); $newOptions = array_merge([['key' => 'other', 'value' => '其他']], $field['select_item']); $this->postJson($url, ['id' => $id, 'select_item' => $newOptions])->assertJsonPath('select_item.0.key', 'other'); $reordered = array_reverse($newOptions); $this->postJson($url, ['id' => $id, 'select_item' => $reordered])->assertJsonPath('select_item.3.key', 'other'); $after = DB::table('course_signs')->where('id', $replyId)->first(); $this->assertSame($before->data, $after->data); $this->assertSame($before->form_snapshot, $after->form_snapshot); $this->assertSame('later', CourseFormSchema::definitions(10, CourseSign::find($replyId))[0]['select_item'][0]['key']); $newAnswer = CourseFormSchema::normalize(CourseFormSchema::definitions(10), [['field' => 'finance', 'selected_keys' => ['other']]]); $this->assertSame('其他', $newAnswer[0]['value']); $this->assertTrue($newAnswer[1]['hidden']); $deleted = array_values(array_filter($reordered, fn ($option) => $option['key'] !== 'road')); $this->postJson($url, ['id' => $id, 'select_item' => $deleted])->assertJsonPath('errmsg', '已有报名,不能删除或修改已有选项的键;可以新增选项或调整顺序'); $renamed = $reordered; $renamed[0]['key'] = 'renamed'; $this->postJson($url, ['id' => $id, 'select_item' => $renamed])->assertJsonPath('errcode', 10001); $this->assertSame('no', \App\Models\CourseForm::find($id)->select_item[0]['key']); } public function testNewSubmissionCannotBypassMinAndExistingSnapshotEditsKeepTheirRules(): void { $fields = $this->fields(); foreach ($fields as $field) { $field['course_id'] = 10; foreach (['config', 'select_item'] as $key) $field[$key] = json_encode($field[$key] ?? []); DB::table('course_forms')->insert($field); } $invalid = [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '法律']]; $this->postJson('/api/mobile/course/sign', ['course_id' => 10, 'data' => $invalid])->assertJsonPath('errcode', 10001); $this->assertSame(0, DB::table('course_signs')->count()); $valid = [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '原咨询']]]; $answers = CourseFormSchema::normalize($fields, $valid); $id = DB::table('course_signs')->insertGetId(['course_id' => 10, 'user_id' => 200, 'data' => json_encode($answers), 'form_snapshot' => json_encode(['version' => 2, 'fields' => $fields])]); $changed = $fields[1]['config']; $changed['min_choices'] = 3; DB::table('course_forms')->where('id', 2)->update(['config' => json_encode($changed)]); $valid[1]['option_texts']['tax'] = '修改后的咨询'; $this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $valid])->assertJsonPath('signModel.data.1.option_texts.tax', '修改后的咨询'); $this->assertSame(2, CourseSign::find($id)->form_snapshot['fields'][1]['config']['min_choices']); $this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $invalid])->assertJsonPath('errcode', 10001); $this->assertSame('财税,法律', CourseSign::find($id)->data[1]['value']); DB::table('course_signs')->where('id', $id)->update(['fee_status' => 1]); $this->postJson('/api/mobile/course/update-sign', ['id' => $id, 'data' => $valid])->assertJsonPath('errmsg', '已缴费或已上传凭证,不允许修改'); } public function testHistoricalDefinitionsAndExportsKeepMissingFieldsNotesAndIdentifiers(): void { $old = ['id' => 1, 'field' => 'legacy', 'name' => '历史问题', 'edit_input' => 'text', 'value' => '=1+1']; $sign = new CourseSign(); $sign->forceFill(['course_id' => 10, 'data' => [$old]]); $definitions = CourseFormSchema::definitions(10, $sign); $this->assertSame('历史问题', $definitions[0]['name']); $this->assertTrue($definitions[0]['readonly']); $restored = CourseFormSchema::normalize($definitions, [], [$old]); $this->assertSame('=1+1', $restored[0]['value']); $empty = new CourseSign(); $empty->forceFill(['course_id' => 10, 'data' => [], 'form_snapshot' => ['version' => 2, 'fields' => []]]); $this->assertSame([], CourseFormSchema::definitions(10, $empty)); $legacyChoice = $old; $legacyChoice['edit_input'] = 'checkbox'; $legacyChoice['value'] = '旧选项甲,旧选项乙'; $legacyChoice['select_item'] = []; $sign->data = [$legacyChoice]; $this->assertSame('旧选项甲,旧选项乙', CourseFormSchema::normalize(CourseFormSchema::definitions(10, $sign), [], [$legacyChoice])[0]['value']); $result = CourseFormSchema::normalize($this->fields(), [['field' => 'finance', 'value' => '参加路演'], ['field' => 'venues', 'value' => '财税,法律', 'option_texts' => ['tax' => '问题']]]); $export = new CourseRegistrationExport([['id' => 1, 'data' => [$old]], ['id' => 2, 'data' => $result]], [], $this->fields()); $sheet = $export->array(); $this->assertContains('历史问题', $sheet[0]); $this->assertContains('会场-补充说明', $sheet[0]); $this->assertSame('=1+1', $sheet[1][array_search('历史问题', $sheet[0])]); $this->assertSame('财税:问题', $sheet[2][array_search('会场-补充说明', $sheet[0])]); $this->assertSame("财税\n法律", $sheet[2][array_search('会场', $sheet[0])]); $this->assertSame('', $sheet[1][array_search('会场', $sheet[0])]); $binder = \PhpOffice\PhpSpreadsheet\Cell\Cell::getValueBinder(); $path = tempnam(sys_get_temp_dir(), 'course-export-'); try { \Maatwebsite\Excel\Facades\Excel::store($export, basename($path).'.xlsx', 'local'); $file = storage_path('app/'.basename($path).'.xlsx'); $book = \PhpOffice\PhpSpreadsheet\IOFactory::load($file); $cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(array_search('历史问题', $sheet[0]) + 1).'2'; $this->assertSame('s', $book->getActiveSheet()->getCell($cell)->getDataType()); $this->assertTrue($book->getActiveSheet()->getStyle('A2')->getAlignment()->getWrapText()); $book->disconnectWorksheets(); } finally { \PhpOffice\PhpSpreadsheet\Cell\Cell::setValueBinder($binder); if (isset($file) && is_file($file)) unlink($file); unlink($path); } } }