Files
datahub/backend/test/Cases/Integration/Sku/SkuMappingControllerTest.php
T

733 lines
26 KiB
PHP
Raw Normal View History

2026-04-14 15:45:29 +08:00
<?php
declare(strict_types=1);
namespace HyperfTest\Cases\Integration\Sku;
use App\Model\SkuMapping;
use App\Model\SkuOrigin;
use HyperfTest\TestCase;
use HyperfTest\Traits\AuthenticatedTestTrait;
/**
* SkuMappingController 集成测试
*
2026-04-17 13:57:01 +08:00
* 覆盖列表筛选、origin_sku_id 过滤、创建校验(platform_product_id 可选)、CRUD、认证拦截、
* 三元组唯一性冲突、bundled 字段 CRUD 与筛选、组合商品映射(NC-6)、origin_sku 快照自动填充(NC-7
2026-04-14 15:45:29 +08:00
*
* @internal
* @coversNothing
*/
class SkuMappingControllerTest extends TestCase
{
use AuthenticatedTestTrait;
protected function hasData(): bool
{
return $this->runInCoroutine(static function (): bool {
return SkuMapping::query()->exists();
});
}
protected function getFirstRecord(): ?array
{
return $this->runInCoroutine(static function (): ?array {
$record = SkuMapping::query()->first();
return $record ? $record->toArray() : null;
});
}
protected function getFirstOrigin(): ?array
{
return $this->runInCoroutine(static function (): ?array {
$record = SkuOrigin::query()->first();
return $record ? $record->toArray() : null;
});
}
2026-04-17 13:57:01 +08:00
protected function createTestOrigin(int $company_id): array
{
return $this->runInCoroutine(static function () use ($company_id): array {
// bin2hex(random_bytes) 比 uniqid() 子串更可靠,避免 barcode 微秒碰撞
$suffix = bin2hex(random_bytes(8));
$origin = SkuOrigin::query()->create([
'company_id' => $company_id,
'sku' => 'TESTSKU-' . $suffix,
'barcode' => 'BC-' . $suffix,
'name' => 'P21.3 Test Origin ' . $suffix,
]);
return $origin->toArray();
});
}
protected function deleteTestOrigins(array $ids): void
{
if (empty($ids)) {
return;
}
$this->runInCoroutine(static function () use ($ids): void {
SkuOrigin::query()->whereIn('id', $ids)->delete();
});
}
2026-04-14 15:45:29 +08:00
// ========== 列表接口 ==========
public function test_list_returns_paginated_data(): void
{
$response = $this->get('/api/v1/sku-mappings', [], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
$response->assertJsonStructure([
'code',
'message',
'data' => [
'items',
'total',
'page',
'per_page',
],
]);
}
public function test_list_filter_by_origin_sku_id(): void
{
if (!$this->hasData()) {
$this->markTestSkipped('没有 SKU Mapping 数据');
}
$first = $this->getFirstRecord();
if (!$first['origin_sku_id']) {
$this->markTestSkipped('首条记录无 origin_sku_id');
}
$response = $this->get('/api/v1/sku-mappings', [
'origin_sku_id' => $first['origin_sku_id'],
], $this->authHeaders());
$response->assertStatus(200);
$body = json_decode($response->getContent(), true);
foreach ($body['data']['items'] as $item) {
$this->assertSame($first['origin_sku_id'], $item['origin_sku_id']);
}
}
public function test_list_filter_by_company_and_platform(): void
{
if (!$this->hasData()) {
$this->markTestSkipped('没有 SKU Mapping 数据');
}
$first = $this->getFirstRecord();
$response = $this->get('/api/v1/sku-mappings', [
'company_id' => $first['company_id'],
'platform_id' => $first['platform_id'],
], $this->authHeaders());
$response->assertStatus(200);
$body = json_decode($response->getContent(), true);
foreach ($body['data']['items'] as $item) {
$this->assertSame($first['company_id'], $item['company_id']);
$this->assertSame($first['platform_id'], $item['platform_id']);
}
}
public function test_list_filter_by_enabled(): void
{
$response = $this->get('/api/v1/sku-mappings', ['enabled' => 'true'], $this->authHeaders());
$response->assertStatus(200);
$body = json_decode($response->getContent(), true);
foreach ($body['data']['items'] as $item) {
$this->assertTrue($item['enabled']);
}
}
// ========== 创建校验 ==========
public function test_create_without_platform_product_id_succeeds(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku' => $origin['sku'],
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'TEST-' . uniqid(),
], $this->authHeaders());
$response->assertStatus(201);
$response->assertJsonPath('code', 0);
// 清理测试数据
$body = json_decode($response->getContent(), true);
if (isset($body['data']['id'])) {
$this->runInCoroutine(static function () use ($body): void {
SkuMapping::query()->where('id', $body['data']['id'])->delete();
});
}
}
public function test_create_with_platform_product_id_succeeds(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku' => $origin['sku'],
'origin_sku_id' => $origin['id'],
'platform_product_id' => 'PROD-TEST-' . uniqid(),
'platform_outer_sku' => 'TEST-' . uniqid(),
], $this->authHeaders());
$response->assertStatus(201);
$response->assertJsonPath('code', 0);
// 清理测试数据
$body = json_decode($response->getContent(), true);
if (isset($body['data']['id'])) {
$this->runInCoroutine(static function () use ($body): void {
SkuMapping::query()->where('id', $body['data']['id'])->delete();
});
}
}
public function test_create_requires_company_id(): void
{
$response = $this->post('/api/v1/sku-mappings', [
'platform_id' => 1,
2026-04-17 09:43:16 +08:00
'origin_sku_id' => 1,
'platform_outer_sku' => 'TEST-SKU',
2026-04-14 15:45:29 +08:00
], $this->authHeaders());
$response->assertStatus(422);
2026-04-17 13:57:01 +08:00
$response->assertJsonPath('message', '缺少必填字段: company_id');
2026-04-14 15:45:29 +08:00
}
public function test_create_requires_platform_id(): void
{
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => 1,
2026-04-17 09:43:16 +08:00
'origin_sku_id' => 1,
'platform_outer_sku' => 'TEST-SKU',
2026-04-14 15:45:29 +08:00
], $this->authHeaders());
$response->assertStatus(422);
2026-04-17 13:57:01 +08:00
$response->assertJsonPath('message', '缺少必填字段: platform_id');
2026-04-14 15:45:29 +08:00
}
2026-04-17 09:43:16 +08:00
public function test_create_requires_origin_sku_id(): void
2026-04-14 15:45:29 +08:00
{
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => 1,
'platform_id' => 1,
2026-04-17 09:43:16 +08:00
'platform_outer_sku' => 'TEST-SKU',
], $this->authHeaders());
$response->assertStatus(422);
2026-04-17 13:57:01 +08:00
$response->assertJsonPath('message', '缺少必填字段: origin_sku_id');
2026-04-17 09:43:16 +08:00
}
public function test_create_requires_platform_outer_sku(): void
{
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => 1,
'platform_id' => 1,
'origin_sku_id' => 1,
2026-04-14 15:45:29 +08:00
], $this->authHeaders());
$response->assertStatus(422);
2026-04-17 13:57:01 +08:00
$response->assertJsonPath('message', '缺少必填字段: platform_outer_sku');
2026-04-14 15:45:29 +08:00
}
// ========== 详情接口 ==========
public function test_detail_not_found_returns_404(): void
{
$response = $this->get('/api/v1/sku-mappings/999999999', [], $this->authHeaders());
$response->assertStatus(404);
$response->assertJsonPath('code', 404);
}
// ========== 更新 & 删除 ==========
public function test_update_mapping(): void
{
if (!$this->hasData()) {
$this->markTestSkipped('没有 SKU Mapping 数据');
}
$first = $this->getFirstRecord();
$response = $this->put('/api/v1/sku-mappings/' . $first['id'], [
'note' => 'integration-test-update-' . uniqid(),
], $this->authHeaders());
$response->assertStatus(200);
$response->assertJsonPath('code', 0);
}
public function test_delete_mapping(): void
{
// 创建一条临时记录然后删除
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$createResponse = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku' => $origin['sku'],
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'DELETE-TEST-' . uniqid(),
], $this->authHeaders());
$createResponse->assertStatus(201);
$body = json_decode($createResponse->getContent(), true);
$newId = $body['data']['id'];
$deleteResponse = $this->delete('/api/v1/sku-mappings/' . $newId, [], $this->authHeaders());
$deleteResponse->assertStatus(200);
$deleteResponse->assertJsonPath('code', 0);
}
2026-04-17 13:57:01 +08:00
// ========== 三元组唯一性冲突(P21.3 ==========
public function test_store_duplicate_mapping_returns_422(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$platform_outer_sku = 'DUP-TEST-' . uniqid();
$created_id = null;
try {
$first_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $platform_outer_sku,
], $this->authHeaders());
$first_response->assertStatus(201);
$first_body = json_decode($first_response->getContent(), true);
$created_id = $first_body['data']['id'];
$dup_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $platform_outer_sku,
], $this->authHeaders());
$dup_response->assertStatus(422);
$dup_response->assertJsonPath(
'message',
'该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku',
);
} finally {
if ($created_id !== null) {
$this->runInCoroutine(static function () use ($created_id): void {
SkuMapping::query()->where('id', $created_id)->delete();
});
}
}
}
public function test_update_to_duplicate_mapping_returns_422(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$suffix = uniqid();
$a_sku = 'AAA-' . $suffix;
$b_sku = 'BBB-' . $suffix;
$a_id = null;
$b_id = null;
try {
$a_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $a_sku,
], $this->authHeaders());
$a_response->assertStatus(201);
$a_id = json_decode($a_response->getContent(), true)['data']['id'];
$b_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $b_sku,
], $this->authHeaders());
$b_response->assertStatus(201);
$b_id = json_decode($b_response->getContent(), true)['data']['id'];
// 把 B 改成 A 的 platform_outer_sku 触发三元组冲突
$update_response = $this->put('/api/v1/sku-mappings/' . $b_id, [
'platform_outer_sku' => $a_sku,
], $this->authHeaders());
$update_response->assertStatus(422);
$update_response->assertJsonPath(
'message',
'该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku',
);
} finally {
$this->runInCoroutine(static function () use ($a_id, $b_id): void {
if ($a_id !== null) {
SkuMapping::query()->where('id', $a_id)->delete();
}
if ($b_id !== null) {
SkuMapping::query()->where('id', $b_id)->delete();
}
});
}
}
// ========== bundled 字段 CRUDP21.3 ==========
public function test_store_with_bundled_true(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'BUNDLED-T-' . uniqid(),
'bundled' => true,
], $this->authHeaders());
$response->assertStatus(201);
$response->assertJsonPath('data.bundled', true);
$body = json_decode($response->getContent(), true);
$new_id = $body['data']['id'];
$this->runInCoroutine(static function () use ($new_id): void {
SkuMapping::query()->where('id', $new_id)->delete();
});
}
public function test_store_defaults_bundled_false(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'BUNDLED-F-' . uniqid(),
], $this->authHeaders());
$response->assertStatus(201);
$response->assertJsonPath('data.bundled', false);
$body = json_decode($response->getContent(), true);
$new_id = $body['data']['id'];
$this->runInCoroutine(static function () use ($new_id): void {
SkuMapping::query()->where('id', $new_id)->delete();
});
}
public function test_update_bundled_field(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$created_id = null;
try {
$create_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'BUNDLED-UP-' . uniqid(),
], $this->authHeaders());
$create_response->assertStatus(201);
$created_id = json_decode($create_response->getContent(), true)['data']['id'];
$update_response = $this->put('/api/v1/sku-mappings/' . $created_id, [
'bundled' => true,
], $this->authHeaders());
$update_response->assertStatus(200);
$detail_response = $this->get('/api/v1/sku-mappings/' . $created_id, [], $this->authHeaders());
$detail_response->assertStatus(200);
$detail_response->assertJsonPath('data.bundled', true);
} finally {
if ($created_id !== null) {
$this->runInCoroutine(static function () use ($created_id): void {
SkuMapping::query()->where('id', $created_id)->delete();
});
}
}
}
public function test_index_filter_by_bundled(): void
{
// 自建 bundled=true / false 两条记录,避免依赖既有数据量导致空 items 静默通过
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$suffix = uniqid();
$true_id = null;
$false_id = null;
try {
$true_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'FILTER-T-' . $suffix,
'bundled' => true,
], $this->authHeaders());
$true_response->assertStatus(201);
$true_id = json_decode($true_response->getContent(), true)['data']['id'];
$false_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => 'FILTER-F-' . $suffix,
'bundled' => false,
], $this->authHeaders());
$false_response->assertStatus(201);
$false_id = json_decode($false_response->getContent(), true)['data']['id'];
$list_true = $this->get('/api/v1/sku-mappings', ['bundled' => 'true', 'per_page' => 100], $this->authHeaders());
$list_true->assertStatus(200);
$body_true = json_decode($list_true->getContent(), true);
$this->assertNotEmpty($body_true['data']['items'], 'bundled=true 筛选应至少命中新建的种子记录');
foreach ($body_true['data']['items'] as $item) {
$this->assertTrue($item['bundled'], "bundled=true 筛选却返回 bundled=false 的记录 id={$item['id']}");
}
$list_false = $this->get('/api/v1/sku-mappings', ['bundled' => 'false', 'per_page' => 100], $this->authHeaders());
$list_false->assertStatus(200);
$body_false = json_decode($list_false->getContent(), true);
$this->assertNotEmpty($body_false['data']['items'], 'bundled=false 筛选应至少命中新建的种子记录');
foreach ($body_false['data']['items'] as $item) {
$this->assertFalse($item['bundled'], "bundled=false 筛选却返回 bundled=true 的记录 id={$item['id']}");
}
} finally {
$this->runInCoroutine(static function () use ($true_id, $false_id): void {
foreach ([$true_id, $false_id] as $id) {
if ($id !== null) {
SkuMapping::query()->where('id', $id)->delete();
}
}
});
}
}
// ========== 组合商品场景(P21.3, NC-6 ==========
public function test_bundle_multiple_origin_skus_to_same_platform_outer_sku(): void
{
$base_origin = $this->getFirstOrigin();
if (!$base_origin) {
$this->markTestSkipped('没有 SKU Origin 基线数据');
}
$company_id = (int) $base_origin['company_id'];
$extra_origin_ids = [];
$created_mapping_ids = [];
try {
// createTestOrigin 的任一次调用异常也能被 finally 清理已创建的 origin
$extra_1 = $this->createTestOrigin($company_id);
$extra_origin_ids[] = $extra_1['id'];
$extra_2 = $this->createTestOrigin($company_id);
$extra_origin_ids[] = $extra_2['id'];
$origins = [$base_origin, $extra_1, $extra_2];
$platform_outer_sku = 'BUNDLE-SHARED-' . uniqid();
foreach ($origins as $origin) {
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $platform_outer_sku,
'bundled' => true,
], $this->authHeaders());
$response->assertStatus(201);
$body = json_decode($response->getContent(), true);
$this->assertSame($platform_outer_sku, $body['data']['platform_outer_sku']);
$this->assertTrue($body['data']['bundled']);
$created_mapping_ids[] = $body['data']['id'];
}
$this->assertCount(3, array_unique($created_mapping_ids));
} finally {
if (!empty($created_mapping_ids)) {
$this->runInCoroutine(static function () use ($created_mapping_ids): void {
SkuMapping::query()->whereIn('id', $created_mapping_ids)->delete();
});
}
$this->deleteTestOrigins($extra_origin_ids);
}
}
public function test_bundle_mapping_unique_constraint_per_origin_sku(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$platform_outer_sku = 'BUNDLE-DUP-' . uniqid();
$created_id = null;
try {
$first_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $platform_outer_sku,
'bundled' => true,
], $this->authHeaders());
$first_response->assertStatus(201);
$first_body = json_decode($first_response->getContent(), true);
$created_id = $first_body['data']['id'];
// 额外断言首次 bundled=true 被正确持久化,与 test_store_duplicate_mapping 区分
$this->assertTrue($first_body['data']['bundled']);
// bundled=true 不应绕过三元组唯一索引
$dup_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'platform_outer_sku' => $platform_outer_sku,
'bundled' => true,
], $this->authHeaders());
$dup_response->assertStatus(422);
$dup_response->assertJsonPath(
'message',
'该映射组合已存在(origin_sku_id + platform_id + platform_outer_sku',
);
} finally {
if ($created_id !== null) {
$this->runInCoroutine(static function () use ($created_id): void {
SkuMapping::query()->where('id', $created_id)->delete();
});
}
}
}
// ========== origin_sku 快照自动填充(P21.3, NC-7 ==========
public function test_store_auto_fills_origin_sku_from_sku_origin(): void
{
$origin = $this->getFirstOrigin();
if (!$origin) {
$this->markTestSkipped('没有 SKU Origin 数据');
}
$created_id = null;
try {
// 客户端传入的 origin_sku 必须被服务端的快照覆盖,而非尊重请求体值
$response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin['id'],
'origin_sku' => 'CLIENT-SHOULD-NOT-WIN-' . uniqid(),
'platform_outer_sku' => 'SNAPSHOT-' . uniqid(),
], $this->authHeaders());
$response->assertStatus(201);
$response->assertJsonPath('data.origin_sku', $origin['sku']);
$created_id = json_decode($response->getContent(), true)['data']['id'];
} finally {
if ($created_id !== null) {
$this->runInCoroutine(static function () use ($created_id): void {
SkuMapping::query()->where('id', $created_id)->delete();
});
}
}
}
public function test_update_origin_sku_id_refreshes_origin_sku_snapshot(): void
{
$origin_a = $this->getFirstOrigin();
if (!$origin_a) {
$this->markTestSkipped('没有 SKU Origin 基线数据');
}
$origin_b = null;
$mapping_id = null;
try {
$origin_b = $this->createTestOrigin((int) $origin_a['company_id']);
$create_response = $this->post('/api/v1/sku-mappings', [
'company_id' => $origin_a['company_id'],
'platform_id' => 1,
'origin_sku_id' => $origin_a['id'],
'platform_outer_sku' => 'SNAPSHOT-UP-' . uniqid(),
], $this->authHeaders());
$create_response->assertStatus(201);
$create_body = json_decode($create_response->getContent(), true);
$mapping_id = $create_body['data']['id'];
$this->assertSame($origin_a['sku'], $create_body['data']['origin_sku']);
$update_response = $this->put('/api/v1/sku-mappings/' . $mapping_id, [
'origin_sku_id' => $origin_b['id'],
], $this->authHeaders());
$update_response->assertStatus(200);
$update_response->assertJsonPath('data.origin_sku', $origin_b['sku']);
$update_response->assertJsonPath('data.origin_sku_id', $origin_b['id']);
} finally {
if ($mapping_id !== null) {
$this->runInCoroutine(static function () use ($mapping_id): void {
SkuMapping::query()->where('id', $mapping_id)->delete();
});
}
if ($origin_b !== null) {
$this->deleteTestOrigins([$origin_b['id']]);
}
}
}
2026-04-14 15:45:29 +08:00
// ========== 认证拦截 ==========
public function test_list_without_token_returns_401(): void
{
$response = $this->get('/api/v1/sku-mappings');
$response->assertStatus(401);
}
}