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.

307 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace Tests\Feature;
use App\Http\Controllers\Mobile\CourseController;
use App\Models\CourseSign;
use App\Models\User;
use App\Models\Admin;
use App\Services\Course\CompanyRegistration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class CompanyRegistrationTest 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();
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);
}
}