- 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>
440 lines
16 KiB
PHP
440 lines
16 KiB
PHP
<?php
|
|
namespace Tests\Unit\Domain\Attribute;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use Domain\Attribute\AttributeRepository;
|
|
|
|
class AttributeRepositoryTest extends TestCase
|
|
{
|
|
public function testFindAttributeReturnsDefaultAttributeForInvalidId(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$mockDb->expects($this->once())
|
|
->method('max')
|
|
->with('pp_shop_attributes', 'o')
|
|
->willReturn(7);
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->findAttribute(0);
|
|
|
|
$this->assertSame(0, (int)$result['id']);
|
|
$this->assertSame(1, (int)$result['status']);
|
|
$this->assertSame(0, (int)$result['type']);
|
|
$this->assertSame(8, (int)$result['o']);
|
|
$this->assertSame([], $result['languages']);
|
|
}
|
|
|
|
public function testListForAdminWhitelistsSortDirectionAndPerPage(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$queries = [];
|
|
|
|
$mockDb->method('select')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_langs') {
|
|
return [['id' => 'pl', 'start' => 1, 'o' => 1]];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
$mockDb->method('query')
|
|
->willReturnCallback(function ($sql, $params = []) use (&$queries) {
|
|
$queries[] = ['sql' => $sql, 'params' => $params];
|
|
|
|
if (preg_match('/SELECT\s+COUNT\(0\)\s+FROM\s+pp_shop_attributes\s+AS\s+sa/i', $sql)) {
|
|
return new class {
|
|
public function fetchAll(): array
|
|
{
|
|
return [[1]];
|
|
}
|
|
};
|
|
}
|
|
|
|
return new class {
|
|
public function fetchAll(): array
|
|
{
|
|
return [[
|
|
'id' => '10',
|
|
'status' => '1',
|
|
'type' => '2',
|
|
'o' => '3',
|
|
'name_default' => '',
|
|
'name_any' => 'Wzor A',
|
|
'values_count' => '5',
|
|
'name_for_sort' => 'Wzor A',
|
|
]];
|
|
}
|
|
};
|
|
});
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->listForAdmin([], 'id DESC; DROP TABLE pp_shop_attributes; --', 'DESC; DELETE', 1, 999);
|
|
|
|
$this->assertCount(2, $queries);
|
|
$dataSql = $queries[1]['sql'];
|
|
|
|
$this->assertMatchesRegularExpression('/ORDER BY\s+sa\.o\s+ASC,\s+sa\.id\s+ASC/i', $dataSql);
|
|
$this->assertStringNotContainsString('DROP TABLE', $dataSql);
|
|
$this->assertStringNotContainsString('DELETE', $dataSql);
|
|
$this->assertMatchesRegularExpression('/LIMIT\s+100\s+OFFSET\s+0/i', $dataSql);
|
|
|
|
$this->assertSame('Wzor A', $result['items'][0]['name']);
|
|
$this->assertSame(5, (int)$result['items'][0]['values_count']);
|
|
}
|
|
|
|
public function testSaveValuesRemovesObsoleteRowsAndSetsDefault(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$insertCalls = [];
|
|
$updateCalls = [];
|
|
$deleteCalls = [];
|
|
|
|
$mockDb->method('select')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_shop_attributes_values' && $columns === 'id') {
|
|
return [10, 11];
|
|
}
|
|
|
|
if ($table === 'pp_shop_products_attributes') {
|
|
return [];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
$mockDb->method('count')
|
|
->willReturnCallback(function ($table, $where) {
|
|
if ($table === 'pp_shop_attributes_values' && (int)($where['AND']['id'] ?? 0) === 11) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
|
|
$mockDb->method('get')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_shop_attributes_values_langs') {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
$mockDb->method('insert')
|
|
->willReturnCallback(function ($table, $row) use (&$insertCalls) {
|
|
$insertCalls[] = ['table' => $table, 'row' => $row];
|
|
});
|
|
|
|
$mockDb->expects($this->once())
|
|
->method('id')
|
|
->willReturn(22);
|
|
|
|
$mockDb->method('update')
|
|
->willReturnCallback(function ($table, $row, $where) use (&$updateCalls) {
|
|
$updateCalls[] = ['table' => $table, 'row' => $row, 'where' => $where];
|
|
return true;
|
|
});
|
|
|
|
$mockDb->method('delete')
|
|
->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
|
|
$deleteCalls[] = ['table' => $table, 'where' => $where];
|
|
return true;
|
|
});
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$saved = $repository->saveValues(3, [
|
|
'rows' => [
|
|
[
|
|
'id' => 11,
|
|
'is_default' => false,
|
|
'impact_on_the_price' => '',
|
|
'translations' => [
|
|
'pl' => ['name' => 'Niebieski', 'value' => 'blue'],
|
|
],
|
|
],
|
|
[
|
|
'id' => 0,
|
|
'is_default' => true,
|
|
'impact_on_the_price' => null,
|
|
'translations' => [
|
|
'pl' => ['name' => 'Czerwony', 'value' => 'red'],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->assertTrue($saved);
|
|
|
|
$this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values_langs', ['value_id' => 10]));
|
|
$this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values', ['id' => 10]));
|
|
$this->assertTrue($this->hasUpdateCall($updateCalls, 'pp_shop_attributes_values', ['is_default' => 0], ['attribute_id' => 3]));
|
|
$this->assertTrue($this->hasUpdateCall($updateCalls, 'pp_shop_attributes_values', ['is_default' => 1], ['id' => 22]));
|
|
|
|
$this->assertTrue($this->hasInsertInto($insertCalls, 'pp_shop_attributes_values'));
|
|
$this->assertTrue($this->hasInsertInto($insertCalls, 'pp_shop_attributes_values_langs'));
|
|
}
|
|
|
|
public function testSaveValuesDeletesTranslationWhenNameIsEmpty(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$deleteCalls = [];
|
|
|
|
$mockDb->method('select')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_shop_attributes_values' && $columns === 'id') {
|
|
return [5];
|
|
}
|
|
|
|
if ($table === 'pp_shop_products_attributes') {
|
|
return [];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
$mockDb->method('count')->willReturn(1);
|
|
|
|
$mockDb->method('get')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_shop_attributes_values_langs' && $columns === 'id') {
|
|
return 77;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
$mockDb->method('update')->willReturn(true);
|
|
|
|
$mockDb->method('delete')
|
|
->willReturnCallback(function ($table, $where) use (&$deleteCalls) {
|
|
$deleteCalls[] = ['table' => $table, 'where' => $where];
|
|
return true;
|
|
});
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$saved = $repository->saveValues(9, [
|
|
'rows' => [
|
|
[
|
|
'id' => 5,
|
|
'is_default' => true,
|
|
'impact_on_the_price' => null,
|
|
'translations' => [
|
|
'pl' => ['name' => '', 'value' => ''],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->assertTrue($saved);
|
|
$this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values_langs', ['id' => 77]));
|
|
}
|
|
|
|
public function testGetAttributeValueByIdUsesDefaultLanguageWhenNotProvided(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
|
|
$mockDb->method('select')
|
|
->willReturnCallback(function ($table, $columns, $where) {
|
|
if ($table === 'pp_langs') {
|
|
return [['id' => 'pl', 'start' => 1, 'o' => 1]];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
$mockDb->expects($this->once())
|
|
->method('get')
|
|
->with(
|
|
'pp_shop_attributes_values_langs',
|
|
'name',
|
|
['AND' => ['value_id' => 123, 'lang_id' => 'pl']]
|
|
)
|
|
->willReturn('Czerwony');
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->getAttributeValueById(123);
|
|
|
|
$this->assertSame('Czerwony', $result);
|
|
}
|
|
|
|
// ── Frontend methods tests ──────────────────────────────────
|
|
|
|
public function testFrontAttributeDetailsReturnsAttributeWithLanguage(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$mockDb->expects($this->exactly(2))
|
|
->method('get')
|
|
->willReturnOnConsecutiveCalls(
|
|
['id' => 5, 'status' => 1, 'type' => 0, 'o' => 2],
|
|
['lang_id' => 'pl', 'name' => 'Kolor']
|
|
);
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->frontAttributeDetails(5, 'pl');
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertSame(5, (int)$result['id']);
|
|
$this->assertSame('Kolor', $result['language']['name']);
|
|
$this->assertSame('pl', $result['language']['lang_id']);
|
|
}
|
|
|
|
public function testFrontAttributeDetailsReturnsFallbackForNotFound(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$mockDb->method('get')->willReturn(null);
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->frontAttributeDetails(999, 'pl');
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertSame(999, (int)$result['id']);
|
|
$this->assertSame(0, (int)$result['status']);
|
|
$this->assertSame('pl', $result['language']['lang_id']);
|
|
$this->assertSame('', $result['language']['name']);
|
|
}
|
|
|
|
public function testFrontValueDetailsReturnsValueWithLanguage(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$mockDb->expects($this->exactly(2))
|
|
->method('get')
|
|
->willReturnOnConsecutiveCalls(
|
|
['id' => 12, 'attribute_id' => 5, 'is_default' => 1, 'impact_on_the_price' => null],
|
|
['lang_id' => 'pl', 'name' => 'Czerwony']
|
|
);
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->frontValueDetails(12, 'pl');
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertSame(12, (int)$result['id']);
|
|
$this->assertSame('Czerwony', $result['language']['name']);
|
|
$this->assertSame('pl', $result['language']['lang_id']);
|
|
}
|
|
|
|
public function testFrontValueDetailsReturnsFallbackForNotFound(): void
|
|
{
|
|
$mockDb = $this->createMock(\medoo::class);
|
|
$mockDb->method('get')->willReturn(null);
|
|
|
|
$repository = new AttributeRepository($mockDb);
|
|
$result = $repository->frontValueDetails(999, 'en');
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertSame(999, (int)$result['id']);
|
|
$this->assertSame('en', $result['language']['lang_id']);
|
|
$this->assertSame('', $result['language']['name']);
|
|
}
|
|
|
|
private function hasDeleteCall(array $calls, string $table, array $where): bool
|
|
{
|
|
foreach ($calls as $call) {
|
|
if ($call['table'] === $table && $call['where'] == $where) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function hasUpdateCall(array $calls, string $table, array $row, array $where): bool
|
|
{
|
|
foreach ($calls as $call) {
|
|
if ($call['table'] === $table && $call['row'] == $row && $call['where'] == $where) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function hasInsertInto(array $calls, string $table): bool
|
|
{
|
|
foreach ($calls as $call) {
|
|
if ($call['table'] === $table) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|