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.
260 lines
17 KiB
260 lines
17 KiB
<?php
|
|
namespace Tests\Feature;
|
|
|
|
use App\Http\Controllers\Admin\CompanyNewsController;
|
|
use App\Models\CompanyNews;
|
|
use App\Repositories\YuanheRepository;
|
|
use App\Services\CompanyNews\NewsCollection;
|
|
use App\Services\CompanyNews\NewsEditor;
|
|
use Illuminate\Database\Schema\Blueprint;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Mockery;
|
|
use RuntimeException;
|
|
use Tests\TestCase;
|
|
|
|
class CompanyNewsWorkflowTest extends TestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['database.default' => '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' => '<p>人工正文</p>'], 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('<p>人工正文</p>', $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' => '<p onclick="alert(1)">正文</p><script>alert(1)</script><a href="javascript:alert(1)">链接</a>'], 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);
|
|
}
|
|
}
|