create-prisma-php-app 5.1.0-alpha.3 → 5.1.0-alpha.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/dist/.github/copilot-instructions.md +80 -33
- package/dist/AGENTS.md +59 -25
- package/dist/bootstrap.php +207 -187
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/bs-config.ts +44 -1
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/src/Lib/Auth/Auth.php +12 -25
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Websocket/ConnectionManager.php +500 -47
- package/dist/src/Lib/Websocket/Socket.php +170 -0
- package/dist/src/Lib/Websocket/SocketPool.php +50 -0
- package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
- package/dist/src/Lib/Websocket/sockets.php +50 -0
- package/dist/src/Lib/Websocket/websocket-server.php +10 -3
- package/dist/src/app/globals.css +3 -1
- package/dist/src/app/layout.php +1 -1
- package/dist/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/DeferComponentRootsTest.php +147 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +119 -0
- package/dist/tests/RpcWireContractTest.php +101 -0
- package/dist/tests/SocketPoolTest.php +69 -0
- package/dist/tests/SocketRegistryTest.php +77 -0
- package/dist/tests/SocketTest.php +124 -0
- package/dist/tests/SocketsRegistrationTest.php +40 -0
- package/dist/tests/Support/FakeConnection.php +62 -0
- package/dist/tests/Support/Features.php +38 -0
- package/dist/tests/Support/RequiresFeature.php +26 -0
- package/dist/tests/bootstrap.php +44 -0
- package/dist/ts/main.ts +5 -8
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- package/dist/public/js/pp-reactive-v2.js +0 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# App tests
|
|
2
|
+
|
|
3
|
+
PHPUnit tests for the **application** code — `src/Lib/**`, the app's wire
|
|
4
|
+
contracts, and `src/Lib/Websocket/sockets.php` registrations — not the Prisma
|
|
5
|
+
PHP framework under `vendor/tsnc/prisma-php` (that package carries its own
|
|
6
|
+
suite in its source repo).
|
|
7
|
+
|
|
8
|
+
## The command
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm run test
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That runs PHPUnit through `settings/run-tests.ts`, which uses the PHP binary
|
|
15
|
+
from `prisma-php.json` (`phpRootPathExe`) so the suite runs with the same PHP
|
|
16
|
+
the app runs on. It exits non-zero on any failure, so CI, a pre-commit hook,
|
|
17
|
+
or an agent knows exactly when something broke.
|
|
18
|
+
|
|
19
|
+
Narrow a run with PHPUnit's own flags after `--`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm run test -- --filter CsrfTest
|
|
23
|
+
npm run test -- --filter testValidTokenRoundTrip
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Running PHPUnit directly also works: `php vendor/phpunit/phpunit/phpunit`
|
|
27
|
+
(configuration is picked up from `phpunit.xml` at the project root).
|
|
28
|
+
|
|
29
|
+
## Layout
|
|
30
|
+
|
|
31
|
+
One file per surface, flat in `tests/`:
|
|
32
|
+
|
|
33
|
+
| File | Surface |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `bootstrap.php` | Shared setup: autoloader, path constants, deterministic test env |
|
|
36
|
+
| `Support/` | Reusable fakes (`Tests\Support\...`, autoloaded via composer `autoload-dev`) |
|
|
37
|
+
| `Support/Features.php` | Feature flags read from `prisma-php.json` |
|
|
38
|
+
| `Support/RequiresFeature.php` | Skip guard for tests of optional features |
|
|
39
|
+
| `FeaturesTest.php` | The suite's own feature awareness mirrors `prisma-php.json` |
|
|
40
|
+
| `CsrfTest.php` | The `pp_csrf` cookie family and `X-CSRF-Token` validation |
|
|
41
|
+
| `RpcWireContractTest.php` | PulsePoint wire headers (`$isRpc` / `$isNavigation` / `$isWire`), including the pin that the legacy header stays dead |
|
|
42
|
+
| `DeferComponentRootsTest.php` | Server-deferred component roots: outermost `[pp-component]` body roots ship inside inert `<template pp-component>` wrappers |
|
|
43
|
+
| `SocketTest.php` | One socket connection: JSON frames, reserved error shape, close semantics |
|
|
44
|
+
| `SocketRegistryTest.php` | Named-socket registration and duplicate refusal |
|
|
45
|
+
| `SocketPoolTest.php` | Broadcast and pruning |
|
|
46
|
+
| `ConnectionManagerTest.php` | The full named-socket wire: handshake refusals, argument frame, auth, limits |
|
|
47
|
+
| `SocketsRegistrationTest.php` | The app's real `sockets.php` loads and names its sockets |
|
|
48
|
+
| `AuthTest.php` | JWT round trip, tamper/expiry/foreign-secret rejection |
|
|
49
|
+
|
|
50
|
+
## The test environment is deterministic
|
|
51
|
+
|
|
52
|
+
`tests/bootstrap.php` does **not** load the real `.env`. It loads the composer
|
|
53
|
+
autoloader plus `settings/paths.php`, then sets deterministic defaults
|
|
54
|
+
(`APP_ENV=development`, a 32-byte `AUTH_SECRET`, `FUNCTION_CALL_SECRET`) only
|
|
55
|
+
when the variable is not already set — so the suite behaves the same on any
|
|
56
|
+
machine, never depends on real secrets, and CI can still override. The Prisma
|
|
57
|
+
PHP `bootstrap.php` at the project root (route resolution, rendering, CSRF
|
|
58
|
+
cookie issuing) is **not** booted; tests exercise app classes directly.
|
|
59
|
+
|
|
60
|
+
A test that needs a different env value must set it with `putenv(...)` and
|
|
61
|
+
restore the previous value in `tearDown()` (`PP\Env` reads `getenv()` first) —
|
|
62
|
+
see `ConnectionManagerTest::setEnv()` for the pattern.
|
|
63
|
+
|
|
64
|
+
## Optional features are optional in the suite too
|
|
65
|
+
|
|
66
|
+
`prisma-php.json` decides which optional scaffolds exist (`websocket`, `mcp`,
|
|
67
|
+
`swaggerDocs`, `prisma`, ...). A test for an optional feature must guard on
|
|
68
|
+
the flag, or a freshly generated app without that feature fatals instead of
|
|
69
|
+
passing:
|
|
70
|
+
|
|
71
|
+
```php
|
|
72
|
+
use Tests\Support\RequiresFeature;
|
|
73
|
+
|
|
74
|
+
final class MySocketTest extends TestCase
|
|
75
|
+
{
|
|
76
|
+
use RequiresFeature;
|
|
77
|
+
|
|
78
|
+
protected function setUp(): void
|
|
79
|
+
{
|
|
80
|
+
$this->requireFeature('websocket'); // FIRST line, before touching the scaffold
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When the flag is off the test reports itself as skipped with the reason and
|
|
86
|
+
the enable command — never as a failure. `tearDown()` still runs after a
|
|
87
|
+
skip, so teardown code must not assume the feature's classes exist (see
|
|
88
|
+
`SocketRegistryTest::resetRegistry()` for the `class_exists` guard).
|
|
89
|
+
|
|
90
|
+
The websocket suites (`SocketTest`, `SocketRegistryTest`, `SocketPoolTest`,
|
|
91
|
+
`ConnectionManagerTest`, `SocketsRegistrationTest`) are gated this way. Core
|
|
92
|
+
surfaces — CSRF, the RPC wire headers, auth — are always-on and never gated.
|
|
93
|
+
|
|
94
|
+
## Conventions
|
|
95
|
+
|
|
96
|
+
- one `SomethingTest.php` per surface, in the `Tests` namespace; shared fakes
|
|
97
|
+
live in `tests/Support` under `Tests\Support`
|
|
98
|
+
- static registries (`SocketRegistry`) are reset in `setUp()`/`tearDown()` via
|
|
99
|
+
reflection so tests never depend on each other's order
|
|
100
|
+
- superglobals a test touches (`$_SERVER`, `$_COOKIE`, env) are backed up in
|
|
101
|
+
`setUp()` and restored in `tearDown()`
|
|
102
|
+
- wire tests assert **frames and close codes**, not internals:
|
|
103
|
+
`Tests\Support\FakeConnection` records everything the server sends, so a
|
|
104
|
+
test reads the conversation the browser would have seen
|
|
105
|
+
- expected framework failures are part of the contract — a refusal test
|
|
106
|
+
asserts the readable `{"error": "..."}` frame (or HTTP status), not just
|
|
107
|
+
"something failed"
|
|
108
|
+
- tests cover app-level code and the wire contracts the app depends on; do
|
|
109
|
+
not test Prisma PHP internals from here — those belong to the package
|
|
110
|
+
repo's own suite (`vendor/tsnc/prisma-php/tests` when vendored)
|
|
111
|
+
|
|
112
|
+
## For AI agents
|
|
113
|
+
|
|
114
|
+
Read this file plus `node_modules/prisma-php/dist/docs/testing.md` before
|
|
115
|
+
adding or changing tests. When you add an app feature with logic worth
|
|
116
|
+
protecting — an exposed function's validation, a socket handler, an auth
|
|
117
|
+
rule — add the matching `*Test.php` here in the same change, and run
|
|
118
|
+
`npm run test` before declaring the work done. A red suite is a finding to
|
|
119
|
+
fix, not to silence.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use PHPUnit\Framework\TestCase;
|
|
8
|
+
use PP\Request;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The PulsePoint wire headers, as the app depends on them.
|
|
12
|
+
*
|
|
13
|
+
* `pp.rpc(...)` posts with `X-PP-RPC: true` + `X-PP-Function`; SPA navigation
|
|
14
|
+
* fetches with `X-PP-Navigation: true`; both carry `X-PulsePoint-Wire: true`.
|
|
15
|
+
* This is a complete migration — the legacy `PP-Wire-Request` header must NOT
|
|
16
|
+
* be recognized any more, and that is pinned here on purpose.
|
|
17
|
+
*/
|
|
18
|
+
final class RpcWireContractTest extends TestCase
|
|
19
|
+
{
|
|
20
|
+
private array $serverBackup = [];
|
|
21
|
+
|
|
22
|
+
protected function setUp(): void
|
|
23
|
+
{
|
|
24
|
+
$this->serverBackup = $_SERVER;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected function tearDown(): void
|
|
28
|
+
{
|
|
29
|
+
$_SERVER = $this->serverBackup;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private function initRequest(array $server): void
|
|
33
|
+
{
|
|
34
|
+
$_SERVER = array_merge([
|
|
35
|
+
'HTTP_HOST' => 'example.test',
|
|
36
|
+
'SCRIPT_NAME' => '/index.php',
|
|
37
|
+
'REMOTE_ADDR' => '127.0.0.1',
|
|
38
|
+
], $server);
|
|
39
|
+
|
|
40
|
+
Request::init();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public function testRpcPostIsDetected(): void
|
|
44
|
+
{
|
|
45
|
+
$this->initRequest([
|
|
46
|
+
'REQUEST_METHOD' => 'POST',
|
|
47
|
+
'CONTENT_TYPE' => 'application/json',
|
|
48
|
+
'HTTP_X_PULSEPOINT_WIRE' => 'true',
|
|
49
|
+
'HTTP_X_PP_RPC' => 'true',
|
|
50
|
+
'HTTP_X_PP_FUNCTION' => 'saveProfile',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
self::assertTrue(Request::$isWire);
|
|
54
|
+
self::assertTrue(Request::$isRpc);
|
|
55
|
+
self::assertFalse(Request::$isNavigation);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public function testNavigationGetIsWireButNotRpc(): void
|
|
59
|
+
{
|
|
60
|
+
$this->initRequest([
|
|
61
|
+
'REQUEST_METHOD' => 'GET',
|
|
62
|
+
'HTTP_X_PULSEPOINT_WIRE' => 'true',
|
|
63
|
+
'HTTP_X_PP_NAVIGATION' => 'true',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
self::assertTrue(Request::$isWire);
|
|
67
|
+
self::assertFalse(Request::$isRpc);
|
|
68
|
+
self::assertTrue(Request::$isNavigation);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public function testRpcHeaderOnAGetIsNotAnRpc(): void
|
|
72
|
+
{
|
|
73
|
+
$this->initRequest([
|
|
74
|
+
'REQUEST_METHOD' => 'GET',
|
|
75
|
+
'HTTP_X_PP_RPC' => 'true',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
self::assertFalse(Request::$isRpc);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public function testPlainRequestCarriesNoWireFlags(): void
|
|
82
|
+
{
|
|
83
|
+
$this->initRequest(['REQUEST_METHOD' => 'GET']);
|
|
84
|
+
|
|
85
|
+
self::assertFalse(Request::$isWire);
|
|
86
|
+
self::assertFalse(Request::$isRpc);
|
|
87
|
+
self::assertFalse(Request::$isNavigation);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public function testLegacyWireHeaderIsNoLongerRecognized(): void
|
|
91
|
+
{
|
|
92
|
+
$this->initRequest([
|
|
93
|
+
'REQUEST_METHOD' => 'POST',
|
|
94
|
+
'CONTENT_TYPE' => 'application/json',
|
|
95
|
+
'HTTP_PP_WIRE_REQUEST' => 'true',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
self::assertFalse(Request::$isWire, 'complete migration: no legacy header support');
|
|
99
|
+
self::assertFalse(Request::$isRpc);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use Lib\Websocket\Socket;
|
|
8
|
+
use Lib\Websocket\SocketPool;
|
|
9
|
+
use PHPUnit\Framework\TestCase;
|
|
10
|
+
use Tests\Support\FakeConnection;
|
|
11
|
+
use Tests\Support\RequiresFeature;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A broadcast pool: one value to everyone, with gone connections pruned on
|
|
15
|
+
* the way.
|
|
16
|
+
*/
|
|
17
|
+
final class SocketPoolTest extends TestCase
|
|
18
|
+
{
|
|
19
|
+
use RequiresFeature;
|
|
20
|
+
|
|
21
|
+
protected function setUp(): void
|
|
22
|
+
{
|
|
23
|
+
$this->requireFeature('websocket');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function testBroadcastReachesEveryOpenSocket(): void
|
|
27
|
+
{
|
|
28
|
+
$pool = new SocketPool();
|
|
29
|
+
$connA = new FakeConnection();
|
|
30
|
+
$connB = new FakeConnection();
|
|
31
|
+
|
|
32
|
+
$pool->add(new Socket($connA, 'chat'));
|
|
33
|
+
$pool->add(new Socket($connB, 'chat'));
|
|
34
|
+
|
|
35
|
+
$pool->broadcast(['text' => 'hello room']);
|
|
36
|
+
|
|
37
|
+
self::assertSame([['text' => 'hello room']], $connA->sentFrames);
|
|
38
|
+
self::assertSame([['text' => 'hello room']], $connB->sentFrames);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public function testClosedSocketsArePrunedByBroadcast(): void
|
|
42
|
+
{
|
|
43
|
+
$pool = new SocketPool();
|
|
44
|
+
$connA = new FakeConnection();
|
|
45
|
+
$gone = new Socket(new FakeConnection(), 'chat');
|
|
46
|
+
|
|
47
|
+
$pool->add(new Socket($connA, 'chat'));
|
|
48
|
+
$pool->add($gone);
|
|
49
|
+
$gone->handleClose();
|
|
50
|
+
|
|
51
|
+
$pool->broadcast('first');
|
|
52
|
+
|
|
53
|
+
self::assertSame(1, $pool->count(), 'the closed connection was forgotten');
|
|
54
|
+
self::assertSame(['first'], $connA->sentFrames);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public function testDiscardRemovesOnlyThatSocket(): void
|
|
58
|
+
{
|
|
59
|
+
$pool = new SocketPool();
|
|
60
|
+
$keep = new Socket(new FakeConnection(), 'chat');
|
|
61
|
+
$drop = new Socket(new FakeConnection(), 'chat');
|
|
62
|
+
|
|
63
|
+
$pool->add($keep);
|
|
64
|
+
$pool->add($drop);
|
|
65
|
+
$pool->discard($drop);
|
|
66
|
+
|
|
67
|
+
self::assertSame(1, $pool->count());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use InvalidArgumentException;
|
|
8
|
+
use Lib\Websocket\SocketRegistry;
|
|
9
|
+
use PHPUnit\Framework\TestCase;
|
|
10
|
+
use ReflectionClass;
|
|
11
|
+
use Tests\Support\RequiresFeature;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The named-socket registry: names are the whole address, so they are unique
|
|
15
|
+
* application-wide and a duplicate is refused at registration time.
|
|
16
|
+
*/
|
|
17
|
+
final class SocketRegistryTest extends TestCase
|
|
18
|
+
{
|
|
19
|
+
use RequiresFeature;
|
|
20
|
+
|
|
21
|
+
protected function setUp(): void
|
|
22
|
+
{
|
|
23
|
+
$this->requireFeature('websocket');
|
|
24
|
+
self::resetRegistry();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected function tearDown(): void
|
|
28
|
+
{
|
|
29
|
+
self::resetRegistry();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public static function resetRegistry(): void
|
|
33
|
+
{
|
|
34
|
+
// tearDown() still runs after a feature skip, and in an app without
|
|
35
|
+
// the websocket scaffold the class does not exist — nothing to reset.
|
|
36
|
+
if (!class_exists(SocketRegistry::class)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
$reflection = new ReflectionClass(SocketRegistry::class);
|
|
41
|
+
$reflection->getProperty('sockets')->setValue(null, []);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public function testRegisterAndResolveByName(): void
|
|
45
|
+
{
|
|
46
|
+
$handler = static function (): void {};
|
|
47
|
+
|
|
48
|
+
SocketRegistry::register('chat', $handler, requireAuth: true, allowedRoles: ['admin']);
|
|
49
|
+
|
|
50
|
+
$entry = SocketRegistry::get('chat');
|
|
51
|
+
self::assertNotNull($entry);
|
|
52
|
+
self::assertSame('chat', $entry['name']);
|
|
53
|
+
self::assertSame($handler, $entry['handler']);
|
|
54
|
+
self::assertTrue($entry['requireAuth']);
|
|
55
|
+
self::assertSame(['admin'], $entry['allowedRoles']);
|
|
56
|
+
self::assertSame(['chat'], SocketRegistry::names());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public function testUnknownNameResolvesToNull(): void
|
|
60
|
+
{
|
|
61
|
+
self::assertNull(SocketRegistry::get('nope'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public function testDuplicateNameIsRefused(): void
|
|
65
|
+
{
|
|
66
|
+
SocketRegistry::register('chat', static function (): void {});
|
|
67
|
+
|
|
68
|
+
$this->expectException(InvalidArgumentException::class);
|
|
69
|
+
SocketRegistry::register('chat', static function (): void {});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public function testEmptyNameIsRefused(): void
|
|
73
|
+
{
|
|
74
|
+
$this->expectException(InvalidArgumentException::class);
|
|
75
|
+
SocketRegistry::register('', static function (): void {});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use InvalidArgumentException;
|
|
8
|
+
use Lib\Websocket\Socket;
|
|
9
|
+
use PHPUnit\Framework\TestCase;
|
|
10
|
+
use Tests\Support\FakeConnection;
|
|
11
|
+
use Tests\Support\RequiresFeature;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One open connection, as a socket handler holds it: JSON frames out, the
|
|
15
|
+
* reserved `{"error": ...}` shape refused, and close semantics.
|
|
16
|
+
*/
|
|
17
|
+
final class SocketTest extends TestCase
|
|
18
|
+
{
|
|
19
|
+
use RequiresFeature;
|
|
20
|
+
|
|
21
|
+
protected function setUp(): void
|
|
22
|
+
{
|
|
23
|
+
$this->requireFeature('websocket');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public function testSendEncodesOneJsonValuePerFrame(): void
|
|
27
|
+
{
|
|
28
|
+
$conn = new FakeConnection();
|
|
29
|
+
$socket = new Socket($conn, 'echo');
|
|
30
|
+
|
|
31
|
+
self::assertTrue($socket->send(['text' => 'hello']));
|
|
32
|
+
self::assertTrue($socket->send('plain'));
|
|
33
|
+
self::assertTrue($socket->send(42));
|
|
34
|
+
|
|
35
|
+
self::assertSame([['text' => 'hello'], 'plain', 42], $conn->sentFrames);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function testSendRefusesTheReservedErrorShape(): void
|
|
39
|
+
{
|
|
40
|
+
$socket = new Socket(new FakeConnection(), 'echo');
|
|
41
|
+
|
|
42
|
+
$this->expectException(InvalidArgumentException::class);
|
|
43
|
+
$socket->send(['error' => 'looks like a failure frame']);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public function testAPayloadThatMerelyContainsAnErrorKeyIsSendable(): void
|
|
47
|
+
{
|
|
48
|
+
$conn = new FakeConnection();
|
|
49
|
+
$socket = new Socket($conn, 'echo');
|
|
50
|
+
|
|
51
|
+
self::assertTrue($socket->send(['error' => 'mine', 'other' => 1]));
|
|
52
|
+
self::assertSame(['error' => 'mine', 'other' => 1], $conn->lastFrame());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public function testSendAfterCloseReturnsFalse(): void
|
|
56
|
+
{
|
|
57
|
+
$conn = new FakeConnection();
|
|
58
|
+
$socket = new Socket($conn, 'echo');
|
|
59
|
+
|
|
60
|
+
$socket->handleClose();
|
|
61
|
+
|
|
62
|
+
self::assertFalse($socket->send('late'));
|
|
63
|
+
self::assertSame([], $conn->sentFrames);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public function testMessageListenerReceivesDecodedValues(): void
|
|
67
|
+
{
|
|
68
|
+
$socket = new Socket(new FakeConnection(), 'echo');
|
|
69
|
+
|
|
70
|
+
$seen = [];
|
|
71
|
+
$socket->onMessage(function (mixed $value) use (&$seen): void {
|
|
72
|
+
$seen[] = $value;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
$socket->handleMessage(['text' => 'hi']);
|
|
76
|
+
$socket->handleMessage('plain');
|
|
77
|
+
|
|
78
|
+
self::assertSame([['text' => 'hi'], 'plain'], $seen);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public function testCloseListenerFiresExactlyOnce(): void
|
|
82
|
+
{
|
|
83
|
+
$socket = new Socket(new FakeConnection(), 'echo');
|
|
84
|
+
|
|
85
|
+
$closes = 0;
|
|
86
|
+
$socket->onClose(function () use (&$closes): void {
|
|
87
|
+
$closes++;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
$socket->handleClose();
|
|
91
|
+
$socket->handleClose();
|
|
92
|
+
|
|
93
|
+
self::assertSame(1, $closes);
|
|
94
|
+
self::assertFalse($socket->isOpen());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public function testErrorSendsTheReservedFrameThenCloses(): void
|
|
98
|
+
{
|
|
99
|
+
$conn = new FakeConnection();
|
|
100
|
+
$socket = new Socket($conn, 'echo');
|
|
101
|
+
|
|
102
|
+
$socket->error('no such room');
|
|
103
|
+
|
|
104
|
+
self::assertSame(['error' => 'no such room'], $conn->lastFrame());
|
|
105
|
+
self::assertSame([1008], $conn->closeCodes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public function testCloseUsesNormalClosure(): void
|
|
109
|
+
{
|
|
110
|
+
$conn = new FakeConnection();
|
|
111
|
+
$socket = new Socket($conn, 'echo');
|
|
112
|
+
|
|
113
|
+
$socket->close();
|
|
114
|
+
|
|
115
|
+
self::assertSame([1000], $conn->closeCodes);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public function testPayloadIsHandedToTheHandler(): void
|
|
119
|
+
{
|
|
120
|
+
$socket = new Socket(new FakeConnection(), 'echo', ['role' => 'admin']);
|
|
121
|
+
|
|
122
|
+
self::assertSame(['role' => 'admin'], $socket->payload());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests;
|
|
6
|
+
|
|
7
|
+
use Lib\Websocket\SocketRegistry;
|
|
8
|
+
use PHPUnit\Framework\TestCase;
|
|
9
|
+
use Tests\Support\RequiresFeature;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The app's real registration file loads cleanly and registers every socket
|
|
13
|
+
* the frontend connects to. A socket the browser names but the server never
|
|
14
|
+
* registered is a refused connection in production — catch it here instead.
|
|
15
|
+
*/
|
|
16
|
+
final class SocketsRegistrationTest extends TestCase
|
|
17
|
+
{
|
|
18
|
+
use RequiresFeature;
|
|
19
|
+
|
|
20
|
+
protected function setUp(): void
|
|
21
|
+
{
|
|
22
|
+
$this->requireFeature('websocket');
|
|
23
|
+
SocketRegistryTest::resetRegistry();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected function tearDown(): void
|
|
27
|
+
{
|
|
28
|
+
SocketRegistryTest::resetRegistry();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function testTheRegistrationFileLoadsAndNamesItsSockets(): void
|
|
32
|
+
{
|
|
33
|
+
require DOCUMENT_PATH . '/src/Lib/Websocket/sockets.php';
|
|
34
|
+
|
|
35
|
+
$names = SocketRegistry::names();
|
|
36
|
+
|
|
37
|
+
self::assertContains('echo', $names);
|
|
38
|
+
self::assertContains('chat', $names);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests\Support;
|
|
6
|
+
|
|
7
|
+
use Ratchet\ConnectionInterface;
|
|
8
|
+
use GuzzleHttp\Psr7\Request as Psr7Request;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Ratchet connection stand-in that records what the server sends.
|
|
12
|
+
*
|
|
13
|
+
* The named-socket wire is frames and closes, so a test only needs to see
|
|
14
|
+
* both: `$sentFrames` collects every `send(...)` payload (decoded from JSON
|
|
15
|
+
* when possible) and `$closeCodes` collects every `close(...)` code. The
|
|
16
|
+
* `httpRequest` property mirrors how Ratchet exposes the handshake request.
|
|
17
|
+
*/
|
|
18
|
+
final class FakeConnection implements ConnectionInterface
|
|
19
|
+
{
|
|
20
|
+
/** @var list<mixed> Decoded JSON frames (raw string when not JSON). */
|
|
21
|
+
public array $sentFrames = [];
|
|
22
|
+
|
|
23
|
+
/** @var list<string> Raw frames exactly as sent. */
|
|
24
|
+
public array $rawFrames = [];
|
|
25
|
+
|
|
26
|
+
/** @var list<int> */
|
|
27
|
+
public array $closeCodes = [];
|
|
28
|
+
|
|
29
|
+
public bool $isClosed = false;
|
|
30
|
+
|
|
31
|
+
public ?Psr7Request $httpRequest = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param array<string, string> $headers Handshake headers (e.g. Origin, Cookie).
|
|
35
|
+
*/
|
|
36
|
+
public function __construct(string $uri = '/__pulsepoint/ws', array $headers = [])
|
|
37
|
+
{
|
|
38
|
+
$this->httpRequest = new Psr7Request('GET', $uri, $headers);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public function send($data): ConnectionInterface
|
|
42
|
+
{
|
|
43
|
+
$this->rawFrames[] = (string) $data;
|
|
44
|
+
|
|
45
|
+
$decoded = json_decode((string) $data, true);
|
|
46
|
+
$this->sentFrames[] = json_last_error() === JSON_ERROR_NONE ? $decoded : (string) $data;
|
|
47
|
+
|
|
48
|
+
return $this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public function close($code = 1000): void
|
|
52
|
+
{
|
|
53
|
+
$this->closeCodes[] = (int) $code;
|
|
54
|
+
$this->isClosed = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The last frame the server sent, decoded. */
|
|
58
|
+
public function lastFrame(): mixed
|
|
59
|
+
{
|
|
60
|
+
return $this->sentFrames === [] ? null : end($this->sentFrames);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests\Support;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Feature flags for the test suite, read from `prisma-php.json`.
|
|
9
|
+
*
|
|
10
|
+
* `prisma-php.json` is the single source of truth for which optional Prisma
|
|
11
|
+
* PHP features are enabled in the current app (`websocket`, `mcp`,
|
|
12
|
+
* `swaggerDocs`, `prisma`, `tailwindcss`, `typescript`, `backendOnly`).
|
|
13
|
+
* Tests that exercise an optional feature's scaffold must guard on the flag —
|
|
14
|
+
* when the feature is off, its files do not exist and the test must skip,
|
|
15
|
+
* not fatal.
|
|
16
|
+
*/
|
|
17
|
+
final class Features
|
|
18
|
+
{
|
|
19
|
+
/** @var array<string, mixed>|null */
|
|
20
|
+
private static ?array $config = null;
|
|
21
|
+
|
|
22
|
+
/** @return array<string, mixed> The parsed prisma-php.json. */
|
|
23
|
+
public static function config(): array
|
|
24
|
+
{
|
|
25
|
+
if (self::$config === null) {
|
|
26
|
+
$raw = @file_get_contents(DOCUMENT_PATH . '/prisma-php.json');
|
|
27
|
+
$decoded = is_string($raw) ? json_decode($raw, true) : null;
|
|
28
|
+
self::$config = is_array($decoded) ? $decoded : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return self::$config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public static function enabled(string $feature): bool
|
|
35
|
+
{
|
|
36
|
+
return (self::config()[$feature] ?? false) === true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
namespace Tests\Support;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Skip guard for tests of optional Prisma PHP features.
|
|
9
|
+
*
|
|
10
|
+
* Call `$this->requireFeature('websocket')` as the FIRST line of `setUp()`,
|
|
11
|
+
* before touching any class from the feature's scaffold — a disabled
|
|
12
|
+
* feature's files are not generated, so reaching them would fatal instead
|
|
13
|
+
* of failing cleanly. A skipped test reports why and how to enable it.
|
|
14
|
+
*/
|
|
15
|
+
trait RequiresFeature
|
|
16
|
+
{
|
|
17
|
+
protected function requireFeature(string $feature): void
|
|
18
|
+
{
|
|
19
|
+
if (!Features::enabled($feature)) {
|
|
20
|
+
self::markTestSkipped(
|
|
21
|
+
"The `$feature` feature is disabled in prisma-php.json; "
|
|
22
|
+
. "enable it and run `npx pp update project -y` to use this suite."
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|