Files
api_arrahn/tests/ShorakaIntegrationTest.php

261 lines
12 KiB
PHP

<?php
use App\Services\ShorakaService;
use App\ShorakaTransaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Log;
class ShorakaIntegrationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->app['config']->set('services.shoraka', [
'base_url' => 'https://shoraka.test',
'api_id' => 'API_ID',
'secret_key' => 'SECRET_KEY',
'timeout' => 5,
]);
// All database writes stay in memory; never run migrations on branch databases.
$this->app['config']->set('database.default', 'shoraka_test');
$this->app['config']->set('database.connections.shoraka_test', [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
]);
DB::purge('shoraka_test');
require_once __DIR__ . '/../database/migrations/2026_09_15_094818_create_shoraka_transactions_table.php';
(new CreateShorakaTransactionsTable())->up();
require_once __DIR__ . '/../database/migrations/2026_10_05_120000_add_kodcaw_to_shoraka_transactions.php';
(new AddKodcawToShorakaTransactions())->up();
require_once __DIR__ . '/../database/migrations/2026_10_26_130000_add_teller_to_shoraka_transactions.php';
(new AddTellerToShorakaTransactions())->up();
Http::swap(new Factory());
// Fail any HTTP request that a test did not explicitly stub.
Log::shouldReceive('error')->zeroOrMoreTimes();
}
protected function fakeResponse($body, array $headers = [], int $status = 200): void
{
Http::fake(function ($request) use ($body, $headers, $status) {
$this->assertStringStartsWith('https://shoraka.test/api/', $request->url());
return Http::response($body, $status, $headers);
});
}
protected function submission(): array
{
return [
'norujukan' => 'SAG-001', 'ownership' => 'Abu Samad',
'order_amount' => '1000.00', 'murabaha_amount' => '1200.00',
'tenor_other' => 6,
];
}
protected function transaction(string $status = 'Active', $orderId = '9999'): ShorakaTransaction
{
return ShorakaTransaction::create([
'norujukan' => 'SAG-001', 'order_id' => $orderId, 'status' => $status,
'product_type' => 'FINANCING', 'commodity_type' => '000-COPPER',
'ownership_name' => 'Abu Samad', 'order_amount' => '1000.00',
'murabaha_amount' => '1200.00', 'attempt_count' => 1,
]);
}
protected function callbackPayload(): array
{
// Published signature example, v1.5 page 14.
return [
'apiId' => 'API_ID', 'orderId' => '9999', 'status' => 'Completed',
'bankName' => 'Test Bank Bhd', 'ownershipName' => 'Abu Samad',
'commodityType' => '000-COPPER', 'unit' => 'Tonne', 'volume' => '245.678901',
'productType' => 'FINANCING', 'valueDate' => '08/12/2025', 'cancelDate' => '',
'orderType' => 'Buy & Sell', 'orderCurrency' => 'MYR',
'orderAmount' => '1000000.00', 'murabahaAmount' => '1000000.00',
'tenor' => 'O/N', 'tenorOther' => '', 'tenorOtherUnit' => '',
'signature' => 'bbc6f59e9c64aea9e30f3c03d30f63e8fc37898d2699c0cdd41e416a518aa122',
];
}
public function testSubmitSignatureMatchesPublishedExample(): void
{
$this->fakeResponse(['orderId' => '12345', 'status' => 'Completed']);
(new ShorakaService())->submitOrder([
'product_type' => 'DEPOSIT', 'commodity_type' => '000-COPPER',
'ownership' => 'Abu Samad', 'value_date' => '10/11/2022',
'order_currency' => 'MYR', 'order_amount' => 1000000,
'murabaha_amount' => 1000000, 'tenor' => 'O/N',
'order_type' => 'Buy & Sell', 'ownership_doc_no' => 'SAG-001',
]);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://shoraka.test/api/submitorder'
&& $request->hasHeader('Content-Type', 'application/x-www-form-urlencoded')
&& $request['order_amount'] === '1000000.00'
&& $request['ownership_doc_no'] === 'SAG-001'
&& $request['signature'] === '43970ac7a5c3867133af0dd92fd5eef9a7b415ebdb8b0d1025d4686bc25693bd';
});
}
public function testStatusUsesPublishedSignatureAndChecksOrderIdentity(): void
{
$this->fakeResponse(['orderId' => '12345', 'status' => 'Active']);
(new ShorakaService())->orderStatus('12345');
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://shoraka.test/api/orderstatus'
&& $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc';
});
$this->expectException(RuntimeException::class);
(new ShorakaService())->orderStatus('another-order');
}
public function testCallbackSignatureMatchesPublishedExampleAndRejectsTampering(): void
{
$service = new ShorakaService();
$payload = $this->callbackPayload();
$this->assertTrue($service->validCallback($payload));
foreach (ShorakaService::CALLBACK_FIELDS as $field) {
$changed = $payload;
$changed[$field] .= 'x';
$this->assertFalse($service->validCallback($changed), $field);
$missing = $payload;
unset($missing[$field]);
$this->assertFalse($service->validCallback($missing), $field);
}
$payload['apiId'] = 'wrong';
$this->assertFalse($service->validCallback($payload));
}
public function testSubmitPersistsOrderAndRejectsDuplicate(): void
{
$this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']);
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(200);
$record = ShorakaTransaction::first();
$this->assertSame('9999', $record->order_id);
$this->assertSame('Completed', $record->status);
$this->assertNotNull($record->completed_at);
$this->assertArrayNotHasKey('signature', $record->request_payload);
$this->assertSame('OTHERS', $record->request_payload['tenor']);
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
Http::assertSentCount(1);
}
public function testAmbiguousFailureBlocksResubmissionAndHidesUpstreamBody(): void
{
$this->fakeResponse('SECRET_KEY confidential', [], 500);
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(502);
$this->assertStringNotContainsString('SECRET_KEY', $this->response->getContent());
$record = ShorakaTransaction::first();
$this->assertSame('UNKNOWN', $record->status);
$this->assertStringNotContainsString('SECRET_KEY', $record->error_message);
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
Http::assertSentCount(1);
}
public function testPendingSubmissionCannotBeSubmittedAgain(): void
{
$this->transaction('PENDING', null);
Http::fake();
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(409);
Http::assertNothingSent();
}
public function testRefreshUpdatesStatus(): void
{
$record = $this->transaction();
$this->fakeResponse(['orderId' => '9999', 'status' => 'Completed']);
$this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(200);
$this->assertSame('Completed', $record->fresh()->status);
$this->assertNotNull($record->fresh()->completed_at);
}
public function testCallbackAcknowledgesAndUsesCurrentStatusNotUnsignedFields(): void
{
$record = $this->transaction();
$payload = $this->callbackPayload();
$payload['certificateUrl'] = 'https://untrusted.test/file';
$payload['ownershipDocNo'] = 'other-reference';
$this->fakeResponse([
'orderId' => '9999', 'status' => 'Cancelled',
'certificateUrl' => 'https://shoraka.test/certificate/9999',
]);
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200);
$this->assertSame('OK', $this->response->getContent());
$this->assertSame('Cancelled', $record->fresh()->status);
$this->assertSame('https://shoraka.test/certificate/9999', $record->fresh()->certificate_url);
$this->assertArrayNotHasKey('signature', $record->fresh()->callback_payload);
// Repeated delivery is acknowledged without creating another order.
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(200);
$this->assertSame(1, ShorakaTransaction::count());
}
public function testInvalidCallbackDoesNotMutateOrCallUpstream(): void
{
$record = $this->transaction();
Http::fake();
$payload = $this->callbackPayload();
$payload['status'] = 'Cancelled';
$this->json('POST', '/api/shoraka/callback', $payload)->seeStatusCode(401);
$this->assertSame('Active', $record->fresh()->status);
Http::assertNothingSent();
}
public function testUnknownCallbackIsNotAcknowledged(): void
{
Http::fake();
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(404);
Http::assertNothingSent();
}
public function testFailedCallbackRefreshPreservesRecord(): void
{
$record = $this->transaction();
$this->fakeResponse('unavailable', [], 503);
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(502);
$this->assertSame('Active', $record->fresh()->status);
$this->assertNull($record->fresh()->callback_payload);
}
public function testCertificateReturnsPdfFromFixedEndpoint(): void
{
$this->transaction('Completed', '12345');
$pdf = "%PDF-1.4\nexample";
$this->fakeResponse($pdf, ['Content-Type' => 'application/pdf']);
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(200);
$this->assertSame($pdf, $this->response->getContent());
$this->assertSame('application/pdf', $this->response->headers->get('Content-Type'));
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://shoraka.test/api/certificate'
&& $request['signature'] === 'add9890b2a08cf47290e8869d4a25b7f4b73cb075c20aa95aa47d1bbfe6b62fc';
});
}
public function testCertificateRejectsNonPdf(): void
{
$this->transaction();
$this->fakeResponse(['error' => 'not ready']);
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(502);
}
public function testMissingCredentialsPreventRequests(): void
{
$this->app['config']->set('services.shoraka.secret_key', '');
Http::fake();
$this->json('POST', '/api/shoraka/orders', $this->submission())->seeStatusCode(503);
$this->json('POST', '/api/shoraka/callback', $this->callbackPayload())->seeStatusCode(503);
$this->assertSame(0, ShorakaTransaction::count());
Http::assertNothingSent();
}
public function testMissingOrderIdCannotBeRefreshedOrDownloaded(): void
{
$this->transaction('UNKNOWN', null);
Http::fake();
$this->json('POST', '/api/shoraka/orders/SAG-001/refresh')->seeStatusCode(409);
$this->get('/api/shoraka/orders/SAG-001/certificate')->seeStatusCode(409);
Http::assertNothingSent();
}
}