'sqlite', 'database.connections.sqlite.database' => ':memory:', 'audit.enabled' => false, 'yuanhe.base_url' => 'https://example.test', 'yuanhe.customer_id' => 'test', 'yuanhe.auth_key' => 'test']); DB::purge('sqlite'); Schema::create('companies', function (Blueprint $t) { $t->id(); $t->string('company_name'); $t->string('credit_code')->nullable(); $t->timestamps(); $t->softDeletes(); }); Schema::create('company_qcc_accounts', function (Blueprint $t) { $t->id(); $t->unsignedBigInteger('company_id'); $t->string('status'); $t->timestamps(); }); (require base_path('database/migrations/2026_10_09_100000_create_company_news_tables.php'))->up(); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect('sqlite'); parent::tearDown(); } private function company(int $id, string $status = 'selected'): void { DB::table('companies')->insert(['id' => $id, 'company_name' => '企业'.$id, 'credit_code' => 'credit-'.$id]); DB::table('company_qcc_accounts')->insert(['company_id' => $id, 'status' => $status]); } private function news(string $id = 'news-1', string $time = '2026-10-09 23:59:59'): array { return ['NewsId' => $id, 'Id' => 'external-'.$id, 'Title' => '接口标题', 'Content' => '', 'Source' => '来源', 'Url' => 'https://example.com/'.$id, 'PublishTime' => $time, 'Category' => '00000,12014', 'CategoryDesc' => '其他,产品信息', 'NewsTags' => '技术,产品', 'EmotionType' => 'positive']; } private function response(?array $items): array { return ['code' => 200, 'status' => '0', 'data' => ['news' => $items]]; } private function provider(array $responses): void { $mock = Mockery::mock(YuanheRepository::class); foreach ($responses as $response) { $expectation = $mock->shouldReceive('enterprisePackInfo')->once()->ordered()->with(Mockery::type('array'), 60); if ($response instanceof \Throwable) $expectation->andThrow($response); else $expectation->andReturn($response); } $this->app->instance(YuanheRepository::class, $mock); } public function test_repeated_import_preserves_editorial_and_discarded_state(): void { $this->company(41); $this->provider([$this->response([$this->news(), $this->news()]), $this->response([$this->news()])]); $service = new NewsCollection(); $batch = $service->start(1); $service->runOne(); $news = CompanyNews::first(); $this->assertEquals(1, CompanyNews::count()); $this->assertFalse($news->is_visible); $this->assertEquals('pending', $news->review_status); $this->assertEquals('00000', $news->categories[0]['code']); $this->assertNull($news->content_html); $editor = new NewsEditor(); $news = $editor->save($news->id, $news->version, ['title' => '人工标题', 'content_html' => '

人工正文

'], 7); $news = $editor->review($news->id, $news->version, 'discarded', '不相关', 7); $second = $service->start(1); $service->runOne(); $news->refresh(); $this->assertEquals(1, CompanyNews::count()); $this->assertEquals('人工标题', $news->title); $this->assertEquals('

人工正文

', $news->content_html); $this->assertEquals('discarded', $news->review_status); $this->assertFalse($news->is_visible); $this->assertEquals(0, $service->summary($second->id)->inserted); $this->assertEquals(1, $service->summary($batch->id)->inserted); $this->assertEquals('selected', DB::table('company_qcc_accounts')->value('status')); $this->assertEquals(2, DB::table('company_news_logs')->count()); } public function test_failure_does_not_stop_other_companies_or_change_account_states(): void { $this->company(1); $this->company(2); $this->company(3, 'occupied'); $this->company(4, 'unknown'); $this->company(5, 'unregistered'); $this->provider([new RuntimeException('请求超时'), $this->response(null), $this->response([])]); $service = new NewsCollection(); $batch = $service->start(1); $this->assertEquals(3, $batch->total); $service->runOne(); $service->runOne(); $service->runOne(); $summary = $service->summary($batch->id); $this->assertEquals(2, $summary->success); $this->assertEquals(1, $summary->failed); $this->assertEquals(100, $summary->progress); $this->assertEquals('completed', $summary->status); $this->assertEquals(['selected', 'selected', 'occupied', 'unknown', 'unregistered'], DB::table('company_qcc_accounts')->orderBy('company_id')->pluck('status')->all()); } public function test_invalid_news_is_recorded_while_valid_news_is_imported(): void { $this->company(1); $this->provider([$this->response([['Title' => '没有标识'], $this->news()])]); $service = new NewsCollection(); $batch = $service->start(1); $service->runOne(); $summary = $service->summary($batch->id); $this->assertEquals(1, $summary->inserted); $this->assertEquals(1, $summary->failed); $this->assertEquals(1, $summary->invalid); $this->assertStringContainsString('第1条', DB::table('company_news_tasks')->value('error')); } public function test_single_active_batch_and_retry_only_failed_eligible_companies(): void { $this->company(1); $this->company(2); $this->provider([new RuntimeException('超时'), $this->response([]), $this->response([])]); $service = new NewsCollection(); $first = $service->start(1); try { $service->start(2); $this->fail('Duplicate start must be rejected'); } catch (RuntimeException $e) { $this->assertStringContainsString('已有采集', $e->getMessage()); } $service->runOne(); $service->runOne(); DB::table('company_qcc_accounts')->where('company_id', 1)->update(['status' => 'occupied']); $retry = $service->start(2, $first->id); $this->assertEquals(1, $retry->total); $this->assertEquals(1, DB::table('company_news_tasks')->where('batch_id', $retry->id)->value('company_id')); $service->runOne(); $this->assertEquals(1, $service->summary($retry->id)->success); } public function test_worker_lease_blocks_duplicate_claim_and_recovers_crashed_request(): void { $this->company(1); $this->company(2); $this->provider([$this->response([])]); $service = new NewsCollection(); $batch = $service->start(1); DB::table('company_news_tasks')->where('company_id', 1)->update(['status' => 'processing']); DB::table('company_news_control')->update(['worker_token' => 'old-worker', 'lease_until' => now()->addMinutes(5)]); $this->assertFalse($service->runOne()); Carbon::setTestNow(now()->addMinutes(6)); $this->assertTrue($service->runOne()); $this->assertEquals(1, $service->summary($batch->id)->failed); $this->assertEquals(1, $service->summary($batch->id)->success); $this->assertStringContainsString('结果不确定', DB::table('company_news_tasks')->where('company_id', 1)->value('error')); } public function test_candidate_removed_after_snapshot_is_not_called(): void { $this->company(1); $mock = Mockery::mock(YuanheRepository::class); $mock->shouldNotReceive('enterprisePackInfo'); $this->app->instance(YuanheRepository::class, $mock); $service = new NewsCollection(); $batch = $service->start(1); DB::table('company_qcc_accounts')->delete(); $service->runOne(); $this->assertEquals(1, $service->summary($batch->id)->failed); } public function test_execution_rechecks_both_eligible_account_states(): void { $this->company(1); $this->company(2, 'occupied'); $this->provider([$this->response([])]); $service = new NewsCollection(); $batch = $service->start(1); DB::table('company_qcc_accounts')->where('company_id', 1)->update(['status' => 'occupied']); DB::table('company_qcc_accounts')->where('company_id', 2)->update(['status' => 'unknown']); $service->runOne(); $service->runOne(); $this->assertEquals(1, $service->summary($batch->id)->success); $this->assertEquals(1, $service->summary($batch->id)->failed); $this->assertEquals('success', DB::table('company_news_tasks')->where('company_id', 1)->value('status')); $this->assertEquals('failed', DB::table('company_news_tasks')->where('company_id', 2)->value('status')); } public function test_empty_batch_finishes_immediately(): void { $service = new NewsCollection(); $batch = $service->start(1); $this->assertEquals('completed', $batch->status); $this->assertEquals(100, $batch->progress); $this->assertNull(DB::table('company_news_control')->value('active_batch_id')); $this->assertFalse($service->runOne()); } public function test_retained_remains_hidden_and_editor_strips_executable_html(): void { $this->company(1); $this->provider([$this->response([$this->news()])]); $service = new NewsCollection(); $service->start(1); $service->runOne(); $news = CompanyNews::first(); $editor = new NewsEditor(); $news = $editor->save($news->id, $news->version, ['title' => '标题', 'content_html' => '

正文

链接'], 1); $this->assertStringNotContainsString('script', $news->content_html); $this->assertStringNotContainsString('onclick', $news->content_html); $news = $editor->review($news->id, $news->version, 'retained', '', 1); $this->assertFalse($news->is_visible); $this->expectException(RuntimeException::class); $editor->save($news->id, 1, ['title' => '过期修改'], 1); } public function test_company_and_publication_date_filter_includes_end_day(): void { $this->company(1); $this->company(2); $this->provider([$this->response([$this->news('a', '2026-10-09 00:00:00'), $this->news('b'), $this->news('c', '2026-10-10 00:00:00')]), $this->response([$this->news('a')])]); $service = new NewsCollection(); $service->start(1); $service->runOne(); $service->runOne(); $this->assertEquals(4, CompanyNews::count()); $controller = new CompanyNewsController(); $data = $controller->index(Request::create('/', 'GET', ['company_id' => 1, 'date_from' => '2026-10-09', 'date_to' => '2026-10-09']))->getData(true); $this->assertEquals(2, $data['total']); $this->assertEquals([1, 1], array_column($data['data'], 'company_id')); } public function test_business_error_and_malformed_news_are_failures(): void { $this->company(1); $this->company(2); $this->company(3); $this->provider([['code' => 500, 'msg' => '失败'], ['code' => 200, 'data' => []], ['code' => 200, 'data' => ['news' => 'wrong']]]); $service = new NewsCollection(); $batch = $service->start(1); while ($service->runOne()) {} $this->assertEquals(3, $service->summary($batch->id)->failed); $this->assertEquals(0, CompanyNews::count()); } public function test_batch_review_is_atomic_and_ignores_visibility_input(): void { $this->company(1); $this->provider([$this->response([$this->news('a'), $this->news('b')])]); $service = new NewsCollection(); $service->start(1); $service->runOne(); $items = CompanyNews::orderBy('id')->get(['id', 'version'])->toArray(); $controller = new class extends CompanyNewsController { public function getUserId() { return 7; } }; $stale = $items; $stale[1]['version'] = 9; $response = $controller->reviewMany(Request::create('/', 'POST', [ 'items' => $stale, 'review_status' => 'retained', 'is_visible' => true, ]), new NewsEditor())->getData(true); $this->assertEquals(409, $response['errcode']); $this->assertEquals(2, CompanyNews::where('review_status', 'pending')->count()); $this->assertEquals(0, DB::table('company_news_logs')->count()); $controller->reviewMany(Request::create('/', 'POST', [ 'items' => $items, 'review_status' => 'retained', 'is_visible' => true, ]), new NewsEditor()); $this->assertEquals(2, CompanyNews::where('review_status', 'retained')->count()); $this->assertEquals(0, CompanyNews::where('is_visible', true)->count()); } public function test_controller_rejects_empty_discard_reason_and_reversed_dates(): void { $controller = new CompanyNewsController(); $response = $controller->review(Request::create('/', 'POST', [ 'id' => 1, 'version' => 1, 'review_status' => 'discarded', ]), new NewsEditor())->getData(true); $this->assertEquals(422, $response['errcode']); $response = $controller->index(Request::create('/', 'GET', [ 'date_from' => '2026-10-10', 'date_to' => '2026-10-09', ]))->getData(true); $this->assertEquals(422, $response['errcode']); $response = $controller->index(Request::create('/', 'GET', ['date_to' => '2026-10-09']))->getData(true); $this->assertEquals(0, $response['total']); } public function test_menu_migration_is_repeatable_and_copies_only_existing_module_grants(): void { Schema::create('permissions', function (Blueprint $t) { $t->id(); $t->string('name'); $t->string('guard_name'); $t->integer('pid')->default(0); $t->string('url'); $t->string('path')->nullable(); $t->string('api_prefix')->nullable(); $t->integer('visible')->default(1); $t->integer('sortnumber')->default(0); $t->string('icon')->nullable(); $t->timestamps(); $t->softDeletes(); }); Schema::create('role_has_permissions', function (Blueprint $t) { $t->integer('permission_id'); $t->integer('role_id'); }); Schema::create('model_has_permissions', function (Blueprint $t) { $t->integer('permission_id'); $t->integer('model_id'); $t->string('model_type'); }); $source = DB::table('permissions')->insertGetId(['name' => '企业户', 'guard_name' => 'admin', 'url' => '/qccEnterpriseAccount/index']); DB::table('role_has_permissions')->insert(['permission_id' => $source, 'role_id' => 5]); $migration = require base_path('database/migrations/2026_10_09_100001_add_company_news_menu.php'); $migration->up(); $migration->up(); $this->assertEquals(2, DB::table('permissions')->count()); $news = DB::table('permissions')->where('url', '/companyNews/index')->first(); $this->assertEquals('^/companyNews/index', $news->path); $this->assertEquals('api/admin/company-news/', $news->api_prefix); $this->assertEquals([5], DB::table('role_has_permissions')->where('permission_id', $news->id)->pluck('role_id')->all()); } public function test_company_dropdown_includes_only_candidates_and_occupied_with_pagination(): void { for ($id = 1; $id <= 32; $id++) $this->company($id, $id === 32 ? 'occupied' : 'selected'); $this->company(33, 'unknown'); DB::table('companies')->insert(['id' => 34, 'company_name' => '未纳入', 'credit_code' => 'credit-34']); $controller = new CompanyNewsController(); $first = $controller->companies(Request::create('/', 'GET', ['page' => 1]))->getData(true); $second = $controller->companies(Request::create('/', 'GET', ['page' => 2]))->getData(true); $this->assertEquals(32, $first['total']); $this->assertCount(30, $first['data']); $this->assertCount(2, $second['data']); $ids = array_column(array_merge($first['data'], $second['data']), 'id'); $this->assertCount(32, array_unique($ids)); $this->assertContains(32, $ids); $this->assertNotContains(33, $ids); $this->assertNotContains(34, $ids); $filtered = $controller->companies(Request::create('/', 'GET', ['keyword' => '企业32']))->getData(true); $this->assertEquals([32], array_column($filtered['data'], 'id')); } public function test_missing_provider_configuration_rejects_batch_before_any_tasks_are_created(): void { config(['yuanhe.base_url' => null, 'yuanhe.auth_key' => null]); $service = new NewsCollection(); $this->assertStringContainsString('YUANHE_BASE_URL', $service->configurationError()); try { $service->start(1); $this->fail('Missing configuration must be rejected'); } catch (RuntimeException $e) { $this->assertStringContainsString('YUANHE_AUTHKEY', $e->getMessage()); } $this->assertEquals(0, DB::table('company_news_batches')->count()); $this->assertEquals(0, DB::table('company_news_tasks')->count()); $this->artisan('qcc:collect-news')->assertExitCode(1); } }