create-prisma-php-app 5.0.0-alpha.8 → 5.0.0

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.
Files changed (60) hide show
  1. package/README.md +23 -2
  2. package/dist/.github/copilot-instructions.md +191 -0
  3. package/dist/AGENTS.md +588 -193
  4. package/dist/bootstrap.php +426 -213
  5. package/dist/index.js +2 -2
  6. package/dist/phpunit.xml +25 -0
  7. package/dist/postcss.config.js +4 -2
  8. package/dist/prisma-php.js +2 -2
  9. package/dist/public/.htaccess +1 -1
  10. package/dist/public/js/main.js +13 -1
  11. package/dist/public/js/pp-reactive-v2.min.js +1 -0
  12. package/dist/settings/auto-swagger-docs.ts +10 -10
  13. package/dist/settings/bs-config.ts +47 -27
  14. package/dist/settings/build.ts +2 -32
  15. package/dist/settings/component-map.ts +476 -0
  16. package/dist/settings/files-list.ts +2 -2
  17. package/dist/settings/project-name.ts +3 -6
  18. package/dist/settings/restart-mcp.ts +2 -2
  19. package/dist/settings/restart-websocket.ts +2 -2
  20. package/dist/settings/run-postcss.ts +205 -0
  21. package/dist/settings/run-tests.ts +35 -0
  22. package/dist/settings/swagger-config.ts +3 -3
  23. package/dist/settings/utils.ts +3 -3
  24. package/dist/src/Lib/Auth/Auth.php +62 -34
  25. package/dist/src/Lib/MCP/mcp-server.php +2 -3
  26. package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
  27. package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
  28. package/dist/src/Lib/Websocket/Socket.php +170 -0
  29. package/dist/src/Lib/Websocket/SocketPool.php +50 -0
  30. package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
  31. package/dist/src/Lib/Websocket/sockets.php +50 -0
  32. package/dist/src/Lib/Websocket/websocket-server.php +10 -3
  33. package/dist/src/app/globals.css +3 -1
  34. package/dist/src/app/layout.php +1 -1
  35. package/dist/tests/AuthTest.php +59 -0
  36. package/dist/tests/ConnectionManagerTest.php +277 -0
  37. package/dist/tests/CsrfTest.php +119 -0
  38. package/dist/tests/DeferComponentRootsTest.php +147 -0
  39. package/dist/tests/FeaturesTest.php +41 -0
  40. package/dist/tests/README.md +119 -0
  41. package/dist/tests/RpcWireContractTest.php +101 -0
  42. package/dist/tests/SocketPoolTest.php +69 -0
  43. package/dist/tests/SocketRegistryTest.php +77 -0
  44. package/dist/tests/SocketTest.php +124 -0
  45. package/dist/tests/SocketsRegistrationTest.php +40 -0
  46. package/dist/tests/Support/FakeConnection.php +62 -0
  47. package/dist/tests/Support/Features.php +38 -0
  48. package/dist/tests/Support/RequiresFeature.php +26 -0
  49. package/dist/tests/bootstrap.php +44 -0
  50. package/dist/ts/main.ts +21 -3
  51. package/dist/ts/tailwind-merge.ts +13 -0
  52. package/package.json +4 -4
  53. package/dist/README.md +0 -213
  54. package/dist/public/js/pp-reactive-v2.js +0 -1
  55. package/dist/settings/bs-config.json +0 -6
  56. package/dist/settings/class-imports.ts +0 -165
  57. package/dist/settings/class-log.ts +0 -244
  58. package/dist/settings/component-import-checker.ts +0 -90
  59. package/dist/settings/files-list.json +0 -1
  60. package/dist/settings/prisma-schema-config.json +0 -16
