You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
18 KiB

<?php
namespace Tests\Feature;
use App\Exports\CourseRegistrationExport;
use App\Models\Admin;
use App\Models\CourseSign;
use App\Models\User;
use App\Services\Course\CourseFormSchema;
use App\Services\Survey\SurveySchema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CourseFormWorkflowTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => '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); }
}
}