22
33namespace Tests \Feature ;
44
5+ use App \Models \WorkerBuildIdRollout ;
6+ use App \Models \WorkerRegistration ;
57use Illuminate \Foundation \Testing \RefreshDatabase ;
68use Illuminate \Support \Facades \Queue ;
79use Tests \Feature \Concerns \ServerTestHelpers ;
810use Tests \Fixtures \InteractiveCommandWorkflow ;
911use Tests \TestCase ;
1012use Workflow \V2 \Models \WorkflowCommand ;
13+ use Workflow \V2 \Models \WorkflowRun ;
14+ use Workflow \V2 \Support \WorkerCompatibilityFleet ;
1115
1216class BridgeAdapterControllerTest extends TestCase
1317{
@@ -22,6 +26,14 @@ protected function setUp(): void
2226 $ this ->configureWorkflowTypes ([
2327 'tests.interactive-command-workflow ' => InteractiveCommandWorkflow::class,
2428 ]);
29+ WorkerCompatibilityFleet::clear ();
30+ }
31+
32+ protected function tearDown (): void
33+ {
34+ WorkerCompatibilityFleet::clear ();
35+
36+ parent ::tearDown ();
2537 }
2638
2739 public function test_webhook_bridge_starts_workflow_and_dedupes_by_provider_event (): void
@@ -229,4 +241,109 @@ public function test_webhook_bridge_uses_named_rejections(): void
229241 ->assertJsonPath ('reason ' , 'unsupported_action ' )
230242 ->assertJsonPath ('action ' , 'not_supported ' );
231243 }
244+
245+ public function test_webhook_bridge_blocks_drained_task_queue_starts_without_an_active_worker_cohort (): void
246+ {
247+ Queue::fake ();
248+
249+ WorkerRegistration::query ()->create ([
250+ 'worker_id ' => 'draining-worker ' ,
251+ 'namespace ' => 'default ' ,
252+ 'task_queue ' => 'drain-queue ' ,
253+ 'runtime ' => 'php ' ,
254+ 'sdk_version ' => '1.0.0 ' ,
255+ 'build_id ' => 'build-draining ' ,
256+ 'supported_workflow_types ' => ['tests.interactive-command-workflow ' ],
257+ 'workflow_definition_fingerprints ' => [],
258+ 'supported_activity_types ' => [],
259+ 'max_concurrent_workflow_tasks ' => 100 ,
260+ 'max_concurrent_activity_tasks ' => 100 ,
261+ 'last_heartbeat_at ' => now (),
262+ 'status ' => 'draining ' ,
263+ ]);
264+
265+ WorkerBuildIdRollout::query ()->create ([
266+ 'namespace ' => 'default ' ,
267+ 'task_queue ' => 'drain-queue ' ,
268+ 'build_id ' => 'build-draining ' ,
269+ 'drain_intent ' => 'draining ' ,
270+ 'drained_at ' => now (),
271+ ]);
272+
273+ $ response = $ this ->withHeaders ($ this ->apiHeaders ())
274+ ->postJson ('/api/bridge-adapters/webhook/stripe ' , [
275+ 'action ' => 'start_workflow ' ,
276+ 'idempotency_key ' => 'stripe-event-drain-1 ' ,
277+ 'target ' => [
278+ 'workflow_id ' => 'wf-bridge-drained-start ' ,
279+ 'workflow_type ' => 'tests.interactive-command-workflow ' ,
280+ 'task_queue ' => 'drain-queue ' ,
281+ ],
282+ ]);
283+
284+ $ response ->assertStatus (422 )
285+ ->assertJsonPath ('adapter ' , 'stripe ' )
286+ ->assertJsonPath ('action ' , 'start_workflow ' )
287+ ->assertJsonPath ('accepted ' , false )
288+ ->assertJsonPath ('outcome ' , 'rejected ' )
289+ ->assertJsonPath ('reason ' , 'task_queue_draining ' )
290+ ->assertJsonPath ('workflow_id ' , 'wf-bridge-drained-start ' )
291+ ->assertJsonPath ('workflow_type ' , 'tests.interactive-command-workflow ' )
292+ ->assertJsonPath ('task_queue ' , 'drain-queue ' )
293+ ->assertJsonPath ('routing_status ' , 'draining ' )
294+ ->assertJsonPath ('active_worker_count ' , 0 )
295+ ->assertJsonPath ('draining_worker_count ' , 1 )
296+ ->assertJsonPath ('stale_worker_count ' , 0 )
297+ ->assertJsonPath ('draining_build_ids.0 ' , 'build-draining ' )
298+ ->assertJsonPath ('drain_intent ' , 'draining ' )
299+ ->assertJsonPath (
300+ 'message ' ,
301+ 'Task queue [drain-queue] is draining and cannot accept new workflow starts until an active worker cohort is available. ' ,
302+ );
303+
304+ $ this ->assertFalse (WorkflowRun::query ()->exists ());
305+ }
306+
307+ public function test_webhook_bridge_surfaces_fail_closed_start_rejection_detail (): void
308+ {
309+ Queue::fake ();
310+
311+ config ()->set ('queue.default ' , 'redis ' );
312+ config ()->set ('queue.connections.redis.driver ' , 'redis ' );
313+ config ()->set ('workflows.v2.compatibility.current ' , 'build-a ' );
314+ config ()->set ('workflows.v2.compatibility.supported ' , ['build-a ' ]);
315+ config ()->set ('workflows.v2.fleet.validation_mode ' , 'fail ' );
316+
317+ WorkerCompatibilityFleet::record (['build-b ' ], 'redis ' , 'default ' , 'worker-build-b ' );
318+
319+ $ response = $ this ->withHeaders ($ this ->apiHeaders ())
320+ ->postJson ('/api/bridge-adapters/webhook/stripe ' , [
321+ 'action ' => 'start_workflow ' ,
322+ 'idempotency_key ' => 'stripe-event-compat-1 ' ,
323+ 'target ' => [
324+ 'workflow_id ' => 'wf-bridge-compatibility-blocked ' ,
325+ 'workflow_type ' => 'tests.interactive-command-workflow ' ,
326+ ],
327+ ]);
328+
329+ $ response ->assertStatus (422 )
330+ ->assertJsonPath ('adapter ' , 'stripe ' )
331+ ->assertJsonPath ('action ' , 'start_workflow ' )
332+ ->assertJsonPath ('accepted ' , false )
333+ ->assertJsonPath ('outcome ' , 'rejected ' )
334+ ->assertJsonPath ('reason ' , 'compatibility_blocked ' )
335+ ->assertJsonPath ('rejection_reason ' , 'compatibility_blocked ' )
336+ ->assertJsonPath ('workflow_id ' , 'wf-bridge-compatibility-blocked ' )
337+ ->assertJsonPath ('run_id ' , null )
338+ ->assertJsonPath ('workflow_type ' , 'tests.interactive-command-workflow ' )
339+ ->assertJsonPath ('control_plane_outcome ' , 'rejected_compatibility_blocked ' )
340+ ->assertJsonPath (
341+ 'message ' ,
342+ 'Workflow instance [wf-bridge-compatibility-blocked] cannot start. Start blocked under fail validation mode. '
343+ .'No active worker heartbeat advertises compatibility [build-a]. '
344+ .'Active workers there advertise [build-b]. ' ,
345+ );
346+
347+ $ this ->assertSame (0 , WorkflowRun::query ()->count ());
348+ }
232349}
0 commit comments