'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false]); DB::purge('sqlite'); $this->withoutMiddleware(); Schema::create('courses', function (Blueprint $t) { $t->id(); $t->string('name')->default('课程'); $t->integer('status')->default(1); $t->integer('is_fee')->default(0); $t->decimal('price')->default(0); $t->date('sign_start_date')->nullable(); $t->date('sign_end_date')->nullable(); $t->softDeletes(); $t->timestamps(); }); Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('name'); $t->string('company_name'); $t->string('mobile'); $t->softDeletes(); $t->timestamps(); }); Schema::create('course_signs', function (Blueprint $t) { $t->id(); $t->integer('course_id'); $t->integer('user_id'); $t->integer('status')->default(0); $t->integer('fee_status')->default(0); $t->json('data')->nullable(); $t->json('change_data')->nullable(); $t->integer('is_change')->default(0); $t->json('fee_file_ids')->nullable(); $t->string('no')->nullable(); $t->decimal('money')->default(0); $t->string('title')->nullable(); $t->text('giveup_reason')->nullable(); $t->softDeletes(); $t->timestamps(); }); (require database_path('migrations/2024_07_29_100821_create_course_forms_table.php'))->up(); (require database_path('migrations/2026_10_30_000001_extend_course_forms_and_sign_snapshots.php'))->up(); (require database_path('migrations/2026_10_30_000002_add_course_company_registration_limit.php'))->up(); Schema::create('third_appointment_logs', function (Blueprint $t) { $t->id(); $t->integer('course_sign_id'); $t->integer('plate_status')->default(0); $t->integer('finally')->default(0); $t->string('door_name')->nullable(); $t->timestamp('end_time')->nullable(); $t->softDeletes(); }); Schema::create('course_appointment_totals', function (Blueprint $t) { $t->id(); $t->integer('user_id'); $t->integer('course_sign_id'); $t->softDeletes(); $t->timestamps(); }); DB::table('courses')->insert([['id' => 10, 'one_person_per_company' => 1], ['id' => 11, 'one_person_per_company' => 0]]); foreach ([1 => '张三', 2 => '李四', 3 => '王五'] as $id => $name) DB::table('users')->insert(['id' => $id, 'name' => $name, 'company_name' => 'A公司', 'mobile' => '1380000000'.$id]); foreach ([10,11] as $id) DB::table('course_forms')->insert($this->field() + ['course_id' => $id]); Auth::guard('mobile')->setUser(User::find(2)); $admin = new Admin(); $admin->forceFill(['id' => 1, 'department_id' => 1]); Auth::guard('admin')->setUser($admin); // No real SMS during integration tests; count only committed submissions. $controller = \Mockery::mock(CourseController::class)->makePartial()->shouldAllowMockingProtectedMethods(); $controller->shouldReceive('notifySignSubmitted')->zeroOrMoreTimes()->andReturnNull(); $this->app->instance(CourseController::class, $controller); } protected function tearDown(): void { Auth::guard('mobile')->forgetUser(); Auth::guard('admin')->forgetUser(); DB::disconnect('sqlite'); parent::tearDown(); } private function field(): array { return ['field' => 'company_name', 'name' => '公司', 'edit_input' => 'text', 'rule' => 'required', 'belong_user' => 1, 'belong_user_table' => 'company_name']; } private function attributes(int $user = 2, int $course = 10, string $company = 'A公司'): array { return ['course_id' => $course, 'user_id' => $user, 'status' => 0, 'data' => [$this->field() + ['value' => $company]], 'form_snapshot' => ['version' => 2, 'fields' => [$this->field()]]]; } private function old(int $status = 0): CourseSign { $sign = new CourseSign($this->attributes(1)); $sign->status = $status; $sign->save(); return $sign; } public function testFullHttpConfirmationCancelThenReplaceAndNoDuplicateSubmission(): void { $old = $this->old(1); DB::table('course_appointment_totals')->insert(['user_id' => 1, 'course_sign_id' => $old->id]); DB::table('course_appointment_totals')->insert(['user_id' => 1, 'course_sign_id' => 999]); $data = ['course_id' => 10, 'data' => [['field' => 'company_name', 'value' => 'A公司']]]; $url = '/api/mobile/course/sign'; $prompt = $this->postJson($url, $data)->assertOk()->assertJsonPath('replacement_required', true)->assertJsonPath('replacement_name', '张三')->json(); // Cancelling means no confirmation request. The first request is entirely read-only. $this->assertSame(1, CourseSign::count()); $this->assertSame(1, (int) $old->fresh()->status); $new = $this->postJson($url, $data + ['replacement_token' => $prompt['replacement_token']])->assertOk()->assertJsonPath('status', 0)->json(); $this->assertSame($old->id, $new['replaces_sign_id']); $this->assertSame(5, (int) $old->fresh()->status); $this->assertSame($new['id'], (int) $old->fresh()->replaced_by_sign_id); $this->assertStringContainsString('李四', $old->fresh()->giveup_reason); $this->assertSame($this->attributes(1)['data'], $old->fresh()->data); $this->assertNotNull(DB::table('course_appointment_totals')->where('course_sign_id', $old->id)->value('deleted_at')); $this->assertNull(DB::table('course_appointment_totals')->where('course_sign_id', 999)->value('deleted_at')); $this->postJson($url, $data + ['replacement_token' => $prompt['replacement_token']])->assertJsonPath('errmsg', '请勿重复报名'); $this->assertSame(2, CourseSign::count()); Auth::guard('mobile')->setUser(User::find(1)); $this->postJson('/api/mobile/course/update-sign', ['id' => $old->id, 'data' => [['field' => 'company_name', 'value' => 'B公司']]]) ->assertJsonPath('errmsg', '该报名已被更换,原报名信息只读,请重新报名'); } public function testChangedOccupantRequiresFreshConfirmationAndTokenCannotTransferUsers(): void { $old = $this->old(); $prompt = CompanyRegistration::submit($this->attributes()); $old->status = 3; $old->save(); $newPrompt = CompanyRegistration::submit($this->attributes(), $prompt['replacement_token']); $this->assertTrue($newPrompt['replacement_required']); $this->assertSame(3, (int) $old->fresh()->status); $otherUser = CompanyRegistration::submit($this->attributes(3), $newPrompt['replacement_token']); $this->assertTrue($otherUser['replacement_required']); $otherData = CompanyRegistration::submit($this->attributes() + ['extra' => 'unused'], 'tampered-token'); $this->assertTrue($otherData['replacement_required']); $this->assertSame(1, CourseSign::count()); } public function testPaidProofAndExternalAccessBlockReplacement(): void { $old = $this->old(); foreach ([['fee_status' => 1, 'fee_file_ids' => null], ['fee_status' => 0, 'fee_file_ids' => [9]]] as $update) { $old->fill($update)->save(); try { CompanyRegistration::submit($this->attributes()); $this->fail('paid replacement accepted'); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('缴费', $e->getMessage()); } } $old->fee_file_ids = null; $old->save(); DB::table('third_appointment_logs')->insert(['course_sign_id' => $old->id, 'finally' => 1, 'door_name' => '门1', 'end_time' => now()->addDay()]); try { CompanyRegistration::submit($this->attributes()); $this->fail('permission replacement accepted'); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('门禁或停车', $e->getMessage()); } $this->assertSame(0, (int) $old->fresh()->status); } public function testReplacedUserCanReapplyAsNewRecordAndDelayedGrantsAreSkipped(): void { $old = $this->old(1); $prompt = CompanyRegistration::submit($this->attributes()); $new = CompanyRegistration::submit($this->attributes(), $prompt['replacement_token'])['sign']; $called = false; CompanyRegistration::grantAccess($old->id, function () use (&$called) { $called = true; }); $this->assertFalse($called); $returnPrompt = CompanyRegistration::submit($this->attributes(1)); $reapply = CompanyRegistration::submit($this->attributes(1), $returnPrompt['replacement_token'])['sign']; $this->assertNotSame($old->id, $reapply->id); $this->assertSame(5, (int) $new->fresh()->status); $this->assertSame(3, CourseSign::count()); $old->refresh(); $old->status = 1; $this->expectException(\InvalidArgumentException::class); $old->save(); } public function testSharedGuardRejectsAdminCreationReactivationAndCompanyChange(): void { $this->old(); $this->postJson('/api/admin/course-signs/batch-update-status', ['course_id' => 10, 'ids' => '', 'status' => 1])->assertJsonStructure(['errcode']); foreach (['create', 'reactivate', 'change'] as $kind) { $attributes = $this->attributes(2, 10, $kind === 'change' ? 'B公司' : 'A公司'); $candidate = new CourseSign($attributes); if ($kind === 'reactivate') $candidate->status = 5; if ($kind !== 'create') $candidate->save(); if ($kind === 'reactivate') $candidate->status = 1; if ($kind === 'change') $candidate->data = $this->attributes()['data']; try { $candidate->save(); $this->fail('company conflict accepted'); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('有效报名', $e->getMessage()); } } } public function testUnrestrictedOtherCompanyReleasedStatusesAndCompanySnapshotCompatibility(): void { $old = $this->old(); $unrestricted = CompanyRegistration::submit($this->attributes(1, 11))['sign']; $this->assertSame(11, (int) $unrestricted->course_id); $this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes(2, 11))); $this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes(3, 10, 'B公司'))); DB::table('users')->where('id', 1)->update(['company_name' => 'C公司']); $this->assertTrue(CompanyRegistration::submit($this->attributes())['replacement_required']); $old->status = 2; $old->save(); $this->assertArrayHasKey('sign', CompanyRegistration::submit($this->attributes())); } public function testFailedNewRegistrationRollsBackWithdrawalAndExpiredTokenNeedsConfirmation(): void { $old = $this->old(); $prompt = CompanyRegistration::submit($this->attributes()); $payload = json_decode(\Illuminate\Support\Facades\Crypt::decryptString($prompt['replacement_token']), true); $payload['expires'] = time() - 1; $expired = \Illuminate\Support\Facades\Crypt::encryptString(json_encode($payload)); $this->assertTrue(CompanyRegistration::submit($this->attributes(), $expired)['replacement_required']); $failInsert = true; CourseSign::saving(function ($sign) use (&$failInsert) { if ($failInsert && !$sign->exists && $sign->replaces_sign_id) throw new \RuntimeException('test insert failure'); }); try { CompanyRegistration::submit($this->attributes(), $prompt['replacement_token']); $this->fail('insert failure was ignored'); } catch (\RuntimeException $e) { $this->assertSame('test insert failure', $e->getMessage()); } finally { $failInsert = false; } $this->assertSame(0, (int) $old->fresh()->status); $this->assertNull($old->fresh()->replaced_at); $this->assertSame(1, CourseSign::count()); } public function testAdminApiCannotBypassLimitAndCompanyFieldCanBeOptional(): void { $this->old(); $response = $this->postJson('/api/admin/course-signs/save', $this->attributes()); $this->assertStringContainsString('有效报名', $response->json('errmsg')); $this->assertSame(1, CourseSign::count()); $inactive = new CourseSign($this->attributes()); $inactive->status = 5; $inactive->save(); $response = $this->postJson('/api/admin/course-signs/batch-update-status', ['course_id' => 10, 'ids' => (string) $inactive->id, 'status' => 1]); $this->assertStringContainsString('有效报名', $response->json('errmsg')); $this->assertSame(5, (int) $inactive->fresh()->status); $fieldId = DB::table('course_forms')->where('course_id', 10)->value('id'); $this->postJson('/api/admin/course-forms/save', ['id' => $fieldId, 'rule' => ''])->assertOk()->assertJsonPath('rule', ''); } public function testLegacyCompanyAndBlacklistedUserAreChecked(): void { // Historical rows have no company snapshot; their saved answers still occupy the company slot. DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 1, 'status' => 0, 'data' => json_encode([['field' => 'company_name', 'value' => ' A 公司 ']])]); $this->assertTrue(CompanyRegistration::submit($this->attributes())['replacement_required']); DB::table('course_signs')->insert(['course_id' => 10, 'user_id' => 2, 'status' => 6]); $this->expectException(\InvalidArgumentException::class); CompanyRegistration::submit($this->attributes()); } private function useNameOnlyForm(): array { DB::table('course_forms')->where('course_id', 10)->delete(); $field = ['field' => 'name', 'name' => '姓名', 'edit_input' => 'text', 'rule' => 'required']; DB::table('course_forms')->insert($field + ['course_id' => 10]); return $field; } public function testNoCompanyFieldUsesProfileForConfirmationReplacementAndFrozenSnapshot(): void { $field = $this->useNameOnlyForm(); $old = CourseSign::create(['course_id' => 10, 'user_id' => 1, 'status' => 0, 'data' => [$field + ['value' => '张三']], 'form_snapshot' => ['fields' => [$field]]]); $this->assertSame('A公司', $old->company_name_snapshot); $payload = ['course_id' => 10, 'data' => [['field' => 'name', 'value' => '李四']]]; $url = '/api/mobile/course/sign'; $prompt = $this->postJson($url, $payload)->assertOk()->assertJsonPath('replacement_required', true)->json(); $new = $this->postJson($url, $payload + ['replacement_token' => $prompt['replacement_token']])->assertOk()->assertJsonPath('company_name_snapshot', 'A公司')->assertJsonPath('status', 0)->json(); $this->assertSame(5, (int) $old->fresh()->status); DB::table('users')->where('id', 2)->update(['company_name' => 'B公司']); $sign = CourseSign::findOrFail($new['id']); $originalKey = $sign->company_key; $answers = $sign->data; $answers[0]['value'] = '李四更新姓名'; $sign->data = $answers; $sign->save(); $this->assertSame('A公司', $sign->fresh()->company_name_snapshot); $this->assertSame($originalKey, $sign->fresh()->company_key); $response = $this->postJson('/api/admin/course-signs/save', ['course_id' => 10, 'user_id' => 3, 'status' => 0, 'data' => [['field' => 'name', 'value' => '王五']]]); $this->assertStringContainsString('有效报名', $response->json('errmsg')); } public function testSubmittedLinkedCompanyOverridesDifferentProfileCompany(): void { $this->old(); DB::table('course_forms')->where('course_id', 10)->update(['field' => 'employer']); $payload = ['course_id' => 10, 'data' => [['field' => 'employer', 'value' => 'B公司']]]; $this->postJson('/api/mobile/course/sign', $payload)->assertOk()->assertJsonPath('company_name_snapshot', 'B公司')->assertJsonPath('status', 0); // User 3 belongs to A in their profile, but their submitted B company occupies B's slot. Auth::guard('mobile')->setUser(User::find(3)); $this->postJson('/api/mobile/course/sign', $payload)->assertOk()->assertJsonPath('replacement_required', true)->assertJsonPath('replacement_name', '李四'); $this->assertSame(2, CourseSign::count()); } public function testOptionalEmptyCompanyFallsBackToProfileAndMissingBothIsRejected(): void { $field = $this->field(); $field['rule'] = ''; $attributes = $this->attributes(1); $attributes['data'] = [$field + ['value' => '']]; $attributes['form_snapshot'] = ['fields' => [$field]]; $sign = CompanyRegistration::submit($attributes)['sign']; $this->assertSame('A公司', $sign->company_name_snapshot); $this->useNameOnlyForm(); DB::table('users')->where('id', 2)->update(['company_name' => ' ']); $this->postJson('/api/mobile/course/sign', ['course_id' => 10, 'data' => [['field' => 'name', 'value' => '李四']]]) ->assertJsonPath('errmsg', '请先完善个人信息中的公司名称,或在报名表中填写公司名称'); $this->assertSame(1, CourseSign::count()); } public function testCompanyFieldCanBeRemovedFromPublishedCourseWithoutRegistrations(): void { // The audit helper uses MySQL column comments; omit that unrelated side effect in SQLite. $this->app->instance(\App\Http\Controllers\Admin\CourseFormsController::class, new class extends \App\Http\Controllers\Admin\CourseFormsController { protected function saveLogs($original, $model) {} }); $fieldId = DB::table('course_forms')->where('course_id', 10)->value('id'); $this->getJson('/api/admin/course-forms/destroy?id='.$fieldId)->assertOk(); $this->assertNull(DB::table('course_forms')->where('id', $fieldId)->whereNull('deleted_at')->first()); $attributes = $this->attributes(); $attributes['data'] = []; $attributes['form_snapshot'] = ['fields' => []]; $this->assertSame('A公司', CompanyRegistration::submit($attributes)['sign']->company_name_snapshot); } public function testReplacementNamesUseSubmittedNamesInListDetailsReasonsAndExport(): void { $nameField = ['field' => 'participant', 'name' => '姓名', 'edit_input' => 'text', 'belong_user_table' => 'username']; $oldAttributes = $this->attributes(1); $oldAttributes['data'][] = $nameField + ['value' => '原报名填写姓名']; $oldAttributes['form_snapshot']['fields'][] = $nameField; $old = CourseSign::create($oldAttributes); $newAttributes = $this->attributes(2); $newAttributes['data'][] = $nameField + ['value' => '新报名填写姓名']; $newAttributes['form_snapshot']['fields'][] = $nameField; $prompt = CompanyRegistration::submit($newAttributes); $this->assertSame('原报名填写姓名', $prompt['replacement_name']); $new = CompanyRegistration::submit($newAttributes, $prompt['replacement_token'])['sign']; // Existing stored descriptions can contain IDs; display resolves the linked person's name. DB::table('course_signs')->where('id', $old->id)->update(['giveup_reason' => '旧版描述:报名ID '.$new->id]); $old->refresh(); $new->refresh(); $this->assertSame('因同公司更换参训人员,已由新报名填写姓名替换报名。', $old->giveup_reason); $this->assertStringContainsString('已由新报名填写姓名替换;更换时间:', $old->replacement_text); $this->assertSame('替换原报名填写姓名的报名,需重新审核', $new->replacement_text); $this->assertStringNotContainsString('报名ID', $old->toArray()['replacement_text']); $export = new \App\Exports\CourseRegistrationExport([$old->toArray(), $new->toArray()], []); $sheet = $export->array(); $column = array_search('报名更换记录', $sheet[0], true); $this->assertSame($old->replacement_text, $sheet[1][$column]); $this->assertSame($new->replacement_text, $sheet[2][$column]); $new->delete(); DB::table('users')->where('id', 2)->update(['deleted_at' => now()]); $this->assertStringContainsString('新报名填写姓名', $old->fresh()->giveup_reason); } public function testHistoricalDuplicatesAreListedButNeverAutomaticallyChanged(): void { DB::table('courses')->where('id', 10)->update(['one_person_per_company' => 0]); $one = $this->old(); $two = CourseSign::create($this->attributes(2)); DB::table('courses')->where('id', 10)->update(['one_person_per_company' => 1]); $this->assertCount(1, CompanyRegistration::duplicates(10)); try { CompanyRegistration::submit($this->attributes(3)); $this->fail('historical duplicates replaced'); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('多条历史', $e->getMessage()); } $this->assertSame(0, (int) $one->fresh()->status); $this->assertSame(0, (int) $two->fresh()->status); } }