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
This commit is contained in:
Mazen Touati
2026-01-24 03:01:32 +01:00
committed by GitHub
parent 106bba7539
commit 2895a0ddc6
40 changed files with 2401 additions and 190 deletions

View File

@@ -0,0 +1,515 @@
<?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());
}
}

View File

@@ -39,11 +39,13 @@ export class BasePage {
await this.executeRequest();
}
async addHeader(key: string, value: string, index: number = 0) {
await this.page
.getByTestId('request-builder-root')
.getByRole('tab', { name: 'Headers' })
.click();
async addHeader(key: string, value: string, index: number = 0, skipTabNavigation: boolean = false) {
if (!skipTabNavigation) {
await this.page
.getByTestId('request-builder-root')
.getByRole('tab', { name: 'Headers' })
.click();
}
await this.page
.getByTestId('request-headers')
@@ -66,26 +68,28 @@ export class BasePage {
return { headerKey, headerValue };
}
async addQueryParameter(key: string, value: string) {
await this.page
.getByTestId('request-builder-root')
.getByRole('tab', { name: 'Parameters' })
.click();
async addQueryParameter(key: string, value: string, index: number = 0, skipTabNavigation: boolean = false) {
if (!skipTabNavigation) {
await this.page
.getByTestId('request-builder-root')
.getByRole('tab', { name: 'Parameters' })
.click();
}
await this.page
.getByTestId('request-parameters')
.getByRole('button', { name: 'Add' })
.click();
const paramKey = this.page
.getByTestId('request-parameters')
.getByTestId('kv-key')
.first();
const paramKey =
index === 0
? this.page.getByTestId('request-parameters').getByTestId('kv-key').first()
: this.page.getByTestId('request-parameters').getByTestId('kv-key').nth(index);
const paramValue = this.page
.getByTestId('request-parameters')
.getByTestId('kv-value')
.first();
const paramValue =
index === 0
? this.page.getByTestId('request-parameters').getByTestId('kv-value').first()
: this.page.getByTestId('request-parameters').getByTestId('kv-value').nth(index);
await paramKey.fill(key);
await paramValue.fill(value);

View File

@@ -13,15 +13,13 @@ import { defineConfig, devices } from '@playwright/test';
*/
export default defineConfig({
testDir: "./tests",
timeout: 120_000,
timeout: 160_000,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: 0,
/* Opt out of parallel tests on CI. */
workers: 8,
workers: process.env.CI ? 4 : 8,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */

View File

@@ -114,7 +114,7 @@ test("Dump and Die visualization sanity checklist", async ({ page }) => {
.click();
await expect(
page.locator("#reka-collapsible-content-v-126"),
page.locator("#reka-collapsible-content-v-127"),
).toMatchAriaSnapshot(`- text: "0: \\"/\\" (1) 1: \\"\\\\\\" (1)"`);
// dump #3 (runtime object)

View File

@@ -1,4 +1,4 @@
import { test, expect } from './fixtures';
import { test, expect } from '../core/fixtures';
test.describe('Search Functionality', () => {

View File

@@ -0,0 +1,152 @@
import { test, expect } from '../core/fixtures';
test('Link Sharing complete workflow', async ({ page, basePage }) => {
// Arrange
await basePage.goto();
// Act - Pick an endpoint and navigate to it
await page.getByRole('button', { name: 'shapes' }).click();
await page.getByRole('button', { name: 'POST /nested-object' }).click();
// Act - Add query parameters
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Parameters' }).click();
const { paramKey: param1Key, paramValue: param1Value } = await basePage.addQueryParameter('param1', 'value1', 0, true);
const { paramKey: param2Key, paramValue: param2Value } = await basePage.addQueryParameter('param2', 'value2', 1, true);
// Act - Add JSON payload
await page.getByRole('tab', { name: 'Body' }).click();
await page.getByRole('button', { name: 'Auto Fill' }).click();
const bodyEditor = page.getByTestId('request-builder-root').locator('.cm-content');
await expect(bodyEditor).toContainText('"name"');
// Act - Add Bearer token authorization
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Authorization' }).click();
// Select Bearer token from the authorization type dropdown
await page.getByTestId('request-authorization').getByRole('combobox').click();
await page.getByRole('option', { name: 'Bearer Token' }).click();
// Set the Bearer token value
await page.getByPlaceholder('Token').fill('test-bearer-token-12345');
// Act - Add custom headers
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Headers' }).click();
const { headerKey: header1Key, headerValue: header1Value } = await basePage.addHeader('X-Custom-Header-1', 'custom-value-1', 0, true);
const { headerKey: header2Key, headerValue: header2Value } = await basePage.addHeader('X-Custom-Header-2', 'custom-value-2', 1, true);
// Act - Execute the request
await basePage.executeRequest();
// Assert - Verify response was received
await expect(page.getByTestId('response-status-badge')).toContainText('201');
// Act - Copy the shareable link
await page.getByTestId('request-options-button').click();
await page.getByTestId('copy-shareable-link-option').click();
// Wait for the shareable link dialog to appear
await expect(page.getByTestId('shareable-link-content')).toBeVisible();
// Extract the shareable link
const shareableLink = await page.getByTestId('shareable-link-content').textContent();
// Close the dialog
await page.keyboard.press('Escape');
// Act - Clear browser storage and refresh
// Navigate to a neutral page first to ensure store doesn't re-persist currently active route
await page.goto('/demo');
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.reload();
// Assert - Verify state is empty
await expect(page.getByTestId('response-empty')).toBeVisible();
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue('');
// Act - Navigate to the copied shared link
if (!shareableLink) {
throw new Error('Shareable link was not extracted');
}
await page.goto(shareableLink);
// Assert - Verify toast notification is shown
await expect(page.getByText('Shared Request Restored')).toBeVisible();
await expect(page.getByText('Request and response have been imported from the shareable link.')).toBeVisible();
// Assert - Verify notification is shown
await expect(page.getByTestId('imported-badge')).toBeVisible();
// Assert - Verify endpoint is populated
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue('_demo/shapes/nested-object');
// Assert - Verify request tabs are populated properly
// Parameters tab
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Parameters' }).click();
await expect(page.getByTestId('request-parameters').getByTestId('kv-key').first()).toHaveValue('param1');
await expect(page.getByTestId('request-parameters').getByTestId('kv-value').first()).toHaveValue('value1');
await expect(page.getByTestId('request-parameters').getByTestId('kv-key').nth(1)).toHaveValue('param2');
await expect(page.getByTestId('request-parameters').getByTestId('kv-value').nth(1)).toHaveValue('value2');
// Body tab
await page.getByRole('tab', { name: 'Body' }).click();
await expect(bodyEditor).toContainText('"name"');
// Authorization tab
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Authorization' }).click();
// Verify Bearer Token is selected
await expect(page.getByTestId('request-authorization').getByRole('combobox')).toHaveText('Bearer Token');
// Verify token value is restored
await expect(page.getByPlaceholder('Token')).toHaveValue('test-bearer-token-12345');
// Headers tab
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Headers' }).click();
await expect(page.getByTestId('request-headers').getByTestId('kv-key').first()).toHaveValue('X-Custom-Header-1');
await expect(page.getByTestId('request-headers').getByTestId('kv-value').first()).toHaveValue('custom-value-1');
await expect(page.getByTestId('request-headers').getByTestId('kv-key').nth(1)).toHaveValue('X-Custom-Header-2');
await expect(page.getByTestId('request-headers').getByTestId('kv-value').nth(1)).toHaveValue('custom-value-2');
// Assert - Verify response tabs are populated properly
await expect(page.getByTestId('response-status-badge')).toContainText('201');
await expect(page.getByTestId('response-status-duration')).not.toHaveText('0ms');
await expect(page.getByTestId('response-status-size')).not.toHaveText('0B');
// Response tab
await page.getByTestId('response-content').getByRole('tab', { name: 'Response' }).click();
await expect(page.getByTestId('response-content')).toContainText('"data"');
// Headers tab
await page.getByTestId('response-content').getByRole('tab', { name: 'Headers' }).click();
await expect(page.getByTestId('response-content')).toBeVisible();
// Cookies tab
await page.getByTestId('response-content').getByRole('tab', { name: 'Cookies' }).click();
await expect(page.getByTestId('response-content')).toBeVisible();
});

View File

@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Vite;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Sunchayn\Nimbus\Http\Web\Controllers\NimbusIndexController;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildCurrentUserAction;
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildGlobalHeadersAction;
use Sunchayn\Nimbus\Modules\Routes\Actions\DisableThirdPartyUiAction;
@@ -50,6 +52,7 @@ class NimbusIndexTest extends TestCase
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
$buildCurrentUserActionMock = $this->mock(BuildCurrentUserAction::class);
$extractRoutesActionMock = $this->mock(ExtractRoutesAction::class);
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
// Anticipate
@@ -57,6 +60,10 @@ class NimbusIndexTest extends TestCase
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
$shareableLinkProcessorMock->shouldReceive('process')->andReturnSelf();
$shareableLinkProcessorMock->shouldReceive('getTargetApplication')->andReturnNull();
$shareableLinkProcessorMock->shouldReceive('toFrontendState')->andReturnNull();
$extractedRoutesCollectionStub = new class($expectedApplicationKey) extends ExtractedRoutesCollection
{
public function __construct(private string $key) {}
@@ -85,6 +92,8 @@ class NimbusIndexTest extends TestCase
$response->assertViewHas('currentUser', ['::current-user::']);
$response->assertViewHas('sharedState', null);
$response->assertViewHas('activeApplicationResolver', function ($resolver) use ($expectedApplicationKey) {
return $resolver->getActiveApplicationKey() === $expectedApplicationKey;
});
@@ -146,6 +155,7 @@ class NimbusIndexTest extends TestCase
$ignoreRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
$buildGlobalHeadersActionSpy = $this->spy(BuildGlobalHeadersAction::class);
$buildCurrentUserActionSpy = $this->spy(BuildCurrentUserAction::class);
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
$extractionRoutesActionMock = $this->mock(ExtractRoutesAction::class);
@@ -153,6 +163,10 @@ class NimbusIndexTest extends TestCase
// Anticipate
$shareableLinkProcessorMock->shouldReceive('process')->andReturnSelf();
$shareableLinkProcessorMock->shouldReceive('getTargetApplication')->andReturnNull();
$shareableLinkProcessorMock->shouldReceive('toFrontendState')->andReturnNull();
$extractionRoutesActionMock->shouldReceive('execute')->andThrow($exception);
// Act
@@ -222,4 +236,223 @@ class NimbusIndexTest extends TestCase
'new-app',
);
}
public function test_it_processes_valid_shareable_link(): void
{
// Arrange
$payload = [
'method' => 'GET',
'endpoint' => '/api/test',
'headers' => [],
'queryParameters' => [],
'body' => [],
'payloadType' => 'empty',
'authorization' => ['type' => 'none'],
];
$encoded = base64_encode(json_encode($payload)); // <- this is dummy payload, we are mocking the interaction.
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
// Anticipate
$shareableLinkProcessorMock
->shouldReceive('process')
->with($encoded)
->andReturnSelf();
$shareableLinkProcessorMock
->shouldReceive('getTargetApplication')
->andReturnNull();
$shareableLinkProcessorMock
->shouldReceive('toFrontendState')
->andReturn([
'payload' => $payload,
'routeExists' => true,
'error' => null,
]);
// Act
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
// Assert
$response->assertStatus(200);
$response->assertViewHas('sharedState', [
'payload' => $payload,
'routeExists' => true,
'error' => null,
]);
}
public function test_it_handles_shareable_link_with_error(): void
{
// Arrange
$invalidPayload = 'invalid-base64-string';
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
// Anticipate
$shareableLinkProcessorMock
->shouldReceive('process')
->with($invalidPayload)
->andReturnSelf();
$shareableLinkProcessorMock
->shouldReceive('getTargetApplication')
->andReturnNull();
$shareableLinkProcessorMock
->shouldReceive('toFrontendState')
->andReturn([
'payload' => null,
'routeExists' => false,
'error' => 'Failed to decode base64 payload',
]);
// Act
$response = $this->get(route('nimbus.index', ['share' => $invalidPayload]));
// Assert
$response->assertStatus(200);
$response->assertViewHas('sharedState', function ($state) {
return $state['error'] === 'Failed to decode base64 payload'
&& $state['routeExists'] === false
&& $state['payload'] === null;
});
}
public function test_it_redirects_when_shareable_link_targets_different_application(): void
{
// Arrange
$payload = [
'method' => 'GET',
'endpoint' => '/api/test',
'headers' => [],
'queryParameters' => [],
'body' => [],
'payloadType' => 'empty',
'authorization' => ['type' => 'none'],
'applicationKey' => 'other-app',
];
$encoded = base64_encode(json_encode($payload)); // <- this is dummy payload, we are mocking the interaction.
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
$activeApplicationResolverMock = $this->mock(ActiveApplicationResolver::class);
// Anticipate
$activeApplicationResolverMock
->shouldReceive('getActiveApplicationKey')
->andReturn('main-app');
$shareableLinkProcessorMock
->shouldReceive('process')
->with($encoded)
->andReturnSelf();
$shareableLinkProcessorMock
->shouldReceive('getTargetApplication')
->andReturn('other-app');
// Act
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
// Assert
$response->assertRedirect();
$response->assertCookie(
\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME,
'other-app',
);
}
public function test_it_does_not_redirect_when_shareable_link_targets_same_application(): void
{
// Arrange
$payload = [
'method' => 'GET',
'endpoint' => '/api/test',
'headers' => [],
'queryParameters' => [],
'body' => [],
'payloadType' => 'empty',
'authorization' => ['type' => 'none'],
'applicationKey' => 'main-app',
];
$encoded = base64_encode(json_encode($payload));
$disableThirdPartyUiActionSpy = $this->spy(DisableThirdPartyUiAction::class);
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
$buildCurrentUserActionMock = $this->mock(BuildCurrentUserAction::class);
$extractRoutesActionMock = $this->mock(ExtractRoutesAction::class);
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
$activeApplicationResolverMock = $this->mock(ActiveApplicationResolver::class);
// Anticipate
$activeApplicationResolverMock
->shouldReceive('getActiveApplicationKey')
->andReturn('main-app');
$activeApplicationResolverMock->shouldReceive('isVersioned')->andReturn(false);
$activeApplicationResolverMock->shouldReceive('getApiBaseUrl')->andReturn('http://localhost');
$activeApplicationResolverMock->shouldReceive('getAvailableApplications')->andReturn('{}');
$buildGlobalHeadersActionMock->shouldReceive('execute')->andReturn(['::global-headers::']);
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
$shareableLinkProcessorMock
->shouldReceive('process')
->with($encoded)
->andReturnSelf();
$shareableLinkProcessorMock
->shouldReceive('getTargetApplication')
->andReturn('main-app');
$shareableLinkProcessorMock
->shouldReceive('toFrontendState')
->andReturn([
'payload' => $payload,
'routeExists' => true,
'error' => null,
]);
$extractRoutesActionMock->shouldReceive('execute')->andReturn($this->mock(ExtractedRoutesCollection::class)->shouldReceive('toFrontendArray')->andReturn([])->getMock());
// Act
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
// Assert
$response->assertStatus(200);
$response->assertViewIs('nimbus::app');
$response->assertViewHas('sharedState', [
'payload' => $payload,
'routeExists' => true,
'error' => null,
]);
$disableThirdPartyUiActionSpy->shouldHaveReceived('execute')->once();
}
}