CaiWuXiTong/tests/Unit/EcommerceFinance/ColumnarBusinessMirrorRepositoryTest.php

114 lines
5.8 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Mockery;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\ColumnarBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessDataPlacement;
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
use Modules\EcommerceFinance\Services\ClickHouseClient;
use Modules\EcommerceFinance\Services\ClickHouseSchemaManager;
use Tests\TestCase;
class ColumnarBusinessMirrorRepositoryTest extends TestCase
{
public function test_backfill_preserves_source_fields_and_adds_generation_metadata(): void
{
$dataset = $this->dataset();
$client = Mockery::mock(ClickHouseClient::class);
$client->shouldReceive('insertJsonEachRow')->once()->withArgs(
static function (string $table, array $columns, array $rows): bool {
return $table === 'fact_rows'
&& $columns === ['_mirror_key', 'Id', 'name', '_mirror_generation', '_mirror_synced_at', '_mirror_version']
&& $rows[0]['_mirror_key'] === 'key-1'
&& $rows[0]['Id'] === 1
&& $rows[0]['name'] === '记录一'
&& $rows[0]['_mirror_generation'] === '11111111-1111-4111-8111-111111111111'
&& is_int($rows[0]['_mirror_version']);
},
);
$repository = new ColumnarBusinessMirrorRepository($client);
$repository->storeBackfillBatch($dataset, [
['column_name' => 'Id', 'data_type' => 'int'],
['column_name' => 'name', 'data_type' => 'nvarchar'],
], [[
'_mirror_key' => 'key-1',
'Id' => 1,
'name' => '记录一',
'_mirror_synced_at' => '2026-09-07 12:00:00.000000',
]], '11111111-1111-4111-8111-111111111111');
}
public function test_columnar_schema_uses_replacing_merge_tree_and_sql_server_safe_types(): void
{
$dataset = $this->dataset();
$source = Mockery::mock(BusinessMirrorSchemaManager::class);
$source->shouldReceive('columns')->once()->andReturn([
['column_name' => 'Id', 'data_type' => 'int'],
['column_name' => 'flag', 'data_type' => 'tinyint'],
['column_name' => 'amount', 'data_type' => 'decimal', 'numeric_precision' => 20, 'numeric_scale' => 4],
['column_name' => 'updated_at', 'data_type' => 'datetime2'],
]);
$placements = Mockery::mock(BusinessDataPlacement::class);
$placements->shouldReceive('targetStore')->once()->with('source.schema.fact')->andReturn('clickhouse');
$client = Mockery::mock(ClickHouseClient::class);
$client->shouldReceive('identifier')->times(4)->andReturnUsing(static fn (string $name): string => '`' . $name . '`');
$client->shouldReceive('qualifiedTable')->once()->with('fact_rows')->andReturn('`analytics`.`fact_rows`');
$client->shouldReceive('scalar')->once()->withArgs(static fn (string $sql, array $parameters): bool =>
str_contains($sql, 'SELECT sorting_key FROM system.tables')
&& $parameters['database'] === 'caiwu_business_analytics'
&& $parameters['table'] === 'fact_rows'
)->andReturn('_mirror_generation, _mirror_key');
$client->shouldReceive('command')->once()->withArgs(static fn (string $sql): bool =>
str_contains($sql, '`flag` Nullable(UInt8)')
&& str_contains($sql, '`amount` Nullable(Decimal(20, 4))')
&& str_contains($sql, "`updated_at` Nullable(DateTime64(6, 'Asia/Shanghai'))")
&& str_contains($sql, 'ENGINE = ReplacingMergeTree(_mirror_version)')
&& str_contains($sql, 'PARTITION BY modulo(cityHash64(_mirror_key), 32)')
&& str_contains($sql, 'ORDER BY (_mirror_generation, _mirror_key)')
);
$columns = (new ClickHouseSchemaManager($source, $client, $placements))->ensure($dataset);
$this->assertCount(4, $columns);
}
public function test_columnar_schema_rejects_a_generation_unsafe_existing_sorting_key(): void
{
$dataset = $this->dataset();
$source = Mockery::mock(BusinessMirrorSchemaManager::class);
$source->shouldReceive('columns')->once()->andReturn([
['column_name' => 'Id', 'data_type' => 'int'],
]);
$placements = Mockery::mock(BusinessDataPlacement::class);
$placements->shouldReceive('targetStore')->once()->with('source.schema.fact')->andReturn('clickhouse');
$client = Mockery::mock(ClickHouseClient::class);
$client->shouldReceive('identifier')->once()->with('Id')->andReturn('`Id`');
$client->shouldReceive('qualifiedTable')->once()->with('fact_rows')->andReturn('`analytics`.`fact_rows`');
$client->shouldReceive('command')->once();
$client->shouldReceive('scalar')->once()->andReturn('_mirror_key');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('business_data_columnar_generation_key_mismatch');
(new ClickHouseSchemaManager($source, $client, $placements))->ensure($dataset);
}
private function dataset(): BusinessMirrorDataset
{
return new class implements BusinessMirrorDataset {
public function key(): string { return 'source.schema.fact'; }
public function sourceKey(): string { return 'source'; }
public function sourceSchema(): string { return 'schema'; }
public function sourceObject(): string { return 'fact'; }
public function targetTable(): string { return 'fact_rows'; }
public function keyColumns(): array { return ['Id']; }
public function watermarkColumn(): ?string { return 'updated_at'; }
public function fixedFilters(): array { return []; }
public function indexDefinitions(): array { return []; }
};
}
}