/
home
/
alixis5
/
integrate.sellofinance.com
/
tests
/
Unit
/
Controllers
/
/home/alixis5/integrate.sellofinance.com/tests/Unit/Controllers
mkdir
upload
Name
Size
Mode
Actions
CatalogControllerTest.php
27564
0644
edit
dl
rm
Edit:
/home/alixis5/integrate.sellofinance.com/tests/Unit/Controllers/CatalogControllerTest.php
(27564B)
<?php declare(strict_types=1); namespace Sello\Tests\Unit\Controllers; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; use Sello\Controllers\CatalogController; use Sello\Helpers\ApiResponse; use Sello\Repositories\EspoApiClient; use Sello\Services\CatalogAggregator; use Sello\Services\MeritRanker; class CatalogControllerTest extends TestCase { // Each test builds its own controller against the real aggregator // (which has no I/O). Only EspoApiClient and MeritRanker are mocked — // the aggregator's pure row-shaping logic is always exercised for real. protected MockObject $espo; protected CatalogAggregator $aggregator; protected MockObject $ranker; protected CatalogController $controller; protected function setUp(): void { $this->espo = $this->createMock(EspoApiClient::class); $this->aggregator = new CatalogAggregator(); $this->ranker = $this->createMock(MeritRanker::class); $this->controller = new CatalogController( $this->espo, $this->aggregator, $this->ranker, new ApiResponse(), ); } public function test_controller_can_be_instantiated(): void { $this->assertInstanceOf(CatalogController::class, $this->controller); } public function test_list_categories_returns_shaped_list_with_counts(): void { $this->espo->method('get') ->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [ ['id' => 'c1', 'name' => 'Personal Loans', 'nameAr' => 'القروض الشخصية', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active'], ['id' => 'c2', 'name' => 'Home Finance', 'nameAr' => 'التمويل العقاري','iconClass' => 'pi-home', 'sortOrder' => 20, 'status' => 'active'], ], 'total' => 2]; } if ($entity === 'CFinancialProduct') { return ['list' => [ ['id' => 'p1', 'categoryId' => 'c1', 'status' => 'active'], ['id' => 'p2', 'categoryId' => 'c1', 'status' => 'active'], ['id' => 'p3', 'categoryId' => 'c2', 'status' => 'active'], ], 'total' => 3]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/categories'); $response = new \Slim\Psr7\Response(); $out = $this->controller->listCategories($request, $response, []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertCount(2, $decoded['data']); $this->assertSame('personal-loans', $decoded['data'][0]['slug']); $this->assertSame(2, $decoded['data'][0]['productCount']); $this->assertSame('home-finance', $decoded['data'][1]['slug']); $this->assertSame(1, $decoded['data'][1]['productCount']); } public function test_list_institutions_includes_only_accounts_with_active_products(): void { $this->espo->method('get') ->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CFinancialProduct') { return ['list' => [ ['id' => 'p1', 'accountId' => '69d8390869f9620a2', 'status' => 'active'], ['id' => 'p2', 'accountId' => '69d8390869f9620a2', 'status' => 'active'], ['id' => 'p3', 'accountId' => '69e67e4db4486c266', 'status' => 'active'], ], 'total' => 3]; } if ($entity === 'Account') { return ['list' => [ ['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt'], ['id' => '69e67e4db4486c266', 'name' => 'EmiratesNBD Egypt'], ], 'total' => 2]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/institutions'); $response = new \Slim\Psr7\Response(); $out = $this->controller->listInstitutions($request, $response, []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertCount(2, $decoded['data']); $byId = array_column($decoded['data'], null, 'id'); $this->assertSame('adcb', $byId['69d8390869f9620a2']['slug']); $this->assertSame(2, $byId['69d8390869f9620a2']['productCount']); $this->assertSame('enbd', $byId['69e67e4db4486c266']['slug']); $this->assertSame(1, $byId['69e67e4db4486c266']['productCount']); } public function test_list_institutions_falls_back_to_name_plus_id_suffix_for_unmapped_accounts(): void { $unmappedId = 'new-bank-id-99aabb'; $this->espo->method('get') ->willReturnCallback(function (string $entity, array $params) use ($unmappedId) { if ($entity === 'CFinancialProduct') { return ['list' => [ ['id' => 'p1', 'accountId' => $unmappedId, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'Account') { return ['list' => [ ['id' => $unmappedId, 'name' => 'Mashreq Bank'], ], 'total' => 1]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/institutions'); $response = new \Slim\Psr7\Response(); $out = $this->controller->listInstitutions($request, $response, []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertCount(1, $decoded['data']); $this->assertSame($unmappedId, $decoded['data'][0]['id']); $this->assertSame('Mashreq Bank', $decoded['data'][0]['name']); // Fallback slug: slugified name + short id fragment for uniqueness. $this->assertSame('mashreq-bank-new-ba', $decoded['data'][0]['slug']); } public function test_list_institutions_returns_empty_array_when_no_active_products(): void { $accountCallCount = 0; $this->espo->method('get') ->willReturnCallback(function (string $entity, array $params) use (&$accountCallCount) { if ($entity === 'CFinancialProduct') { return ['list' => [], 'total' => 0]; } if ($entity === 'Account') { $accountCallCount++; return ['list' => [], 'total' => 0]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/institutions'); $response = new \Slim\Psr7\Response(); $out = $this->controller->listInstitutions($request, $response, []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertSame([], $decoded['data']); $this->assertSame(0, $accountCallCount, 'Account entity should not be queried when no active products exist'); } public function test_list_products_translates_category_slug_to_id(): void { $captured = null; $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) use (&$captured) { if ($entity === 'CProductCategory') { return ['list' => [ ['id' => 'cat-1', 'name' => 'Personal Loans', 'nameAr' => '', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'Account') { return ['list' => [ ['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt'], ], 'total' => 1]; } if ($entity === 'CFinancialProduct') { $captured = $params; return ['list' => [], 'total' => 0]; } if ($entity === 'CRequiredDocumentRule') { return ['list' => [], 'total' => 0]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/products') ->withQueryParams(['categories' => 'personal-loans']); $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $this->assertNotNull($captured); $where = $captured['where'] ?? []; $found = false; foreach ($where as $clause) { if (($clause['attribute'] ?? '') === 'categoryId' && ($clause['type'] ?? '') === 'in' && in_array('cat-1', $clause['value'] ?? [], true)) { $found = true; } } $this->assertTrue($found, 'Expected categoryId IN [cat-1] clause in CFinancialProduct query'); } public function test_list_products_returns_shaped_list_with_total(): void { $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [ ['id' => 'cat-1', 'name' => 'Personal Loans', 'nameAr' => '', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'Account') { return ['list' => [ ['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt'], ], 'total' => 1]; } if ($entity === 'CFinancialProduct') { return ['list' => [ [ 'id' => 'p1', 'name' => 'ADCB Personal Loan Classic', 'nameAr' => 'الكلاسيكي', 'productType' => 'personalLoan', 'financeType' => 'conventional', 'accountId' => '69d8390869f9620a2', 'categoryId' => 'cat-1', 'minAmount' => 5000, 'maxAmount' => 50000, 'minTenorMonths' => 6, 'maxTenorMonths' => 48, 'profitRateMin' => 3.99, 'profitRateMax' => 5.99, 'minSalary' => 5000, 'maxDbr' => 50, 'minCreditScore' => 600, 'processingFeePercent' => 1.0, 'createdAt' => '2026-04-20T19:36:00+00:00', 'modifiedAt' => '2026-04-22T12:00:00+00:00', 'status' => 'active', ], ], 'total' => 1]; } if ($entity === 'CRequiredDocumentRule') { return ['list' => [ ['id' => 'rd1', 'financialProductId' => 'p1'], ['id' => 'rd2', 'financialProductId' => 'p1'], ], 'total' => 2]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/products')->withQueryParams([]); $out = $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertSame(1, $decoded['data']['total']); $this->assertSame(1, $decoded['data']['page']); $this->assertCount(1, $decoded['data']['data']); $p = $decoded['data']['data'][0]; $this->assertSame('p1', $p['id']); $this->assertSame('personal-loans', $p['category']['slug']); $this->assertSame('adcb', $p['institution']['slug']); $this->assertSame(2, $p['requiredDocCount']); } public function test_list_products_honours_pagination_params(): void { $captured = null; $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) use (&$captured) { if ($entity === 'CFinancialProduct') { $captured = $params; return ['list' => [], 'total' => 0]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/products')->withQueryParams([ 'page' => '3', 'pageSize' => '9', ]); $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $this->assertNotNull($captured); $this->assertSame(9, $captured['maxSize']); $this->assertSame(18, $captured['offset']); // (page-1)*pageSize } public function test_list_products_returns_400_for_unknown_category_slug(): void { $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [ ['id' => 'cat-1', 'name' => 'Personal Loans', 'nameAr' => '', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'Account') { return ['list' => [['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt']], 'total' => 1]; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/products') ->withQueryParams(['categories' => 'does-not-exist']); $out = $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertSame(400, $out->getStatusCode()); $this->assertFalse($decoded['success']); $this->assertSame('BAD_REQUEST', $decoded['error']['code']); $this->assertStringContainsString('does-not-exist', $decoded['error']['message']); } public function test_list_products_returns_400_for_unknown_program_id_404(): void { $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [], 'total' => 0]; } if ($entity === 'Account') { return ['list' => [], 'total' => 0]; } // programId sub-link returns 404 → unknown program if (str_starts_with($entity, 'CFinancialProgram/')) { throw new \Sello\Repositories\EspoApiException('Not found', 404); } return ['list' => [], 'total' => 0]; }); // Use a 17-char lowercase-hex id that passes format validation so the // request reaches the upstream CFinancialProgram/{id}/… lookup and we // actually exercise the 404-rethrow branch. $request = $this->makeRequest('GET', '/catalog/products') ->withQueryParams(['programId' => '69e00000000000000']); $out = $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertSame(400, $out->getStatusCode()); $this->assertSame('BAD_REQUEST', $decoded['error']['code']); $this->assertStringContainsString('69e00000000000000', $decoded['error']['message']); } public function test_list_products_rejects_malformed_program_id_without_hitting_upstream(): void { $upstreamHits = 0; $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) use (&$upstreamHits) { if (str_starts_with($entity, 'CFinancialProgram/')) { $upstreamHits++; } return ['list' => [], 'total' => 0]; }); $request = $this->makeRequest('GET', '/catalog/products') ->withQueryParams(['programId' => '../User']); $out = $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertSame(400, $out->getStatusCode()); $this->assertSame('BAD_REQUEST', $decoded['error']['code']); $this->assertStringContainsString('Invalid programId format', $decoded['error']['message']); $this->assertSame(0, $upstreamHits, 'Malformed programId must not reach upstream'); } public function test_list_products_returns_upstream_error_when_program_id_lookup_fails_non_404(): void { $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [], 'total' => 0]; } if ($entity === 'Account') { return ['list' => [], 'total' => 0]; } // programId sub-link returns 503 → EspoCRM unreachable (NOT a bad-request) if (str_starts_with($entity, 'CFinancialProgram/')) { throw new \Sello\Repositories\EspoApiException('EspoCRM unreachable', 503); } return ['list' => [], 'total' => 0]; }); // Valid-format programId (17 lowercase hex chars) so the request // reaches upstream and we test the non-404 rethrow path. $request = $this->makeRequest('GET', '/catalog/products') ->withQueryParams(['programId' => '69e11111111111111']); $out = $this->controller->listProducts($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertSame(500, $out->getStatusCode()); $this->assertSame('SERVER_ERROR', $decoded['error']['code']); $this->assertStringNotContainsString('Unknown programId', $decoded['error']['message']); } public function test_featured_returns_ranked_shaped_products(): void { $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CProductCategory') { return ['list' => [ ['id' => 'cat-1', 'name' => 'Personal Loans', 'nameAr' => '', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'Account') { return ['list' => [ ['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt'], ['id' => '69e67e4db4486c266', 'name' => 'EmiratesNBD Egypt'], ], 'total' => 2]; } if ($entity === 'CFinancialProduct') { return ['list' => [ ['id' => 'cheap', 'name' => 'Cheap', 'nameAr' => '', 'productType' => 'personalLoan', 'financeType' => 'conventional', 'accountId' => '69d8390869f9620a2', 'categoryId' => 'cat-1', 'minAmount' => 1000, 'maxAmount' => 10000, 'minTenorMonths' => 6, 'maxTenorMonths' => 36, 'profitRateMin' => 1.0, 'profitRateMax' => 2.0, 'minSalary' => 0, 'maxDbr' => 50, 'minCreditScore' => 0, 'processingFeePercent' => 0.5, 'createdAt' => '2026-04-22T00:00:00+00:00', 'modifiedAt' => '2026-04-22T00:00:00+00:00', 'status' => 'active'], ['id' => 'expensive','name' => 'Pricey','nameAr' => '', 'productType' => 'personalLoan', 'financeType' => 'conventional', 'accountId' => '69e67e4db4486c266', 'categoryId' => 'cat-1', 'minAmount' => 20000, 'maxAmount' => 100000, 'minTenorMonths' => 12, 'maxTenorMonths' => 60, 'profitRateMin' => 9.0, 'profitRateMax' => 10.0, 'minSalary' => 15000, 'maxDbr' => 45, 'minCreditScore' => 700, 'processingFeePercent' => 1.0, 'createdAt' => '2026-01-01T00:00:00+00:00', 'modifiedAt' => '2026-01-01T00:00:00+00:00', 'status' => 'active'], ], 'total' => 2]; } if ($entity === 'CRequiredDocumentRule') { return ['list' => [], 'total' => 0]; } return ['list' => [], 'total' => 0]; }); // Swap the mocked ranker for a real one so the scoring actually runs. $this->controller = new CatalogController( $this->espo, $this->aggregator, new \Sello\Services\MeritRanker(), new ApiResponse(), ); $request = $this->makeRequest('GET', '/catalog/products/featured')->withQueryParams(['limit' => '5']); $out = $this->controller->getFeatured($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertNotEmpty($decoded['data']); $this->assertSame('cheap', $decoded['data'][0]['id']); $this->assertSame('adcb', $decoded['data'][0]['institution']['slug']); } public function test_featured_returns_empty_array_when_no_products(): void { $this->espo->method('get')->willReturnCallback(fn() => ['list' => [], 'total' => 0]); $this->controller = new CatalogController( $this->espo, $this->aggregator, new \Sello\Services\MeritRanker(), new ApiResponse(), ); $request = $this->makeRequest('GET', '/catalog/products/featured'); $out = $this->controller->getFeatured($request, new \Slim\Psr7\Response(), []); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertSame([], $decoded['data']); } public function test_get_product_returns_detail_with_bands_docs_and_programs(): void { $this->espo->method('getById')->willReturnCallback(function (string $entity, string $id) { if ($entity === 'CFinancialProduct' && $id === 'p1') { return [ 'id' => 'p1', 'name' => 'ADCB Personal Loan Classic', 'nameAr' => '', 'productType' => 'personalLoan', 'financeType' => 'conventional', 'accountId' => '69d8390869f9620a2', 'categoryId' => 'cat-1', 'minAmount' => 5000, 'maxAmount' => 50000, 'minTenorMonths' => 6, 'maxTenorMonths' => 48, 'profitRateMin' => 3.99, 'profitRateMax' => 5.99, 'minSalary' => 5000, 'maxDbr' => 50, 'minCreditScore' => 600, 'processingFeePercent' => 1.0, 'createdAt' => '2026-04-20T19:36:00+00:00', 'modifiedAt' => '2026-04-22T12:00:00+00:00', 'status' => 'active', ]; } if ($entity === 'CProductCategory' && $id === 'cat-1') { return ['id' => 'cat-1', 'name' => 'Personal Loans', 'nameAr' => '', 'iconClass' => 'pi-wallet', 'sortOrder' => 10, 'status' => 'active']; } if ($entity === 'Account' && $id === '69d8390869f9620a2') { return ['id' => '69d8390869f9620a2', 'name' => 'ADCB Egypt']; } throw new \Sello\Repositories\EspoApiException('Not found', 404); }); $this->espo->method('get')->willReturnCallback(function (string $entity, array $params) { if ($entity === 'CPricingRule') { return ['list' => [ ['id' => 'pr-1', 'name' => 'Fair Credit', 'ruleType' => 'creditScoreBand', 'minValue' => 550, 'maxValue' => 649, 'profitRate' => 8.75, 'rateType' => 'fixed', 'margin' => 1.5, 'processingFee' => 0.75, 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'CRequiredDocumentRule') { return ['list' => [ ['id' => 'rd-1', 'applicantType' => 'individual', 'isMandatory' => true, 'documentTypeId' => 'dt-1', 'financialProductId' => 'p1', 'status' => 'active'], ], 'total' => 1]; } if ($entity === 'CDocumentType') { return ['list' => [ ['id' => 'dt-1', 'name' => 'Emirates ID', 'nameAr' => '', 'category' => 'identity'], ], 'total' => 1]; } // linkedWith query for programs containing this product if ($entity === 'CFinancialProgram') { return ['list' => [ ['id' => 'prog-1', 'name' => 'ADCB Salaried Personal Finance', 'description' => 'Salaried program'], ], 'total' => 1]; } // Sibling-count sub-link if ($entity === 'CFinancialProgram/prog-1/financialProducts') { return ['list' => [ ['id' => 'p1', 'status' => 'active'], ['id' => 'p2', 'status' => 'active'], ['id' => 'p3', 'status' => 'active'], ['id' => 'p4', 'status' => 'active'], ['id' => 'p5', 'status' => 'active'], ], 'total' => 5]; } return ['list' => [], 'total' => 0]; }); $out = $this->controller->getProduct( $this->makeRequest('GET', '/catalog/products/p1'), new \Slim\Psr7\Response(), ['id' => 'p1'], ); $decoded = json_decode((string) $out->getBody(), true); $this->assertTrue($decoded['success']); $this->assertSame('p1', $decoded['data']['id']); $this->assertSame('personal-loans', $decoded['data']['category']['slug']); $this->assertSame('adcb', $decoded['data']['institution']['slug']); $this->assertCount(1, $decoded['data']['pricingBands']); $this->assertSame('Fair Credit', $decoded['data']['pricingBands'][0]['name']); $this->assertCount(1, $decoded['data']['requiredDocuments']); $this->assertSame('Emirates ID', $decoded['data']['requiredDocuments'][0]['documentType']['name']); $this->assertCount(1, $decoded['data']['programs']); $this->assertSame('prog-1', $decoded['data']['programs'][0]['id']); $this->assertSame(4, $decoded['data']['programs'][0]['siblingProductCount']); } public function test_get_product_returns_404_for_unknown_id(): void { $this->espo->method('getById')->willThrowException( new \Sello\Repositories\EspoApiException('Not found', 404) ); $out = $this->controller->getProduct( $this->makeRequest('GET', '/catalog/products/nope'), new \Slim\Psr7\Response(), ['id' => 'nope'], ); $this->assertSame(404, $out->getStatusCode()); } private function makeRequest(string $method, string $path): \Psr\Http\Message\ServerRequestInterface { $uri = new \Slim\Psr7\Uri('http', 'localhost', null, $path); $headers = new \Slim\Psr7\Headers(); $body = (new \Slim\Psr7\Factory\StreamFactory())->createStream(''); return new \Slim\Psr7\Request($method, $uri, $headers, [], [], $body); } }
Save
cmd:
run