/home/alixis5/integrate.sellofinance.com/tests/Unit/Controllers
Edit: /home/alixis5/integrate.sellofinance.com/tests/Unit/Controllers/CatalogControllerTest.php (27564B)
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);
}
}