ver. 0.302: REST API product variants, attributes dictionary, attribute filtering

- Add variant CRUD endpoints (variants, create_variant, update_variant, delete_variant)
- Add dictionaries/attributes endpoint with multilingual names and values
- Add attribute_* filter params for product list filtering by attribute values
- Enrich product detail attributes with translated names (attribute_names, value_names)
- Include variants array in product detail response for parent products
- Add price_brutto validation on product create
- Batch-load attribute/value translations (4 queries instead of N+1)
- Add 43 new unit tests (730 total, 2066 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 14:42:52 +01:00
parent c0cdaaf638
commit 1fc36e4403
18 changed files with 1721 additions and 22 deletions

View File

@@ -359,4 +359,81 @@ class AttributeRepositoryTest extends TestCase
return false;
}
// --- listForApi ---
public function testListForApiReturnsActiveAttributesWithValues(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')
->willReturnCallback(function ($table, $columns, $where) {
if ($table === 'pp_shop_attributes') {
return [
['id' => '5', 'type' => '0', 'status' => '1'],
];
}
if ($table === 'pp_shop_attributes_langs') {
return [
['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar'],
['attribute_id' => '5', 'lang_id' => 'en', 'name' => 'Size'],
];
}
if ($table === 'pp_shop_attributes_values') {
return [
['id' => '12', 'attribute_id' => '5', 'is_default' => '1', 'impact_on_the_price' => null],
['id' => '13', 'attribute_id' => '5', 'is_default' => '0', 'impact_on_the_price' => '10.00'],
];
}
if ($table === 'pp_shop_attributes_values_langs') {
return [
['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M'],
['value_id' => '12', 'lang_id' => 'en', 'name' => 'M'],
['value_id' => '13', 'lang_id' => 'pl', 'name' => 'L'],
['value_id' => '13', 'lang_id' => 'en', 'name' => 'L'],
];
}
if ($table === 'pp_langs') {
return [['id' => 'pl', 'start' => 1, 'o' => 1]];
}
return [];
});
$repository = new AttributeRepository($mockDb);
$result = $repository->listForApi();
$this->assertCount(1, $result);
$this->assertSame(5, $result[0]['id']);
$this->assertSame(0, $result[0]['type']);
$this->assertSame(1, $result[0]['status']);
$this->assertSame('Rozmiar', $result[0]['names']['pl']);
$this->assertSame('Size', $result[0]['names']['en']);
$this->assertCount(2, $result[0]['values']);
$this->assertSame(12, $result[0]['values'][0]['id']);
$this->assertSame(1, $result[0]['values'][0]['is_default']);
$this->assertNull($result[0]['values'][0]['impact_on_the_price']);
$this->assertSame(13, $result[0]['values'][1]['id']);
$this->assertSame(10.0, $result[0]['values'][1]['impact_on_the_price']);
}
public function testListForApiReturnsEmptyWhenNoAttributes(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('select')
->willReturnCallback(function ($table) {
if ($table === 'pp_shop_attributes') {
return [];
}
if ($table === 'pp_langs') {
return [['id' => 'pl', 'start' => 1, 'o' => 1]];
}
return [];
});
$repository = new AttributeRepository($mockDb);
$result = $repository->listForApi();
$this->assertSame([], $result);
}
}

View File

