|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace Tests\Feature; |
| 4 | + |
| 5 | +use App\Enums\RpcAction; |
| 6 | +use App\Models\EditorSession; |
| 7 | +use App\Models\Server; |
| 8 | +use App\Services\RpcBridge; |
| 9 | +use Database\Factories\ServerFactory; |
| 10 | +use Illuminate\Foundation\Testing\RefreshDatabase; |
| 11 | +use Tests\TestCase; |
| 12 | + |
| 13 | +/** |
| 14 | + * A broadcaster that cannot be reached is an infrastructure problem. It must |
| 15 | + * never turn an operation that already succeeded into a failure. |
| 16 | + */ |
| 17 | +class BroadcastFailureTest extends TestCase |
| 18 | +{ |
| 19 | + use RefreshDatabase; |
| 20 | + |
| 21 | + protected function setUp(): void |
| 22 | + { |
| 23 | + parent::setUp(); |
| 24 | + |
| 25 | + // Point the broadcaster at a port nothing listens on. |
| 26 | + config([ |
| 27 | + 'broadcasting.default' => 'reverb', |
| 28 | + 'broadcasting.connections.reverb.key' => 'key', |
| 29 | + 'broadcasting.connections.reverb.secret' => 'secret', |
| 30 | + 'broadcasting.connections.reverb.app_id' => '1', |
| 31 | + 'broadcasting.connections.reverb.options' => [ |
| 32 | + 'host' => '127.0.0.1', |
| 33 | + 'port' => 1, |
| 34 | + 'scheme' => 'http', |
| 35 | + 'useTLS' => false, |
| 36 | + ], |
| 37 | + ]); |
| 38 | + } |
| 39 | + |
| 40 | + public function test_a_save_still_succeeds_when_the_change_cannot_be_announced(): void |
| 41 | + { |
| 42 | + $session = $this->readySession(); |
| 43 | + $this->withAnsweringPlugin(); |
| 44 | + |
| 45 | + $this->actingAs($session, 'editor') |
| 46 | + ->postJson(route('api.menus.store'), [ |
| 47 | + 'platform' => 'java', |
| 48 | + 'fileName' => 'shop.yml', |
| 49 | + 'content' => 'menu: {}', |
| 50 | + ]) |
| 51 | + ->assertOk(); |
| 52 | + } |
| 53 | + |
| 54 | + public function test_a_delete_still_succeeds_when_the_change_cannot_be_announced(): void |
| 55 | + { |
| 56 | + $session = $this->readySession(); |
| 57 | + $this->withAnsweringPlugin(); |
| 58 | + |
| 59 | + $this->actingAs($session, 'editor') |
| 60 | + ->deleteJson(route('api.menus.destroy'), ['platform' => 'java', 'fileName' => 'shop.yml']) |
| 61 | + ->assertOk(); |
| 62 | + } |
| 63 | + |
| 64 | + public function test_a_heartbeat_still_succeeds_when_the_return_cannot_be_announced(): void |
| 65 | + { |
| 66 | + $server = Server::factory()->offline()->create(); |
| 67 | + EditorSession::factory()->create(['server_id' => $server->id]); |
| 68 | + |
| 69 | + $this->withHeaders(['X-Server-Uuid' => $server->uuid, 'X-Server-Token' => ServerFactory::TOKEN]) |
| 70 | + ->postJson(route('plugin.heartbeat')) |
| 71 | + ->assertOk(); |
| 72 | + |
| 73 | + $this->assertTrue($server->refresh()->isOnline()); |
| 74 | + } |
| 75 | + |
| 76 | + public function test_a_request_that_cannot_be_published_is_queued_for_the_next_poll(): void |
| 77 | + { |
| 78 | + config(['editor.rpc.timeout' => 0.2, 'editor.rpc.poll_interval_ms' => 20]); |
| 79 | + $bridge = app(RpcBridge::class); |
| 80 | + $server = Server::factory()->onChannel()->create(); |
| 81 | + |
| 82 | + rescue(fn () => $bridge->call($server, RpcAction::MenuList)); |
| 83 | + |
| 84 | + $this->assertCount(1, $bridge->collectPending($server)); |
| 85 | + } |
| 86 | + |
| 87 | + private function readySession(): EditorSession |
| 88 | + { |
| 89 | + return EditorSession::factory() |
| 90 | + ->for(Server::factory()) |
| 91 | + ->create(['confirmed' => true, 'consumed' => true, 'active' => false]); |
| 92 | + } |
| 93 | + |
| 94 | + /** |
| 95 | + * A plugin that always answers, so the test isolates what it is about: the |
| 96 | + * response of an operation whose broadcast could not go out. |
| 97 | + */ |
| 98 | + private function withAnsweringPlugin(): void |
| 99 | + { |
| 100 | + $this->app->instance(RpcBridge::class, new class extends RpcBridge |
| 101 | + { |
| 102 | + public function call(Server $server, RpcAction $action, array $payload = []): array |
| 103 | + { |
| 104 | + return ['ok' => true, 'payload' => [], 'error' => null]; |
| 105 | + } |
| 106 | + }); |
| 107 | + } |
| 108 | +} |
0 commit comments