'sqlite','database.connections.sqlite.database'=>':memory:','audit.enabled'=>false]); DB::purge('sqlite'); $this->withoutMiddleware(); Schema::create('users',function(Blueprint $t) { $t->id(); foreach(['name','nickname','headimgurl','company_name','mobile','openid'] as $f) $t->string($f)->nullable(); $t->timestamps();$t->softDeletes(); }); Schema::create('supply_demands',function(Blueprint $t) { $t->id();$t->integer('user_id')->default(0);$t->integer('type')->default(2);$t->integer('status')->default(0);$t->integer('public_way')->default(3);$t->integer('view_count')->default(0);$t->integer('contact_count')->default(0);foreach(['title','content','tag','mobile','wechat','email','expire_time','file_ids','contact_name'] as $f)$t->text($f)->nullable();$t->timestamps();$t->softDeletes(); }); foreach(['dialogues','messages'] as $table) Schema::create($table,function(Blueprint $t) use($table) { $t->id();$t->integer('user_id');$t->integer('to_user_id');$t->integer('supply_demand_id')->nullable();if($table==='messages') { $t->integer('dialogue_id');$t->text('content');$t->integer('is_read')->default(0); }else { $t->text('last_content')->nullable();$t->text('last_datetime')->nullable(); }$t->timestamps();$t->softDeletes(); }); Schema::create('configs',function(Blueprint $t) {$t->id();$t->string('key');$t->text('value')->nullable();$t->timestamps();$t->softDeletes();}); Schema::create('course_types',function(Blueprint $t) {$t->id();$t->string('name');$t->softDeletes();}); Schema::create('courses',function(Blueprint $t) {$t->id();$t->string('name');$t->string('class')->nullable();$t->integer('type');$t->softDeletes();}); Schema::create('course_signs',function(Blueprint $t) {$t->id();$t->integer('user_id');$t->integer('course_id');$t->integer('status');$t->timestamps();$t->softDeletes();}); (require database_path('migrations/2026_10_16_160000_add_supply_demand_handover.php'))->up(); foreach([1,2,3] as $id) User::create(['id'=>$id,'name'=>'学员'.$id,'mobile'=>'1380000000'.$id,'company_name'=>'公司'.$id]); $admin=new Admin();$admin->forceFill(['id'=>1,'department_id'=>1]);Auth::guard('admin')->setUser($admin); Auth::guard('mobile')->setUser(User::find(2)); Carbon::setTestNow(Carbon::parse('2026-10-16 15:00:00')); } protected function tearDown(): void { Carbon::setTestNow();Auth::guard('mobile')->forgetUser();Auth::guard('admin')->forgetUser();DB::disconnect('sqlite');parent::tearDown(); } private function demand($owner=0,$status=1) { return SupplyDemand::create(['user_id'=>$owner,'status'=>$status,'is_proxy'=>1,'publisher_name'=>'发布人','publisher_company'=>'导入公司','publisher_mobile'=>'13800000001','title'=>'外部需求','content'=>'采购设备','public_way'=>3]); } private function actAs($id) { Auth::guard('mobile')->setUser(User::find($id)); } public function test_managed_messages_transfer_without_copying_and_owner_can_reply() { $d=$this->demand();$sent=Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'我可以供应']); $this->assertSame(0,Message::first()->to_user_id);$this->assertSame(1,Message::count()); Handover::verified(User::find(1)); $this->assertSame(1,Message::first()->to_user_id);$this->assertSame(0,Message::first()->is_read);$this->assertSame(1,Message::count()); $this->assertSame(1,(int)$d->fresh()->user_id); Conversations::send(1,['dialogue_id'=>$sent['dialogue_id'],'content'=>'您好,可以进一步沟通']); $this->assertSame(2,Message::count());$this->assertSame(2,Message::latest('id')->first()->to_user_id); $this->assertSame(1,DB::table('supply_demand_sms_notices')->count()); } public function test_unverified_and_duplicate_phones_do_not_auto_bind() { $d=$this->demand();$this->assertSame(0,Handover::autoBind(User::find(1))); User::create(['name'=>'重复手机号','mobile'=>'13800000001']);Handover::verified(User::find(1)); $this->assertSame(0,(int)$d->fresh()->user_id);$this->assertNotEmpty($d->fresh()->binding_note); } public function test_one_consultant_two_demands_have_separate_conversations() { $a=$this->demand();$b=$this->demand(); $one=Conversations::send(2,['supply_demand_id'=>$a->id,'content'=>'咨询A']);$two=Conversations::send(2,['supply_demand_id'=>$b->id,'content'=>'咨询B']); $this->assertNotSame($one['dialogue_id'],$two['dialogue_id']); Handover::verified(User::find(1));$this->assertSame(2,Dialogue::where('to_user_id',1)->count()); } public function test_read_only_current_conversation_and_deny_other_user() { $d=$this->demand(1);$a=Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']); $b=Conversations::send(3,['supply_demand_id'=>$d->id,'content'=>'另一位咨询']); $this->actAs(1);$this->getJson('/api/mobile/supply-demand/unread-count')->assertOk()->assertJsonPath('unread_count',2); $this->getJson('/api/mobile/supply-demand/message-list?dialogue_id='.$a['dialogue_id'])->assertOk()->assertJsonPath('message.total',1); $this->assertSame(1,Message::where('to_user_id',1)->where('is_read',0)->count()); $this->actAs(3);$this->getJson('/api/mobile/supply-demand/message-list?dialogue_id='.$a['dialogue_id'])->assertOk()->assertJsonPath('errcode',10002); } public function test_self_send_is_rejected_even_existing_conversation() { $d=$this->demand(1);Dialogue::create(['user_id'=>1,'to_user_id'=>1,'supply_demand_id'=>$d->id]); $this->expectException(\InvalidArgumentException::class);Conversations::send(1,['supply_demand_id'=>$d->id,'to_user_id'=>1,'content'=>'自己']); } public function test_missing_limit_is_unlimited_but_positive_limit_is_exact() { $a=$this->demand();$b=$this->demand();Conversations::send(2,['supply_demand_id'=>$a->id,'content'=>'一']); Config::create(['key'=>'message_limit','value'=>'1']);$this->expectException(\InvalidArgumentException::class);Conversations::send(2,['supply_demand_id'=>$b->id,'content'=>'二']); } public function test_import_validation_duplicate_and_pending_publish() { $row=['title'=>'设备采购','content'=>'一批设备','publisher_name'=>'张同学','publisher_company'=>'公司','publisher_mobile'=>'13900000001']; $v=ImportRows::validate([$row,$row]);$this->assertCount(1,$v['errors']); $this->postJson('/api/admin/supply-demand/import-rows',['rows'=>[$row]])->assertOk()->assertJsonPath('imported',1); $d=SupplyDemand::first();$this->assertSame(0,(int)$d->status);$this->assertSame(0,(int)$d->user_id); $this->getJson('/api/mobile/supply-demand/index')->assertJsonPath('supplyDemands.total',0); $this->postJson('/api/admin/supply-demand/batch-publish',['ids'=>[$d->id]])->assertOk()->assertJsonPath('count',1); $this->getJson('/api/mobile/supply-demand/index')->assertJsonPath('supplyDemands.total',1); $this->postJson('/api/admin/supply-demand/import-rows',['rows'=>[$row]])->assertJsonPath('imported',0); } public function test_contact_details_are_not_leaked_for_proxy_demand() { $d=$this->demand();$d->mobile='13800000001';$d->wechat='private';$d->email='private@example.com';$d->save(); $res=$this->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertOk(); $res->assertJsonPath('mobile',null)->assertJsonPath('wechat',null)->assertJsonMissingPath('publisher_mobile')->assertJsonMissingPath('publisher.mobile'); } public function test_deleted_demand_keeps_history_but_has_no_detail_link() { $d=$this->demand();$a=Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']); $this->getJson('/api/admin/supply-demand/destroy?id='.$d->id)->assertOk(); $res=$this->getJson('/api/mobile/supply-demand/message-list?dialogue_id='.$a['dialogue_id'])->assertOk(); $res->assertJsonPath('supplyDemand',null)->assertJsonPath('message.data.0.supply_demand',null); $this->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertOk()->assertJsonPath('errcode',10002); } public function test_class_priority_approved_only_and_latest_within_system() { foreach([[1,'初创班'],[2,'高研班'],[3,'其他课程']] as [$id,$name]) DB::table('course_types')->insert(['id'=>$id,'name'=>$name]); foreach([[1,1,'初创一班'],[2,2,'高研旧班'],[3,2,'高研新班'],[4,2,'待审核班'],[5,3,'其他新班']] as [$id,$type,$name]) DB::table('courses')->insert(['id'=>$id,'type'=>$type,'name'=>$name,'class'=>$name]); foreach([[1,1,1,'2026-01-01'],[2,2,1,'2026-01-02'],[3,3,1,'2026-02-01'],[4,4,0,'2026-10-16'],[5,5,1,'2026-10-15']] as [$id,$course,$status,$date]) DB::table('course_signs')->insert(['id'=>$id,'user_id'=>1,'course_id'=>$course,'status'=>$status,'created_at'=>$date]); $this->assertSame('高研新班',Profiles::user(User::find(1))['class_name']); DB::table('course_signs')->whereIn('id',[1,2,3])->update(['status'=>0]); $this->assertSame('其他新班',Profiles::user(User::find(1))['class_name']); DB::table('course_signs')->update(['status'=>0]);$this->assertSame('',Profiles::user(User::find(1))['class_name']); } public function test_sms_uses_existing_channel_contract_cooldown_and_failure_is_retryable() { $d=$this->demand(1);Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']); $calls=0;$sender=function($mobile,$text) use(&$calls) {$calls++;$this->assertSame('13800000001',$mobile);$this->assertStringContainsString('校友权益—供需对接—我的私信',$text);return true;}; $this->assertTrue(Notices::deliver(1,$sender));Notices::queue(1);$this->assertFalse(Notices::deliver(1,$sender));$this->assertSame(1,$calls); Carbon::setTestNow(now()->addMinutes(31));$this->assertFalse(Notices::deliver(1,function(){return false;}));$this->assertSame(1,DB::table('supply_demand_sms_notices')->value('attempts'));$this->assertSame(1,Message::count()); } public function test_today_old_demand_new_messages_and_two_way_stats() { $d=$this->demand(1);$d->created_at='2026-01-01 00:00:00';$d->save(); $a=Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'今天咨询']);Conversations::send(1,['dialogue_id'=>$a['dialogue_id'],'content'=>'今天回复']); $this->getJson('/api/admin/supply-demand/chart?start_date=2026-10-16&end_date=2026-10-16')->assertOk()->assertJsonPath('message_count',2)->assertJsonPath('interaction_count',1)->assertJsonPath('list.total',1)->assertJsonPath('supply_demand_count',0); } public function test_settings_and_manual_binding_validation() { $this->postJson('/api/admin/supply-demand/settings',['detail_tip'=>'详情提示','chat_tip'=>'顶部提示','sent_tip'=>'成功提示','sms_enabled'=>'0','sms_interval_minutes'=>15,'sms_daily_limit'=>2])->assertOk()->assertJsonPath('chat_tip','顶部提示'); $d=$this->demand();$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>1])->assertJsonPath('errcode',10002); Handover::verified(User::find(3));$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>3])->assertOk()->assertJsonPath('user_id',3); } public function test_provider_calls_stop_at_daily_cap_and_retry_next_day() { $d=$this->demand(1);Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']); Config::create(['key'=>'supply_demand_sms_daily_limit','value'=>'1']); $calls=0;$sender=function() use(&$calls) {$calls++;return true;}; $this->assertTrue(Notices::deliver(1,$sender));Notices::queue(1); Carbon::setTestNow(now()->addHours(2));$this->assertFalse(Notices::deliver(1,$sender)); Carbon::setTestNow(now()->addDay());$this->assertTrue(Notices::deliver(1,$sender));$this->assertSame(2,$calls); } public function test_export_includes_both_profiles_and_selected_course() { $d=$this->demand(1);Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'今天咨询']); \Maatwebsite\Excel\Facades\Excel::fake(); $this->getJson('/api/admin/supply-demand/export-interactions?start_date=2026-10-16&end_date=2026-10-16')->assertOk(); \Maatwebsite\Excel\Facades\Excel::assertDownloaded('供需交互统计.xlsx',function($export) { $row=$export->array()[0];$headers=$export->headings(); $this->assertSame('公司1',$row[array_search('发布人公司',$headers)]); $this->assertSame('公司2',$row[array_search('对接人公司',$headers)]); $this->assertSame('13800000002',$row[array_search('对接人联系方式',$headers)]); $this->assertContains('对接人报名课程',$headers);$this->assertContains('发布人所在班级',$headers);return true; }); } public function test_import_with_invalid_row_is_atomic_and_expired_publish_rejected() { $row=['title'=>'设备采购','content'=>'设备','publisher_name'=>'张同学','publisher_company'=>'公司','publisher_mobile'=>'13900000001']; $bad=$row;$bad['publisher_mobile']='错误'; $this->postJson('/api/admin/supply-demand/import-rows',['rows'=>[$row,$bad]])->assertJsonPath('imported',0);$this->assertSame(0,SupplyDemand::count()); $d=$this->demand(0,0);$d->expire_time='2026-10-15 23:59:59';$d->save(); $this->postJson('/api/admin/supply-demand/batch-publish',['ids'=>[$d->id]])->assertJsonPath('errcode',10002);$this->assertSame(0,(int)$d->fresh()->status); } public function test_spoofed_receiver_is_ignored_and_direct_access_is_denied() { $d=$this->demand(1);$sent=Conversations::send(2,['supply_demand_id'=>$d->id,'to_user_id'=>3,'content'=>'咨询']); $this->assertSame(1,Message::first()->to_user_id); $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class); Conversations::send(3,['dialogue_id'=>$sent['dialogue_id'],'content'=>'越权发送']); } public function test_repeated_binding_is_rejected_without_duplicating_messages() { $d=$this->demand();Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']);Handover::verified(User::find(1)); $this->assertSame(0,Handover::autoBind(User::find(1)));$this->assertSame(1,Message::count()); $this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>1])->assertJsonPath('errcode',10002); } public function test_normalization_can_preview_and_apply_idempotently() { $d=$this->demand(1);$c=Dialogue::create(['user_id'=>2,'to_user_id'=>1,'supply_demand_id'=>$d->id]); Message::create(['dialogue_id'=>$c->id,'user_id'=>2,'to_user_id'=>1,'supply_demand_id'=>$d->id,'content'=>'历史咨询','is_read'=>0]); $this->artisan('supply-demand:normalize-conversations')->assertExitCode(0);$this->assertSame(1,Dialogue::count()); $this->artisan('supply-demand:normalize-conversations',['--apply'=>true])->assertExitCode(0);$id=Message::first()->dialogue_id; $this->artisan('supply-demand:normalize-conversations',['--apply'=>true])->assertExitCode(0);$this->assertSame($id,Message::first()->dialogue_id);$this->assertSame(1,Message::count()); } public function test_real_excel_preview_preserves_mobile_and_imports_valid_date() { $sheet=new \PhpOffice\PhpSpreadsheet\Spreadsheet(); $sheet->getActiveSheet()->fromArray([ImportRows::HEADERS,['采购设备','一批设备','制造','张同学','某公司','13900000001','2027-01-01']],null,'A1'); $path=tempnam(sys_get_temp_dir(),'demand'); try { (new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($sheet))->save($path); $file=new \Illuminate\Http\UploadedFile($path,'需求导入.xlsx',null,null,true); $res=$this->post('/api/admin/supply-demand/import-preview',['file'=>$file])->assertOk()->assertJsonCount(0,'errors')->assertJsonPath('rows.0.publisher_mobile','13900000001')->assertJsonPath('rows.0.expire_time','2027-01-01 23:59:59'); $rows=$res->json('rows');$rows[0]['expire_time']=substr($rows[0]['expire_time'],0,10); $this->postJson('/api/admin/supply-demand/import-rows',['rows'=>$rows])->assertJsonPath('imported',1); } finally { unlink($path); } } public function test_excel_export_stores_formula_like_input_as_literal_text() { $export=new \App\Exports\SupplyDemandExport([],[]);$sheet=new \PhpOffice\PhpSpreadsheet\Spreadsheet();$cell=$sheet->getActiveSheet()->getCell('A1'); $export->bindValue($cell,'=HYPERLINK("example.com")');$this->assertSame('s',$cell->getDataType()); } public function test_owner_cannot_publish_unreviewed_import_through_mobile_edit() { $d=$this->demand(1,0);$this->actAs(1); $this->postJson('/api/mobile/supply-demand/save',['id'=>$d->id,'status'=>1,'title'=>'更新标题'])->assertOk(); $this->assertSame(0,(int)$d->fresh()->status); $this->actAs(2);$this->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertJsonPath('errcode',10002); } public function test_verified_login_account_merge_retains_and_unifies_consultations() { $d=$this->demand(1);$first=Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'临时账号咨询']); Conversations::send(3,['supply_demand_id'=>$d->id,'content'=>'已有账号咨询']); Handover::mergeAccount(2,3); $this->assertSame(0,Message::where('user_id',2)->count());$this->assertSame(2,Message::where('user_id',3)->count()); $this->assertSame(1,Dialogue::whereHas('messages')->count());$this->assertSame(2,Message::where('is_read',0)->count()); } private function enableGuestRequests() { Schema::create('personal_access_tokens',function(Blueprint $t) { $t->id();$t->morphs('tokenable');$t->string('name');$t->string('token',64)->unique();$t->text('abilities')->nullable();$t->timestamp('last_used_at')->nullable();$t->timestamp('expires_at')->nullable();$t->timestamps(); }); $this->withMiddleware();config(['app.debug'=>false]);Auth::guard('mobile')->forgetUser(); } public function test_shared_supply_pages_allow_guests_and_expired_tokens_but_not_drafts() { $published=$this->demand();$draft=$this->demand(0,0);$this->enableGuestRequests(); $this->getJson('/api/mobile/supply-demand/index')->assertOk()->assertJsonPath('supplyDemands.total',1); $this->getJson('/api/mobile/supply-demand/detail?id='.$published->id)->assertOk()->assertJsonPath('title','外部需求'); $this->getJson('/api/mobile/supply-demand/detail?id='.$draft->id)->assertOk()->assertJsonPath('errcode',10002); $this->withToken('expired-token')->getJson('/api/mobile/supply-demand/index')->assertOk()->assertJsonPath('supplyDemands.total',1); $this->withToken('expired-token')->getJson('/api/mobile/supply-demand/session')->assertJsonPath('registered',false)->assertJsonPath('user',null); $this->getJson('/api/mobile/supply-demand/index?myself=1')->assertJsonPath('errcode',10003); } public function test_guest_or_wechat_only_account_cannot_use_member_actions() { $d=$this->demand();$this->enableGuestRequests(); $this->postJson('/api/mobile/supply-demand/send-message',['supply_demand_id'=>$d->id,'content'=>'游客发送'])->assertJsonPath('errcode',40001); $bare=User::create(['name'=>null,'mobile'=>null]);$token=$bare->createToken('mobile-token')->plainTextToken; $this->withToken($token)->getJson('/api/mobile/supply-demand/session')->assertJsonPath('registered',false); $this->withToken($token)->postJson('/api/mobile/supply-demand/send-message',['supply_demand_id'=>$d->id,'content'=>'未注册发送'])->assertJsonPath('errcode',10003); $this->withToken($token)->postJson('/api/mobile/supply-demand/save',['title'=>'未注册发布'])->assertJsonPath('errcode',10003); $this->assertSame(0,Message::count()); } public function test_registered_member_keeps_private_access_after_optional_browse() { $d=$this->demand(1,0);$this->enableGuestRequests();$token=User::find(1)->createToken('mobile-token')->plainTextToken; $this->withToken($token)->getJson('/api/mobile/supply-demand/session')->assertJsonPath('registered',true)->assertJsonPath('user.id',1); $this->withToken($token)->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertJsonPath('id',$d->id); $this->withToken($token)->getJson('/api/mobile/supply-demand/index?myself=1')->assertJsonPath('supplyDemands.total',1); $this->withToken('invalid')->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertJsonPath('errcode',10002); } public function test_existing_mobile_account_with_older_token_does_not_need_login_again() { $this->enableGuestRequests();config(['sanctum.expiration'=>1440]); $user=User::find(2);$user->name=null;$user->save(); $token=$user->createToken('mobile-token')->plainTextToken; DB::table('personal_access_tokens')->update(['created_at'=>now()->subDays(7)]); $this->withToken($token)->getJson('/api/mobile/supply-demand/session')->assertJsonPath('registered',true)->assertJsonPath('user.id',2); $this->withToken($token)->getJson('/api/mobile/supply-demand/unread-count')->assertJsonMissing(['errcode'=>40001])->assertJsonMissing(['errcode'=>10003]); } public function test_expired_real_token_can_browse_but_cannot_send() { $d=$this->demand();$this->enableGuestRequests();$token=User::find(2)->createToken('mobile-token')->plainTextToken; DB::table('personal_access_tokens')->update(['expires_at'=>now()->subMinute()]); $this->withToken($token)->getJson('/api/mobile/supply-demand/index')->assertJsonPath('supplyDemands.total',1); $this->withToken($token)->postJson('/api/mobile/supply-demand/send-message',['supply_demand_id'=>$d->id,'content'=>'已过期登录'])->assertJsonPath('errcode',40001); $this->assertSame(0,Message::count()); } }