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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from '../core/fixtures';
|
||||
|
||||
|
||||
test.describe('Search Functionality', () => {
|
||||
|
||||
152
tests/E2E/tests/link-sharing.spec.ts
Normal file
152
tests/E2E/tests/link-sharing.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user