user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } public function test_page_renders_mirror_rows_second_page_and_filters(): void { $this->grantAdminPermission(); setting()->set('default.list_limit', 25); $states = [ NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], ]; $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ 'code' => 'syncing', 'last_succeeded_at' => '2026-08-21 12:00:00', ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters === [ 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', 'waybill_no' => 'WB-2002', 'shop_name' => '测试店', 'sku_id' => 'SKU-1', ] && $page === 2 && $limit === 25 )->andReturn([ 'data' => [(object) [ 'Id' => 102, 'refundDate' => '2026-08-20 10:00:00.123456', 'as_id' => 2002, 'outer_as_id' => 'AS-2002', 'waybillNo' => 'WB-2002', 'o_id' => 3002, 'so_id' => 'ORDER-1', 'type' => '退款', 'node' => '完成', 'isZC' => '是', 'shop_name' => '测试店', 'platform' => '天猫', 'responsibleDepartment' => '电商部', 'totalRefundMoney' => 99.5, 'originPostcode' => 'ZIP', 'shop_i_id' => 'ITEM-1', 'shop_sku_id' => 'SHOP-SKU-1', 'i_id' => 'STYLE-1', 'sku_id' => 'SKU-1', 'buyer_paid_amount' => 120, 'qty' => 1, 'unitCostGoods' => 50, 'detailRefundMoney' => 99.5, 'refundType' => '退货退款', 'refundQty' => 1, 'refundCostGoods' => 50, 'RefundMoneyIncome' => 99.5, 'AfSalesMoney' => 99.5, 'createTime' => '2026-08-20 09:00:00', 'status' => '完成', 'shop_status' => '完成', 'created' => '2026-08-20 09:00:00', 'confirm_date' => '2026-08-20 10:00:00', ]], 'total' => 26, ]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $url = route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ]) . '?' . http_build_query([ 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', 'waybill_no' => 'WB-2002', 'shop_name' => '测试店', 'sku_id' => 'SKU-1', 'page' => 2, ]); $response = $this->loginAs()->get($url); $response ->assertOk() ->assertSee('data-filter-contract="non-jingxi-refund-data.filters"', false) ->assertSee('data-sync-state="syncing"', false) ->assertSeeText('业务镜像同步中,当前显示最近成功数据') ->assertSeeText('AS-2002') ->assertSeeText('WB-2002') ->assertSeeText('2026-08-20 10:00:00') ->assertDontSee('2026-08-20 10:00:00.123456', false) ->assertSeeText('业务来源') ->assertSeeText('商品明细') ->assertSeeText('SKU-1') ->assertSeeText('99.50') ->assertSeeText('共 26 条记录') ->assertSeeText('第 2 / 2 页') ->assertSeeText('导出筛选结果') ->assertSee('workspace/refunds/non-jingxi-refund-data/export', false) ->assertSee('data-finance-export-base=', false) ->assertSee('data-export-field="refundDate"', false) ->assertSee('data-date-picker', false) ->assertSee('data-date-calendar', false) ->assertSee('data-date-toggle', false) ->assertSee('CaiWuDatePicker.setup(form)', false) ->assertSee('value="2026-08-01"', false) ->assertSee('value="ORDER-1"', false) ->assertSee('name="waybill_no"', false) ->assertSee('value="WB-2002"', false); $this->assertSame(1, preg_match('/data-finance-export-query="([^"]+)"/', $response->getContent(), $exportQuery)); $this->assertSame([ 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', 'waybill_no' => 'WB-2002', 'shop_name' => '测试店', 'sku_id' => 'SKU-1', ], json_decode(html_entity_decode($exportQuery[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'), true, 512, JSON_THROW_ON_ERROR)); } public function test_refund_export_downloads_filtered_csv(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('exportRows')->once()->with([ 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', 'waybill_no' => 'WB-2002', 'shop_name' => '测试店', 'sku_id' => 'SKU-1', ])->andReturn([(object) [ 'Id' => 102, 'refundDate' => '2026-08-20 10:00:00.123456', 'as_id' => 2002, 'outer_as_id' => 'AS-2002', 'waybillNo' => 'WB-2002', 'o_id' => 3002, 'so_id' => 'ORDER-1', 'type' => '退款', 'node' => '完成', 'isZC' => '是', 'shop_name' => '测试店', 'platform' => '天猫', 'responsibleDepartment' => '电商部', 'totalRefundMoney' => 99.5, 'originPostcode' => 'ZIP', 'shop_i_id' => 'ITEM-1', 'shop_sku_id' => 'SHOP-SKU-1', 'i_id' => 'STYLE-1', 'sku_id' => 'SKU-1', 'buyer_paid_amount' => 120, 'qty' => 1, 'unitCostGoods' => 50, 'detailRefundMoney' => 99.5, 'refundType' => '退货退款', 'refundQty' => 1, 'refundCostGoods' => 50, 'RefundMoneyIncome' => 99.5, 'AfSalesMoney' => 99.5, 'createTime' => '2026-08-20 09:00:00', 'status' => '完成', 'shop_status' => '完成', 'created' => '2026-08-20 09:00:00', 'confirm_date' => '2026-08-20 10:00:00', ]]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [ 'from' => '2026-08-01', 'to' => '2026-08-21', 'order_no' => 'ORDER-1', 'waybill_no' => 'WB-2002', 'shop_name' => '测试店', 'sku_id' => 'SKU-1', ])); $response->assertOk()->assertDownload('after_sales_refunds_2026-08-01_2026-08-21.csv'); $content = $response->streamedContent(); $this->assertStringStartsWith("\xEF\xBB\xBF", $content); $this->assertStringContainsString('序号/主键ID', $content); $this->assertStringContainsString('AS-2002', $content); $this->assertStringContainsString('WB-2002', $content); $this->assertStringContainsString('2026-08-20 10:00:00', $content); $this->assertStringNotContainsString('.123456', $content); $this->assertStringContainsString('99.5', $content); } public function test_refund_export_accepts_only_declared_columns_in_requested_order(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('exportRows')->once()->andReturn([(object) [ 'shop_name' => '测试店', 'refundDate' => '2026-08-20', 'sku_id' => 'SKU-1', ]]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [ 'columns' => 'shop_name,refundDate,not_a_real_field', ])); $response->assertOk(); $content = $response->streamedContent(); $header = strtok($content, "\r\n"); $this->assertSame("\xEF\xBB\xBF店铺名称,退款日期", $header); $lines = preg_split('/\r?\n/', trim($content)); $this->assertSame(['测试店', '2026-08-20 00:00:00'], str_getcsv($lines[1])); $this->assertStringNotContainsString('SKU编码', $content); } public function test_page_removes_dates_before_2026_without_changing_other_filters(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available', 'ready', 'queryReady')->andReturnTrue(); $repository->shouldReceive('states')->andReturn([]); $repository->shouldReceive('syncStatus')->andReturn(['code' => 'healthy', 'last_succeeded_at' => null]); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters): bool => $filters['from'] === '2026-01-01' && $filters['to'] === '2026-08-21' && $filters['shop_name'] === '测试店' )->andReturn(['data' => [], 'total' => 0]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', 'from' => '2025-01-01', 'to' => '2026-08-21', 'shop_name' => '测试店', ]))->assertOk() ->assertSee('data-date-picker', false) ->assertSee('value="2026-01-01"', false); } public function test_export_clamps_the_date_and_sends_headers_before_consuming_all_rows(): void { $this->grantAdminPermission(); $finished = false; $rows = (static function () use (&$finished): \Generator { yield (object) ['refundDate' => '2026-01-01 00:00:00']; yield (object) ['refundDate' => '2026-01-02 00:00:00']; $finished = true; })(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available', 'ready', 'queryReady')->andReturnTrue(); $repository->shouldReceive('exportRows')->once()->withArgs( static fn (array $filters): bool => $filters['from'] === '2026-01-01' && $filters['to'] === '2026-01-02' )->andReturn($rows); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [ 'from' => '2025-12-01', 'to' => '2026-01-02', 'columns' => 'refundDate', ])); $response->assertOk()->assertDownload('after_sales_refunds_2026-01-01_2026-01-02.csv') ->assertHeader('X-Accel-Buffering', 'no'); $this->assertFalse($finished); $content = $response->streamedContent(); $this->assertTrue($finished); $this->assertStringContainsString('2026-01-01 00:00:00', $content); $this->assertStringContainsString('2026-01-02 00:00:00', $content); } public function test_export_with_only_old_dates_does_not_expand_into_the_retained_range(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available', 'ready', 'queryReady')->andReturnTrue(); $repository->shouldReceive('exportRows')->once()->withArgs( static fn (array $filters): bool => $filters['from'] === '2026-01-01' && $filters['to'] === '2025-12-31' )->andReturn([]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export', [ 'from' => '2025-12-31', 'to' => '2025-01-01', 'columns' => 'refundDate', ])); $response->assertOk(); $this->assertSame("\xEF\xBB\xBF退款日期\n", $response->streamedContent()); } public function test_export_reports_first_batch_failure_before_starting_download(): void { $this->grantAdminPermission(); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available', 'ready', 'queryReady')->andReturnTrue(); $repository->shouldReceive('exportRows')->andReturn((static function (): \Generator { throw new \RuntimeException('refund_export_test_failure'); yield; })()); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs()->get(route('business.refunds.non-jingxi-refund-data.export')) ->assertRedirect()->assertSessionHas('error', '售后(退货退款)导出暂时无法完成,请稍后重试。'); } public function test_page_does_not_report_empty_data_before_initial_sync_finishes(): void { $this->grantAdminPermission(); $states = [ NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running'], NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running'], ]; $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ 'code' => 'initializing', 'last_succeeded_at' => null, ]); $repository->shouldReceive('ready')->once()->andReturnFalse(); $repository->shouldNotReceive('queryReady'); $repository->shouldNotReceive('paginate'); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ])) ->assertOk() ->assertSeeText('业务镜像正在执行首次同步') ->assertSeeText('首次同步尚未完成,完成后将自动显示数据。') ->assertDontSeeText('当前筛选范围暂无数据。'); } public function test_page_shows_a_real_empty_filter_result(): void { $this->grantAdminPermission(); $states = [ NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], ]; $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->andReturn(['code' => 'healthy', 'last_succeeded_at' => '2026-08-21 12:00:00']); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->andReturn(['data' => [], 'total' => 0]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', 'sku_id' => 'NOT-FOUND', ])) ->assertOk() ->assertSeeText('当前筛选范围暂无数据。') ->assertSeeText('共 0 条记录') ->assertSee('is-disabled', false) ->assertSeeText('导出筛选结果'); } public function test_page_does_not_run_the_query_before_required_indexes_exist(): void { $this->grantAdminPermission(); $states = [ NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'running', 'initial_completed_at' => '2026-08-15 00:00:00'], ]; $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ 'code' => 'syncing', 'last_succeeded_at' => '2026-08-17 10:05:07', ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnFalse(); $repository->shouldNotReceive('paginate'); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs(); $url = route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ]).'?page=2'; $this->get($url) ->assertOk() ->assertSee('upstream-data-alert--warning', false) ->assertSeeText('页面查询索引正在准备,生产同步轮转完成后将自动显示数据。') ->assertSeeText('页面查询索引尚未就绪,当前不会执行高压力查询。') ->assertSeeText('第 1 / 1 页') ->assertDontSeeText('第 2 / 1 页') ->assertDontSeeText('当前筛选范围暂无数据。'); } public function test_page_replaces_invalid_dates_with_the_default_range(): void { $this->grantAdminPermission(); $this->travelTo('2026-08-21 12:00:00'); $states = [ NonJingxiRefundMirrorRepository::INFO_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], NonJingxiRefundMirrorRepository::ITEM_DATASET => ['status' => 'succeeded', 'initial_completed_at' => '2026-08-15 00:00:00'], ]; $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('states')->once()->andReturn($states); $repository->shouldReceive('syncStatus')->once()->with($states)->andReturn([ 'code' => 'healthy', 'last_succeeded_at' => '2026-08-21 12:00:00', ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('queryReady')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters['from'] === '2026-08-15' && $filters['to'] === '2026-08-21' && $page === 1 && $limit === 25 )->andReturn(['data' => [], 'total' => 0]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', 'from' => '2026-02-31', 'to' => 'not-a-date', ])) ->assertOk() ->assertSee('value="2026-08-15"', false) ->assertSee('value="2026-08-21"', false) ->assertDontSee('非京喜退款数据暂时无法读取,请稍后重试。'); } public function test_page_rejects_users_without_sidebar_permission(): void { $subject = 'user_' . $this->user->id; $this->user->roles()->sync([\App\Models\Auth\Role::where('name', 'admin')->firstOrFail()->id]); setting()->set('system.sidebar_permissions', json_encode([ 'configured' => [$subject], 'subjects' => [ $subject => ['company-management/basic-information'], ], ], JSON_UNESCAPED_UNICODE)); $this->withExceptionHandling()->loginAs()->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ]))->assertForbidden(); } }