SuperSeller3000/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php

183 lines
6.1 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Channel\Frappe;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType;
use App\Domain\Channel\Platform;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;
use App\Infrastructure\Channel\Frappe\FrappeErpAdapter;
use App\Infrastructure\Channel\Frappe\FrappeHttpClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class FrappeErpAdapterTest extends TestCase
{
private FrappeHttpClient&MockObject $frappe;
private FrappeErpAdapter $adapter;
protected function setUp(): void
{
$this->frappe = $this->createMock(FrappeHttpClient::class);
$this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW');
}
public function test_create_customer_returns_frappe_id(): void
{
$this->frappe
->method('post')
->with('/api/resource/Customer', $this->isType('array'))
->willReturn(['data' => ['name' => 'CUST-00001']]);
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
$customer->addPlatformId('ebay', 'buyer123');
$result = $this->adapter->createCustomer($customer);
$this->assertSame('CUST-00001', $result);
}
public function test_create_sales_invoice_submits_and_returns_id(): void
{
$this->frappe
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type Frappe ERP: - findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup - FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16) - buildItemDescription() uses specsText + inventory number + serial number - Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown - FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration Pipeline — model cache: - PhotoUploadHandler: after vision, check DB for existing article with same modelNumber - On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps - DraftArticleHandler: apply model_match data and mark job complete directly - ArticleRepository: findCompletedByModelNumber() query Pipeline — specs by article type: - SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var - SpecsResearchHandler: derive attribute names from ArticleType, pass to agent - SpecsResearchMessage: add attributeFields param - Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder Article: - specsText field (nullable text column + migration) - stock field visible on index and editable in CRUD form - addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding - AttributeValue::getArticle() getter - AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true - ManualIngestType: add stock/quantity field (default 1, min 1) Users / ACL: - PermissionVoter: define named permission constants + allPermissions() - User: getGrantedPermissions()/setGrantedPermissions() helpers - UserCrudController: permissions checkbox group on edit form UI / assets: - public/css/admin/custom.css: red asterisk for required fields - DashboardController: register custom CSS Infra: - PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries - doctrine.yaml: exclude app.inventory_seq from schema diff - ErpAdapterInterface: add findExistingCustomer() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
->expects($this->once())
->method('post')
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type Frappe ERP: - findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup - FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16) - buildItemDescription() uses specsText + inventory number + serial number - Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown - FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration Pipeline — model cache: - PhotoUploadHandler: after vision, check DB for existing article with same modelNumber - On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps - DraftArticleHandler: apply model_match data and mark job complete directly - ArticleRepository: findCompletedByModelNumber() query Pipeline — specs by article type: - SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var - SpecsResearchHandler: derive attribute names from ArticleType, pass to agent - SpecsResearchMessage: add attributeFields param - Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder Article: - specsText field (nullable text column + migration) - stock field visible on index and editable in CRUD form - addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding - AttributeValue::getArticle() getter - AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true - ManualIngestType: add stock/quantity field (default 1, min 1) Users / ACL: - PermissionVoter: define named permission constants + allPermissions() - User: getGrantedPermissions()/setGrantedPermissions() helpers - UserCrudController: permissions checkbox group on edit form UI / assets: - public/css/admin/custom.css: red asterisk for required fields - DashboardController: register custom CSS Infra: - PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries - doctrine.yaml: exclude app.inventory_seq from schema diff - ErpAdapterInterface: add findExistingCustomer() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
->willReturn(['data' => ['name' => 'SINV-00001']]);
$this->frappe
->expects($this->once())
->method('put')
->with($this->stringContains('SINV-00001'), ['docstatus' => 1])
->willReturn(['data' => ['name' => 'SINV-00001', 'docstatus' => 1]]);
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
$customer->setFrappeCustomerId('CUST-00001');
$order = new Order(
new Article(new ArticleType('Laptop'), 'LAP-001', 'INV-001', 1, ArticleCondition::Good),
$customer,
new Platform('ebay', 'eBay DE'),
'ORDER-001',
'299.99',
new \DateTimeImmutable('2026-05-13'),
);
$result = $this->adapter->createSalesInvoice($order);
$this->assertSame('SINV-00001', $result);
}
public function test_fetch_invoice_pdf_returns_binary(): void
{
$this->frappe
->method('getContent')
->with($this->stringContains('SINV-00001'))
->willReturn('%PDF-binary-content');
$result = $this->adapter->fetchInvoicePdf('SINV-00001');
$this->assertStringStartsWith('%PDF', $result);
}
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type Frappe ERP: - findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup - FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16) - buildItemDescription() uses specsText + inventory number + serial number - Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown - FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration Pipeline — model cache: - PhotoUploadHandler: after vision, check DB for existing article with same modelNumber - On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps - DraftArticleHandler: apply model_match data and mark job complete directly - ArticleRepository: findCompletedByModelNumber() query Pipeline — specs by article type: - SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var - SpecsResearchHandler: derive attribute names from ArticleType, pass to agent - SpecsResearchMessage: add attributeFields param - Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder Article: - specsText field (nullable text column + migration) - stock field visible on index and editable in CRUD form - addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding - AttributeValue::getArticle() getter - AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true - ManualIngestType: add stock/quantity field (default 1, min 1) Users / ACL: - PermissionVoter: define named permission constants + allPermissions() - User: getGrantedPermissions()/setGrantedPermissions() helpers - UserCrudController: permissions checkbox group on edit form UI / assets: - public/css/admin/custom.css: red asterisk for required fields - DashboardController: register custom CSS Infra: - PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries - doctrine.yaml: exclude app.inventory_seq from schema diff - ErpAdapterInterface: add findExistingCustomer() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
public function test_find_existing_customer_returns_id_when_address_matches(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
);
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertSame('CUST-99999', $result);
}
public function test_find_existing_customer_returns_null_when_address_mismatch(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Musterstr. 5', 'city' => 'Berlin', 'pincode' => '10115']]],
);
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertNull($result);
}
public function test_find_existing_customer_returns_null_when_not_in_erp(): void
{
$this->frappe
->expects($this->once())
->method('get')
->willReturn(['data' => []]);
$result = $this->adapter->findExistingCustomer('Nobody', 'Unknown St. 1', 'Nowhere', '00000');
$this->assertNull($result);
}
public function test_find_simon_and_create_invoice_for_1337(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
);
$this->frappe
->expects($this->once())
->method('post')
->willReturn(['data' => ['name' => 'SINV-13370']]);
$this->frappe
->expects($this->once())
->method('put')
->willReturn(['data' => ['name' => 'SINV-13370', 'docstatus' => 1]]);
$frappeId = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertSame('CUST-99999', $frappeId);
$simon = new Customer('Simon Kühn', 'simon.kuehn83@gmail.com', [
'street' => 'Kirchstr. 1',
'city' => 'Karbach',
'zip' => '56281',
]);
$simon->setFrappeCustomerId($frappeId);
$article = new Article(
new ArticleType('Laptop'),
'LAP-THINK-001',
'INV-THINK-001',
1,
ArticleCondition::Good,
);
$article->setManufacturer('Lenovo');
$article->setModelName('ThinkBook 14 G6 IRL');
$article->setModelNumber('21KG00NQGE');
$article->setEbayTitle('Lenovo ThinkBook 14 G6 IRL — generalüberholt, top Zustand');
$order = new Order(
$article,
$simon,
new Platform('direct', 'Direktverkauf'),
'ORDER-SIMON-1337',
'1337.00',
new \DateTimeImmutable('2026-05-18'),
);
$invoiceId = $this->adapter->createSalesInvoice($order);
$this->assertSame('SINV-13370', $invoiceId);
}
}