@@ -877,4 +877,419 @@ class ProductRepositoryTest extends TestCase
$this->assertEquals([], $repository->promotedProductIdsCached(6));
}
// --- findVariantsForApi ---
public function testFindVariantsForApiReturnsVariants(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockStmt = new class {
public function fetchAll($mode = null): array
{
return [
[
'id' => '101',
'permutation_hash' => '5-12|7-18',
'sku' => 'SKU-M-RED',
'ean' => null,
'price_brutto' => '109.99',
'price_brutto_promo' => null,
'price_netto' => '89.42',
'price_netto_promo' => null,
'quantity' => '5',
'stock_0_buy' => '0',
'weight' => null,
'status' => '1',
],
];
}
};
$mockDb->method('query')->willReturn($mockStmt);
$mockDb->method('select')->willReturnCallback(function ($table, $columns, $where) {
if ($table === 'pp_shop_products_attributes') {
return [
['product_id' => '101', 'attribute_id' => '5', 'value_id' => '12'],
['product_id' => '101', 'attribute_id' => '7', 'value_id' => '18'],
];
}
if ($table === 'pp_shop_attributes_langs') {
return [
['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar'],
['attribute_id' => '7', 'lang_id' => 'pl', 'name' => 'Kolor'],
];
}
if ($table === 'pp_shop_attributes_values_langs') {
return [
['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M'],
['value_id' => '18', 'lang_id' => 'pl', 'name' => 'Czerwony'],
];
}
return [];
});
$mockDb->method('get')->willReturn(0);
$repository = new ProductRepository($mockDb);
$result = $repository->findVariantsForApi(1);
$this->assertCount(1, $result);
$this->assertSame(101, $result[0]['id']);
$this->assertSame('5-12|7-18', $result[0]['permutation_hash']);
$this->assertSame('SKU-M-RED', $result[0]['sku']);
$this->assertSame(109.99, $result[0]['price_brutto']);
$this->assertSame(5, $result[0]['quantity']);
$this->assertCount(2, $result[0]['attributes']);
}
public function testFindVariantsForApiReturnsEmptyWhenNoVariants(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockStmt = new class {
public function fetchAll($mode = null): array
{
return [];
}
};
$mockDb->method('query')->willReturn($mockStmt);
$repository = new ProductRepository($mockDb);
$result = $repository->findVariantsForApi(1);
$this->assertSame([], $result);
}
// --- findVariantForApi ---
public function testFindVariantForApiReturnsVariant(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturnCallback(function ($table, $columns, $where) {
if ($table === 'pp_shop_products') {
return [
'id' => '101',
'parent_id' => '1',
'permutation_hash' => '5-12',
'sku' => 'SKU-M',
'ean' => null,
'price_brutto' => '99.99',
'price_brutto_promo' => null,
'price_netto' => '81.29',
'price_netto_promo' => null,
'quantity' => '10',
'stock_0_buy' => '0',
'weight' => null,
'status' => '1',
];
}
return null;
});
$mockDb->method('select')->willReturnCallback(function ($table) {
if ($table === 'pp_shop_products_attributes') {
return [['attribute_id' => '5', 'value_id' => '12']];
}
if ($table === 'pp_shop_attributes_langs') {
return [['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar']];
}
if ($table === 'pp_shop_attributes_values_langs') {
return [['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M']];
}
return [];
});
$repository = new ProductRepository($mockDb);
$result = $repository->findVariantForApi(101);
$this->assertNotNull($result);
$this->assertSame(101, $result['id']);
$this->assertSame(1, $result['parent_id']);
$this->assertSame('5-12', $result['permutation_hash']);
$this->assertSame(99.99, $result['price_brutto']);
$this->assertCount(1, $result['attributes']);
}
public function testFindVariantForApiReturnsNullForNonVariant(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1',
'parent_id' => null,
'permutation_hash' => '',
]);
$repository = new ProductRepository($mockDb);
$result = $repository->findVariantForApi(1);
$this->assertNull($result);
}
public function testFindVariantForApiReturnsNullForNonexistent(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn(null);
$repository = new ProductRepository($mockDb);
$result = $repository->findVariantForApi(999);
$this->assertNull($result);
}
// --- createVariantForApi ---
public function testCreateVariantForApiSuccess(): void
{
$mockDb = $this->createMock(\medoo::class);
$callCount = 0;
$mockDb->method('get')->willReturnCallback(function ($table, $columns, $where) use (&$callCount) {
$callCount++;
if ($callCount === 1) {
// Parent exists
return ['id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23'];
}
return null;
});
$mockDb->method('count')->willReturn(0);
$mockDb->method('insert')->willReturn(true);
$mockDb->method('id')->willReturn(101);
$repository = new ProductRepository($mockDb);
$result = $repository->createVariantForApi(1, [
'attributes' => [5 => 12, 7 => 18],
'sku' => 'SKU-M-RED',
'price_brutto' => 109.99,
'quantity' => 5,
]);
$this->assertNotNull($result);
$this->assertSame(101, $result['id']);
$this->assertSame('5-12|7-18', $result['permutation_hash']);
}
public function testCreateVariantForApiReturnsNullForArchivedParent(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1', 'archive' => '1', 'parent_id' => null, 'vat' => '23',
]);
$repository = new ProductRepository($mockDb);
$result = $repository->createVariantForApi(1, [
'attributes' => [5 => 12],
]);
$this->assertNull($result);
}
public function testCreateVariantForApiReturnsNullWhenParentIsVariant(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '101', 'archive' => '0', 'parent_id' => '1', 'vat' => '23',
]);
$repository = new ProductRepository($mockDb);
$result = $repository->createVariantForApi(101, [
'attributes' => [5 => 12],
]);
$this->assertNull($result);
}
public function testCreateVariantForApiReturnsNullForEmptyAttributes(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23',
]);
$repository = new ProductRepository($mockDb);
$result = $repository->createVariantForApi(1, ['attributes' => []]);
$this->assertNull($result);
}
public function testCreateVariantForApiReturnsNullForDuplicateHash(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23',
]);
$mockDb->method('count')->willReturn(1);
$repository = new ProductRepository($mockDb);
$result = $repository->createVariantForApi(1, [
'attributes' => [5 => 12],
]);
$this->assertNull($result);
}
// --- updateVariantForApi ---
public function testUpdateVariantForApiSuccess(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '101', 'parent_id' => '1',
]);
$mockDb->expects($this->once())->method('update');
$repository = new ProductRepository($mockDb);
$result = $repository->updateVariantForApi(101, [
'sku' => 'NEW-SKU',
'price_brutto' => 119.99,
]);
$this->assertTrue($result);
}
public function testUpdateVariantForApiReturnsFalseForNonVariant(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1', 'parent_id' => null,
]);
$repository = new ProductRepository($mockDb);
$result = $repository->updateVariantForApi(1, ['sku' => 'NEW']);
$this->assertFalse($result);
}
public function testUpdateVariantForApiReturnsFalseForNonexistent(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn(null);
$repository = new ProductRepository($mockDb);
$result = $repository->updateVariantForApi(999, ['sku' => 'NEW']);
$this->assertFalse($result);
}
public function testUpdateVariantForApiFiltersUnallowedFields(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '101', 'parent_id' => '1',
]);
$mockDb->expects($this->once())
->method('update')
->with(
'pp_shop_products',
$this->callback(function ($data) {
return isset($data['sku'])
&& !isset($data['parent_id'])
&& !isset($data['permutation_hash']);
}),
$this->anything()
);
$repository = new ProductRepository($mockDb);
$repository->updateVariantForApi(101, [
'sku' => 'NEW',
'parent_id' => 999,
'permutation_hash' => 'hacked',
]);
}
public function testUpdateVariantForApiCastsTypes(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '101', 'parent_id' => '1',
]);
$mockDb->expects($this->once())
->method('update')
->with(
'pp_shop_products',
$this->callback(function ($data) {
return $data['sku'] === '123'
&& $data['price_brutto'] === 99.99
&& $data['quantity'] === 5
&& $data['weight'] === null
&& $data['status'] === 1;
}),
$this->anything()
);
$repository = new ProductRepository($mockDb);
$repository->updateVariantForApi(101, [
'sku' => 123,
'price_brutto' => '99.99',
'quantity' => '5',
'weight' => '',
'status' => '1',
]);
}
// --- deleteVariantForApi ---
public function testDeleteVariantForApiSuccess(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '101', 'parent_id' => '1',
]);
$deleteCalls = [];
$mockDb->expects($this->exactly(3))
->method('delete')
->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
$deleteCalls[] = ['table' => $table, 'where' => $where];
return true;
});
$repository = new ProductRepository($mockDb);
$result = $repository->deleteVariantForApi(101);
$this->assertTrue($result);
$this->assertSame('pp_shop_products_langs', $deleteCalls[0]['table']);
$this->assertSame(['product_id' => 101], $deleteCalls[0]['where']);
$this->assertSame('pp_shop_products_attributes', $deleteCalls[1]['table']);
$this->assertSame('pp_shop_products', $deleteCalls[2]['table']);
}
public function testDeleteVariantForApiReturnsFalseForNonVariant(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn([
'id' => '1', 'parent_id' => null,
]);
$repository = new ProductRepository($mockDb);
$result = $repository->deleteVariantForApi(1);
$this->assertFalse($result);
}
public function testDeleteVariantForApiReturnsFalseForNonexistent(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->method('get')->willReturn(null);
$repository = new ProductRepository($mockDb);
$result = $repository->deleteVariantForApi(999);
$this->assertFalse($result);
}
}