Files
nimbus/tests/App/Modules/Export/Services/ShareableLinkProcessorServiceUnitTest.php
Mazen Touati 2895a0ddc6 feat(export): add shareable links (#41)
* feat(export): add shareable links

* chore: reconfigure PW

* test: fix namespace

* style: apply prettier

* chore: reduce workers count in CI for PW

tests are running slower (to the point some time out) and flaky

* fix: initialize pending request from store immediately

* chore: apply rector
2026-01-24 03:01:32 +01:00

516 lines
15 KiB
PHP

<?php
namespace Sunchayn\Nimbus\Tests\App\Modules\Export\Services;
use Generator;
use Illuminate\Contracts\Config\Repository;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(ShareableLinkProcessorService::class)]
class ShareableLinkProcessorServiceUnitTest extends TestCase
{
private Repository|MockInterface $configMock;
protected function setUp(): void
{
parent::setUp();
$this->configMock = Mockery::mock(Repository::class);
}
protected function defineRoutes($router): void
{
$router->get('/api/test', fn () => 'test');
$router->post('/admin/users', fn () => 'users');
$router->get('/admin/test', fn () => 'test');
}
public function test_it_decodes_valid_shareable_link_payload(): void
{
// Arrange
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/users');
$processor = $this->createProcessor($payload);
// Act
$decodedPayload = $processor->getDecodedPayload();
// Assert
$this->assertTrue($processor->hasPayload());
$this->assertNotNull($decodedPayload);
$this->assertSame('GET', $decodedPayload['method']);
$this->assertSame('/api/users', $decodedPayload['endpoint']);
}
public function test_it_handles_invalid_base64_gracefully(): void
{
// Arrange
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process('!!!invalid-base64!!!');
// Assert
$this->assertFalse($processor->routeExists());
$this->assertNotNull($processor->getError());
}
public function test_it_preserves_headers_and_query_parameters(): void
{
// Arrange
$payload = $this->encodePayload([
...$this->basePayload(),
'method' => 'POST',
'endpoint' => '/api/orders',
'headers' => [
['key' => 'Authorization', 'value' => 'Bearer token123'],
['key' => 'Content-Type', 'value' => 'application/json'],
],
'queryParameters' => [
['key' => 'page', 'value' => '1'],
['key' => 'limit', 'value' => '25'],
],
'payloadType' => 'json',
'authorization' => ['type' => 'bearer', 'value' => 'token123'],
]);
$processor = $this->createProcessor($payload);
// Act
$decodedPayload = $processor->getDecodedPayload();
// Assert
$this->assertNotNull($decodedPayload);
$this->assertCount(2, $decodedPayload['headers']);
$this->assertSame('Authorization', $decodedPayload['headers'][0]['key']);
$this->assertCount(2, $decodedPayload['queryParameters']);
$this->assertSame('page', $decodedPayload['queryParameters'][0]['key']);
}
public function test_it_sets_route_exists_to_false_when_route_not_found(): void
{
// Arrange
$payload = $this->createMinimalPayload(method: 'DELETE', endpoint: '/api/non-existent-route');
$processor = $this->createProcessor($payload);
// Assert
$this->assertFalse($processor->routeExists());
}
public function test_to_frontend_state_returns_complete_structure(): void
{
// Arrange
$payload = $this->createMinimalPayload();
$processor = $this->createProcessor($payload);
// Act
$frontendState = $processor->toFrontendState();
// Assert
$this->assertArrayHasKey('payload', $frontendState);
$this->assertArrayHasKey('routeExists', $frontendState);
$this->assertArrayHasKey('error', $frontendState);
}
#[DataProvider('providePayloadCompressionScenarios')]
public function test_it_handles_different_payload_sizes(array $payloadData): void
{
// Arrange
$payload = $this->encodePayload($payloadData);
$processor = $this->createProcessor($payload);
// Act
$decodedPayload = $processor->getDecodedPayload();
// Assert
$this->assertTrue($processor->hasPayload());
$this->assertSame($payloadData['method'], $decodedPayload['method']);
}
public static function providePayloadCompressionScenarios(): Generator
{
yield 'minimal payload' => [
'payloadData' => [
'method' => 'GET',
'endpoint' => '/api',
'headers' => [],
'queryParameters' => [],
'body' => [],
'payloadType' => 'empty',
'authorization' => ['type' => 'none'],
],
];
yield 'payload with response' => [
'payloadData' => [
'method' => 'POST',
'endpoint' => '/api/users',
'headers' => [['key' => 'X-Test', 'value' => 'test']],
'queryParameters' => [],
'body' => [],
'payloadType' => 'json',
'authorization' => ['type' => 'none'],
'response' => [
'status' => 200,
'statusCode' => 200,
'statusText' => 'OK',
'body' => '{"id": 1, "name": "Test User"}',
'sizeInBytes' => 30,
'headers' => [],
'cookies' => [],
'timestamp' => 1234567890,
],
],
];
}
public function test_it_handles_decompression_failure(): void
{
// Arrange
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
// Create invalid compressed data (valid base64 but invalid gzip)
$invalidCompressed = base64_encode('not-gzip-data');
// Act
$processor->process($invalidCompressed);
// Assert
$this->assertFalse($processor->routeExists());
$this->assertNotNull($processor->getError());
$this->assertStringContainsString('Failed to decompress payload', $processor->getError());
}
public function test_it_handles_json_parsing_failure(): void
{
// Arrange
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
// Create valid gzip but invalid JSON
$invalidJson = gzcompress('not valid json {]');
$encoded = base64_encode($invalidJson);
// Act
$processor->process($encoded);
// Assert
$this->assertFalse($processor->routeExists());
$this->assertNotNull($processor->getError());
$this->assertStringContainsString('JSON', $processor->getError());
}
public function test_it_handles_url_safe_base64_with_padding(): void
{
// Arrange
$payload = $this->basePayload();
$payload['method'] = 'GET';
$payload['endpoint'] = '/api/test';
$json = json_encode($payload);
$compressed = gzcompress($json);
$base64 = base64_encode($compressed);
// Convert to URL-safe and remove padding
$urlSafe = rtrim(strtr($base64, '+/', '-_'), '=');
$processor = $this->createProcessor($urlSafe);
// Act
$decodedPayload = $processor->getDecodedPayload();
// Assert
$this->assertNotNull($decodedPayload);
$this->assertSame('GET', $decodedPayload['method']);
}
public function test_it_searches_other_applications_when_route_not_in_current_app(): void
{
// Arrange
$payload = $this->encodePayload([
...$this->basePayload(),
'method' => 'GET',
'endpoint' => '/other-prefix/users',
]);
// Mock config to return an application with a different prefix
// The route won't exist in current app and won't be found in other apps either
// because there are no actual routes defined
$this->configMock
->shouldReceive('get')
->with('nimbus.applications', [])
->andReturn([
'other-app' => [
'routes' => [
'prefix' => 'other-prefix',
],
],
]);
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process($payload);
// Assert
// Since no routes are defined anywhere, routeExists will be false
$this->assertFalse($processor->routeExists());
$this->assertNull($processor->getTargetApplication());
}
public function test_it_returns_null_for_target_application_when_route_in_current_app(): void
{
// Arrange
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/test');
$processor = $this->createProcessor($payload);
// Assert
$this->assertNull($processor->getTargetApplication());
}
public function test_it_handles_empty_json_decode(): void
{
// Arrange
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
// Create payload that decodes to null
$nullJson = gzcompress('null');
$encoded = base64_encode($nullJson);
// Act
$processor->process($encoded);
// Assert
$this->assertTrue($processor->hasPayload());
$this->assertSame([], $processor->getDecodedPayload());
}
public function test_it_handles_application_config_without_routes_key(): void
{
// Arrange
$payload = $this->encodePayload([
...$this->basePayload(),
'method' => 'GET',
'endpoint' => '/api/non-existent',
]);
$this->configMock
->shouldReceive('get')
->with('nimbus.applications', [])
->andReturn([
'app-without-routes' => [
'name' => 'Test App',
],
]);
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process($payload);
// Assert
$this->assertFalse($processor->routeExists());
$this->assertNull($processor->getTargetApplication());
}
public function test_frontend_state_includes_error_when_present(): void
{
// Arrange
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process('invalid-payload');
$frontendState = $processor->toFrontendState();
// Assert
$this->assertArrayHasKey('error', $frontendState);
$this->assertNotNull($frontendState['error']);
$this->assertNull($frontendState['payload']);
$this->assertFalse($frontendState['routeExists']);
}
/**
* Creates a processor with the given encoded payload and processes it.
*/
private function createProcessor(string $encodedPayload): ShareableLinkProcessorService
{
$this->expectNoApplicationLookup();
$processor = new ShareableLinkProcessorService($this->configMock);
$processor->process($encodedPayload);
return $processor;
}
/**
* Creates an encoded minimal payload with optional method and endpoint overrides.
*/
private function createMinimalPayload(string $method = 'GET', string $endpoint = '/api/test'): string
{
return $this->encodePayload([
...$this->basePayload(),
'method' => $method,
'endpoint' => $endpoint,
]);
}
/**
* Returns the base payload structure with default values.
*
* @return array<string, mixed>
*/
private function basePayload(): array
{
return [
'headers' => [],
'queryParameters' => [],
'body' => [],
'payloadType' => 'empty',
'authorization' => ['type' => 'none'],
];
}
private function expectNoApplicationLookup(): void
{
$this->configMock
->shouldReceive('get')
->with('nimbus.applications', [])
->andReturn([]);
}
/**
* Encodes a payload using zlib compression matching frontend's pako.deflate().
*
* @param array<string, mixed> $data
*/
private function encodePayload(array $data): string
{
$json = json_encode($data);
$compressed = gzcompress($json);
$base64 = base64_encode($compressed);
return rtrim(strtr($base64, '+/', '-_'), '=');
}
public function test_it_returns_early_when_route_exists_in_current_app(): void
{
// Arrange
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/test');
$processor = $this->createProcessor($payload);
// Assert
$this->assertTrue($processor->routeExists());
$this->assertNull($processor->getTargetApplication());
}
public function test_it_sets_target_application_when_route_found_in_other_app(): void
{
// Arrange
$payload = $this->encodePayload([
...$this->basePayload(),
'method' => 'POST',
'endpoint' => '/admin/users',
]);
$this->configMock->shouldReceive('get')
->with('nimbus.applications', [])
->andReturn([
'admin' => [
'routes' => ['prefix' => '/admin'],
],
]);
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process($payload);
// Assert
$this->assertTrue($processor->routeExists());
$this->assertEquals('admin', $processor->getTargetApplication());
}
public function test_it_returns_false_when_prefix_does_not_match(): void
{
// Arrange
$payload = $this->encodePayload([
...$this->basePayload(),
'method' => 'GET',
'endpoint' => '/wrong/prefix',
]);
$this->configMock->shouldReceive('get')
->with('nimbus.applications', [])
->andReturn([
'admin' => [
'routes' => ['prefix' => '/admin'],
],
]);
$processor = new ShareableLinkProcessorService($this->configMock);
// Act
$processor->process($payload);
// Assert
$this->assertFalse($processor->routeExists());
$this->assertNull($processor->getTargetApplication());
}
}