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.
wx.sstbc.com/tests/Feature/SupplyDemandHandoverTest.php

414 lines
37 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 Tests\TestCase;
use App\Models\User;
use App\Models\Admin;
use App\Models\SupplyDemand;
use App\Models\Message;
use App\Models\Dialogue;
use App\Models\Config;
use App\Services\SupplyDemand\Conversations;
use App\Services\SupplyDemand\Handover;
use App\Services\SupplyDemand\Profiles;
use App\Services\SupplyDemand\ImportRows;
use App\Services\SupplyDemand\Notices;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Carbon;
class SupplyDemandHandoverTest 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('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'=>'学员1','publisher_company'=>'公司1','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_auto_binding_requires_matching_name_company_and_preserves_pending_messages() {
$matching=$this->demand();$name=$this->demand();$company=$this->demand();$incomplete=$this->demand();
$name->publisher_name='其他人';$name->save();
$company->publisher_company='其他公司';$company->save();
$incomplete->publisher_company='';$incomplete->save();
Conversations::send(2,['supply_demand_id'=>$name->id,'content'=>'先保存咨询']);
$this->assertSame(1,Handover::verified(User::find(1)));
$this->assertSame(1,(int)$matching->fresh()->user_id);
foreach ([$name,$company,$incomplete] as $d) $this->assertSame(0,(int)$d->fresh()->user_id);
$this->assertStringContainsString('姓名不一致',$name->fresh()->binding_note);
$this->assertStringContainsString('公司不一致',$company->fresh()->binding_note);
$this->assertStringContainsString('公司信息不完整',$incomplete->fresh()->binding_note);
$this->assertSame(0,Message::first()->to_user_id);
$this->assertSame(0,DB::table('supply_demand_sms_notices')->count());
$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$name->id,'user_id'=>1,'identity_confirmed'=>true])->assertJsonPath('user_id',1);
$this->assertSame(1,Message::first()->to_user_id);$this->assertSame(1,Message::count());
}
public function test_profile_completion_retries_verified_account_binding_and_ignores_edge_whitespace() {
$d=$this->demand();$user=User::find(1);$user->name=null;$user->company_name=null;$user->save();
$this->assertSame(0,Handover::verified($user));
$this->assertStringContainsString('信息不完整',$d->fresh()->binding_note);
$user->name=' 学员1 ';$user->company_name=' 公司1 ';$user->save();
$this->assertSame(1,Handover::autoBind($user));
$this->assertNull($d->fresh()->binding_note);
}
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_customer_uploaded_template_previews_three_valid_rows() {
$path=base_path('../../需求文档/需求导入模板 (1).xlsx');
if (!is_file($path)) { $this->markTestSkipped('客户附件仅用于本地回归'); }
$file=new \Illuminate\Http\UploadedFile($path,'需求导入模板.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',null,true);
$this->post('/api/admin/supply-demand/import-preview',['file'=>$file])->assertOk()->assertJsonPath('total',3)->assertJsonCount(3,'rows')->assertJsonCount(0,'errors');
}
public function test_import_types_contacts_tags_and_long_content() {
$row=['type'=>'供应','title'=>'设备供应','content'=>str_repeat('详细内容',100),'tag'=>'新能源、新材料,制造,新能源','wechat'=>'wx-test','email'=>'test@example.com','publisher_name'=>'张同学','publisher_company'=>'公司','publisher_mobile'=>'13900000001','expire_time'=>'2027-01-31'];
$this->postJson('/api/admin/supply-demand/import-rows',['rows'=>[$row]])->assertJsonPath('imported',1);
$d=SupplyDemand::first();$this->assertSame(1,(int)$d->type);$this->assertSame('新能源,新材料,制造',$d->tag);$this->assertSame($row['content'],$d->content);$this->assertSame('wx-test',$d->wechat);$this->assertSame('test@example.com',$d->email);
$row['type']='未知';$this->assertCount(1,ImportRows::validate([$row])['errors']);
$export=new \App\Exports\SupplyDemandImportTemplate([],ImportRows::HEADERS);
$bytes=\Maatwebsite\Excel\Facades\Excel::raw($export,\Maatwebsite\Excel\Excel::XLSX);
$file=tempnam(sys_get_temp_dir(),'supply-template');file_put_contents($file,$bytes);
try { $sheet=\PhpOffice\PhpSpreadsheet\IOFactory::load($file)->getActiveSheet();$this->assertSame('"供应,需求,投融资"',$sheet->getCell('A2')->getDataValidation()->getFormula1());$this->assertSame('详细内容',$sheet->getCell('C1')->getValue());$this->assertStringContainsString('、',$sheet->getCell('D2')->getDataValidation()->getPrompt()); } finally { unlink($file); }
}
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_no_enrollment_never_inherits_another_users_class() {
DB::table('course_types')->insert(['id'=>1,'name'=>'高研班']);
DB::table('courses')->insert(['id'=>1,'type'=>1,'name'=>'第十期高研班','class'=>'第十期']);
DB::table('course_signs')->insert(['id'=>1,'user_id'=>1,'course_id'=>1,'status'=>1,'created_at'=>now()]);
$user=User::find(2);$profile=Profiles::user($user);
$this->assertSame('',$profile['class_name']);$this->assertSame('',$profile['course_name']);
$user->setRelation('courseSigns',User::find(1)->courseSigns);
$this->assertSame('',Profiles::user($user)['class_name']);
$d=$this->demand(2);$this->getJson('/api/mobile/supply-demand/index')->assertJsonPath('supplyDemands.data.0.publisher.class_name','');
}
public function test_unbound_publisher_hides_matched_courses_and_keeps_imported_company_until_binding() {
DB::table('course_types')->insert(['id'=>1,'name'=>'高研班']);
DB::table('courses')->insert(['id'=>1,'type'=>1,'name'=>'高研课程','class'=>'高研一班']);
DB::table('course_signs')->insert(['id'=>1,'user_id'=>1,'course_id'=>1,'status'=>1,'created_at'=>now()]);
$user=User::find(1);$user->company_name='账号公司';$user->save();
$d=$this->demand();$d->publisher_mobile=$user->getRawOriginal('mobile');$d->publisher_company='导入公司';$d->save();
foreach ([false,true] as $private) {
$profile=Profiles::publisher($d,$private);
$this->assertSame('',$profile['course_name']);$this->assertSame('',$profile['class_name']);$this->assertSame('导入公司',$profile['company_name']);
}
$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>1,'identity_confirmed'=>true])->assertJsonPath('publisher.class_name','高研一班')->assertJsonPath('publisher.company_name','账号公司');
$this->getJson('/api/mobile/supply-demand/detail?id='.$d->id)->assertJsonPath('publisher.class_name','高研一班')->assertJsonPath('publisher.company_name','账号公司');
$this->assertSame('导入公司',$d->fresh()->publisher_company);
$this->assertSame('账号公司',$user->fresh()->company_name);
$user->company_name=null;$user->save();
$this->assertSame('导入公司',Profiles::publisher($d->fresh())['company_name']);
}
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_body_can_be_configured_without_sending_on_save() {
$settings=\App\Services\SupplyDemand\Settings::all();
$this->assertStringContainsString('我的私信',$settings['sms_content']);
$settings['sms_content']='您有新的供需对接,请登录查看。';
$this->postJson('/api/admin/supply-demand/settings',$settings)->assertJsonPath('sms_content',$settings['sms_content']);
$this->assertSame(0,DB::table('supply_demand_sms_notices')->count());
Config::create(['key'=>'sms_sign','value'=>'【测试签名】']);
$d=$this->demand(1);Conversations::send(2,['supply_demand_id'=>$d->id,'content'=>'咨询']);
$calls=0;
$this->assertTrue(Notices::deliver(1,function($mobile,$text)use(&$calls){$calls++;$this->assertSame('【测试签名】您有新的供需对接,请登录查看。',$text);return true;}));
$this->assertSame(1,$calls);
$settings['sms_content']=' ';
$this->postJson('/api/admin/supply-demand/settings',$settings)->assertJsonPath('errcode',10002);
$this->assertSame('您有新的供需对接,请登录查看。',\App\Services\SupplyDemand\Settings::all()['sms_content']);
}
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_manual_binding_links_same_imported_identity_demands_and_moves_all_their_messages() {
$a=$this->demand();$b=$this->demand();$otherName=$this->demand();$otherCompany=$this->demand();
$otherName->publisher_name='其他人';$otherName->save();$otherCompany->publisher_company='其他公司';$otherCompany->save();
Conversations::send(2,['supply_demand_id'=>$a->id,'content'=>'咨询A']);
Conversations::send(3,['supply_demand_id'=>$b->id,'content'=>'咨询B']);
Conversations::send(2,['supply_demand_id'=>$otherName->id,'content'=>'其他人咨询']);
$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$a->id,'user_id'=>1,'identity_confirmed'=>true])->assertJsonPath('bound_count',2);
$this->assertSame(1,(int)$a->fresh()->user_id);$this->assertSame(1,(int)$b->fresh()->user_id);
$this->assertSame(0,(int)$otherName->fresh()->user_id);$this->assertSame(0,(int)$otherCompany->fresh()->user_id);
$this->assertSame(2,Message::where('to_user_id',1)->count());$this->assertSame(1,Message::where('to_user_id',0)->count());
$this->assertSame(3,Message::count());$this->assertSame(2,Dialogue::where('to_user_id',1)->count());
}
public function test_batch_unpublish_keeps_bindings_and_messages_and_excludes_public_listing() {
$published=$this->demand(1);$pending=$this->demand(0,0);$rejected=$this->demand(0,2);
Conversations::send(2,['supply_demand_id'=>$published->id,'content'=>'历史咨询']);
$this->postJson('/api/admin/supply-demand/batch-unpublish',['ids'=>[$published->id,$pending->id,$rejected->id]])->assertJsonPath('count',2)->assertJsonPath('skipped_count',1);
$this->assertSame(4,(int)$published->fresh()->status);$this->assertSame(4,(int)$pending->fresh()->status);$this->assertSame(2,(int)$rejected->fresh()->status);
$this->assertSame(1,(int)$published->fresh()->user_id);$this->assertSame(1,Message::count());
$this->getJson('/api/mobile/supply-demand/index')->assertJsonPath('supplyDemands.total',0);
$other=$this->demand();
$this->postJson('/api/admin/supply-demand/batch-unpublish',['ids'=>[$other->id,99999]])->assertJsonPath('errcode',10002);
$this->assertSame(1,(int)$other->fresh()->status);
$this->postJson('/api/admin/supply-demand/batch-unpublish',['ids'=>[]])->assertJsonPath('errcode',10002);
}
public function test_manual_review_filter_only_includes_unbound_proxy_rows_needing_review() {
$review=$this->demand();$review->binding_note='姓名不一致,请人工核实';$review->save();
$plain=$this->demand();$bound=$this->demand(1);$bound->binding_note='管理员人工核实绑定:1';$bound->save();
$this->getJson('/api/admin/supply-demand/index?binding=manual_review')->assertJsonPath('total',1)->assertJsonPath('data.0.id',$review->id);
$this->getJson('/api/admin/supply-demand/index?binding=unbound')->assertJsonPath('total',2);
$this->getJson('/api/admin/supply-demand/index?binding=bound')->assertJsonPath('total',1);
$this->getJson('/api/admin/supply-demand/index?binding=manual_review&filter[0][key]=title&filter[0][op]=like&filter[0][value]=不存在')->assertJsonPath('total',0);
}
public function test_manual_search_and_confirmed_binding_include_legacy_unverified_students() {
$d=$this->demand();$user=User::find(1);
$this->getJson('/api/admin/supply-demand/binding-users?keyword='.$user->getRawOriginal('mobile'))->assertJsonPath('0.id',1)->assertJsonPath('0.mobile_verified',false);
$this->assertSame(0,Handover::autoBind($user));
$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>1])->assertJsonPath('errcode',10002);
$this->postJson('/api/admin/supply-demand/bind-user',['id'=>$d->id,'user_id'=>1,'identity_confirmed'=>true])->assertJsonPath('user_id',1);
$this->assertNull($user->fresh()->supply_mobile_verified_at);
$this->assertStringContainsString('管理员人工核实绑定',$d->fresh()->binding_note);
}
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,'identity_confirmed'=>true])->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_keyword_search_matches_tags_without_bypassing_public_or_owner_filters() {
$published=$this->demand(1);$published->tag='新能源,人工智能';$published->save();
$draft=$this->demand(2,0);$draft->tag='人工智能';$draft->save();
$this->getJson('/api/mobile/supply-demand/index?keyword='.urlencode('智能'))->assertJsonPath('supplyDemands.total',1)->assertJsonPath('supplyDemands.data.0.id',$published->id);
$this->getJson('/api/mobile/supply-demand/index?myself=1&keyword='.urlencode('智能'))->assertJsonPath('supplyDemands.total',1)->assertJsonPath('supplyDemands.data.0.id',$draft->id);
$this->getJson('/api/mobile/supply-demand/index?type=1&keyword='.urlencode('智能'))->assertJsonPath('supplyDemands.total',0);
$this->getJson('/api/mobile/supply-demand/index?keyword='.urlencode('外部需求'))->assertJsonPath('supplyDemands.total',1);
$this->getJson('/api/mobile/supply-demand/index?keyword='.urlencode('采购设备'))->assertJsonPath('supplyDemands.total',1);
$this->enableGuestRequests();
$this->getJson('/api/mobile/supply-demand/index?keyword='.urlencode('新能源'))->assertJsonPath('supplyDemands.total',1)->assertJsonPath('supplyDemands.data.0.id',$published->id);
}
public function test_library_read_endpoints_allow_guests_but_booking_submission_remains_protected() {
Schema::create('uploads',function(Blueprint $t) { $t->id();$t->softDeletes(); });
Schema::create('books',function(Blueprint $t) { $t->id();$t->string('title');$t->string('author')->nullable();$t->string('isbn')->nullable();$t->string('category');$t->integer('cover_id')->nullable();$t->timestamps();$t->softDeletes(); });
DB::table('books')->insert(['id'=>1,'title'=>'游客查询图书','category'=>'科技']);
$this->enableGuestRequests();
$this->getJson('/api/mobile/book/other')->assertOk()->assertJsonPath('category.0','科技');
$this->getJson('/api/mobile/book/index')->assertOk()->assertJsonPath('data.0.title','游客查询图书');
$this->getJson('/api/mobile/book/detail?id=1')->assertOk()->assertJsonPath('title','游客查询图书');
$this->postJson('/api/mobile/schedule/save',[])->assertJsonPath('errcode',40001);
}
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_user_without_any_course_can_be_searched_and_publish() {
$this->assertSame(0,DB::table('course_signs')->where('user_id',2)->count());
$this->getJson('/api/admin/supply-demand/binding-users?keyword=13800000002')->assertJsonPath('0.id',2)->assertJsonPath('0.course_name','');
$this->enableGuestRequests();$token=User::find(2)->createToken('mobile-token')->plainTextToken;
$this->withToken($token)->postJson('/api/mobile/supply-demand/save',['title'=>'未报名用户发布','type'=>2,'content'=>'需求详情','status'=>0])->assertOk()->assertJsonPath('user_id',2);
$this->assertSame(1,SupplyDemand::where('user_id',2)->where('title','未报名用户发布')->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());
}
}