@@ -0,0 +1,147 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use PP\PHPX\TemplateCompiler;
9
+
10
+ /**
11
+ * The server-deferred component-root contract, as the app depends on it.
12
+ *
13
+ * The final document wraps every outermost `[pp-component]` root inside
14
+ * `<body>` in an inert `<template pp-component="...">`. The browser never
15
+ * executes scripts or parses `{...}` placeholders inside a template, so
16
+ * component scripts cannot run before the PulsePoint runtime module loads
17
+ * ("pp is not defined") and raw placeholders cannot trigger bogus requests
18
+ * or value coercion before hydration. `pp.mount()` materializes
19
+ * `template[pp-component]` back into live DOM before scanning for roots.
20
+ */
21
+ final class DeferComponentRootsTest extends TestCase
22
+ {
23
+ private function document(string $bodyContent): string
24
+ {
25
+ return "<html>\n<head><title>t</title></head>\n<body>\n"
26
+ . $bodyContent
27
+ . "\n</body>\n</html>";
28
+ }
29
+
30
+ public function testWrapsRouteRootInInertTemplate(): void
31
+ {
32
+ $html = $this->document(
33
+ '<div pp-component="page_abc123">'
34
+ . '<p>Count: {count}</p>'
35
+ . '<script>const [count, setCount] = pp.state(0);</script>'
36
+ . '</div>'
37
+ );
38
+
39
+ $deferred = TemplateCompiler::deferComponentRoots($html);
40
+
41
+ self::assertStringContainsString(
42
+ '<template pp-component="page_abc123"><div pp-component="page_abc123">',
43
+ $deferred
44
+ );
45
+ self::assertStringContainsString('</div></template>', $deferred);
46
+ // The component script rides along inside the inert template, unchanged.
47
+ self::assertStringContainsString(
48
+ '<script>const [count, setCount] = pp.state(0);</script>',
49
+ $deferred
50
+ );
51
+ }
52
+
53
+ public function testOnlyOutermostBoundaryIsWrapped(): void
54
+ {
55
+ $html = $this->document(
56
+ '<div pp-component="layout_1">'
57
+ . '<div pp-component="page_2"><p>{msg}</p></div>'
58
+ . '</div>'
59
+ );
60
+
61
+ $deferred = TemplateCompiler::deferComponentRoots($html);
62
+
63
+ self::assertSame(1, substr_count($deferred, '<template pp-component='));
64
+ self::assertStringContainsString('<template pp-component="layout_1">', $deferred);
65
+ // The nested boundary stays a plain element inside the inert content.
66
+ self::assertStringContainsString('<div pp-component="page_2">', $deferred);
67
+ }
68
+
69
+ public function testWrapsFooterComponentScript(): void
70
+ {
71
+ $html = $this->document(
72
+ '<div pp-component="page_1"><p>hi</p></div>'
73
+ . '<script pp-component="s9footer">pp.state(1);</script>'
74
+ );
75
+
76
+ $deferred = TemplateCompiler::deferComponentRoots($html);
77
+
78
+ self::assertStringContainsString(
79
+ '<template pp-component="s9footer"><script pp-component="s9footer">pp.state(1);</script></template>',
80
+ $deferred
81
+ );
82
+ }
83
+
84
+ public function testSiblingBoundariesAreEachWrapped(): void
85
+ {
86
+ $html = $this->document(
87
+ '<main><div pp-component="a"><span>x</span></div>'
88
+ . '<div pp-component="b"><span>y</span></div></main>'
89
+ );
90
+
91
+ $deferred = TemplateCompiler::deferComponentRoots($html);
92
+
93
+ self::assertStringContainsString('<template pp-component="a"><div pp-component="a">', $deferred);
94
+ self::assertStringContainsString('<template pp-component="b"><div pp-component="b">', $deferred);
95
+ }
96
+
97
+ public function testEscapedBraceEntitiesGainOneEncodingLayer(): void
98
+ {
99
+ // The browser's parse of the template content consumes one entity
100
+ // layer; the extra `&amp;` keeps the escape visible to the runtime.
101
+ $html = $this->document(
102
+ '<div pp-component="page_1"><code>&#123;literal&#125;</code>'
103
+ . '<script>const s = "&#123;";</script></div>'
104
+ );
105
+
106
+ $deferred = TemplateCompiler::deferComponentRoots($html);
107
+
108
+ self::assertStringContainsString('<code>&amp;#123;literal&amp;#125;</code>', $deferred);
109
+ // Script content is raw text in the template parse — left untouched.
110
+ self::assertStringContainsString('const s = "&#123;";', $deferred);
111
+ }
112
+
113
+ public function testHeadContentIsNeverWrapped(): void
114
+ {
115
+ $html = "<html>\n<head><meta name=\"x\" pp-component=\"nope\"></head>\n<body>\n"
116
+ . '<div pp-component="page_1"><p>hi</p></div>'
117
+ . "\n</body>\n</html>";
118
+
119
+ $deferred = TemplateCompiler::deferComponentRoots($html);
120
+
121
+ self::assertStringNotContainsString('<template pp-component="nope">', $deferred);
122
+ self::assertStringContainsString('<template pp-component="page_1">', $deferred);
123
+ }
124
+
125
+ public function testAlreadyDeferredTemplateIsLeftAlone(): void
126
+ {
127
+ $html = $this->document(
128
+ '<template pp-component="page_1"><div pp-component="page_1"><p>hi</p></div></template>'
129
+ );
130
+
131
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
132
+ }
133
+
134
+ public function testDocumentWithoutBoundariesIsUnchanged(): void
135
+ {
136
+ $html = $this->document('<div><p>plain</p></div>');
137
+
138
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
139
+ }
140
+
141
+ public function testUnbalancedBoundaryMarkupLeavesDocumentUntouched(): void
142
+ {
143
+ $html = $this->document('<div pp-component="page_1"><p>never closed');
144
+
145
+ self::assertSame($html, TemplateCompiler::deferComponentRoots($html));
146
+ }
147
+ }
@@ -0,0 +1,41 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Tests;
6
+
7
+ use PHPUnit\Framework\TestCase;
8
+ use Tests\Support\Features;
9
+
10
+ /**
11
+ * The suite's own feature awareness: flags come from `prisma-php.json`, the
12
+ * single source of truth for which optional scaffolds exist in this app.
13
+ */
14
+ final class FeaturesTest extends TestCase
15
+ {
16
+ public function testConfigIsReadFromPrismaPhpJson(): void
17
+ {
18
+ $raw = json_decode((string) file_get_contents(DOCUMENT_PATH . '/prisma-php.json'), true);
19
+
20
+ self::assertIsArray($raw);
21
+ self::assertSame($raw, Features::config());
22
+ }
23
+
24
+ public function testEnabledMirrorsTheBooleanFlags(): void
25
+ {
26
+ $config = Features::config();
27
+
28
+ foreach (['websocket', 'mcp', 'swaggerDocs', 'prisma', 'tailwindcss', 'typescript'] as $flag) {
29
+ self::assertSame(
30
+ ($config[$flag] ?? false) === true,
31
+ Features::enabled($flag),
32
+ "Features::enabled('$flag') mirrors prisma-php.json"
33
+ );
34
+ }
35
+ }
36
+
37
+ public function testAnUnknownFlagIsDisabled(): void
38
+ {
39
+ self::assertFalse(Features::enabled('not-a-real-feature'));
40
+ }
41
+ }
@@ -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
+ }