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

1 week ago
<?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);
}
}