88use App \Support \ControlPlaneProtocol ;
99use App \Support \WorkerProtocol ;
1010use Illuminate \Foundation \Testing \RefreshDatabase ;
11+ use Illuminate \Support \Facades \DB ;
12+ use Illuminate \Testing \TestResponse ;
1113use Tests \TestCase ;
14+ use Workflow \V2 \Models \WorkflowSchedule ;
1215use Workflow \V2 \Support \WorkerCompatibilityFleet ;
1316
1417class WorkflowBootstrapRouteGatingTest extends TestCase
@@ -84,6 +87,52 @@ public function test_bootstrap_gate_runs_before_namespace_resolution_on_hosted_r
8487 ->assertJsonMissing (['reason ' => 'namespace_not_found ' ]);
8588 }
8689
90+ public function test_pending_rollout_safety_migrations_block_schedule_mutation_routes (): void
91+ {
92+ $ this ->createSchedule ('bootstrap-blocked-schedule ' );
93+ $ this ->blockWorkflowBootstrap ();
94+
95+ foreach ($ this ->blockedScheduleMutations () as [$ method , $ path , $ body ]) {
96+ $ response = $ this ->sendControlPlaneJson ($ method , $ path , $ body );
97+
98+ $ response ->assertStatus (503 )
99+ ->assertHeader (ControlPlaneProtocol::HEADER , ControlPlaneProtocol::VERSION )
100+ ->assertJsonPath ('reason ' , 'workflow_v2_blocked ' )
101+ ->assertJsonPath ('blocked_by.0 ' , 'migrations ' );
102+ }
103+ }
104+
105+ public function test_schedule_read_routes_remain_available_when_bootstrap_is_blocked (): void
106+ {
107+ $ this ->createSchedule ('bootstrap-readable-schedule ' );
108+ $ this ->blockWorkflowBootstrap ();
109+
110+ $ this ->withHeaders ($ this ->controlHeaders ('operator-token ' ))
111+ ->getJson ('/api/schedules ' )
112+ ->assertOk ()
113+ ->assertJsonPath ('schedules.0.schedule_id ' , 'bootstrap-readable-schedule ' );
114+
115+ $ this ->withHeaders ($ this ->controlHeaders ('operator-token ' ))
116+ ->getJson ('/api/schedules/bootstrap-readable-schedule ' )
117+ ->assertOk ()
118+ ->assertJsonPath ('schedule_id ' , 'bootstrap-readable-schedule ' );
119+ }
120+
121+ public function test_bootstrap_gate_runs_before_namespace_resolution_on_schedule_mutation_routes (): void
122+ {
123+ $ this ->blockWorkflowBootstrap ();
124+
125+ $ response = $ this ->sendControlPlaneJson ('POST ' , '/api/schedules ' , [
126+ 'schedule_id ' => 'ghost-namespace-schedule ' ,
127+ 'spec ' => ['cron_expressions ' => ['0 * * * * ' ]],
128+ 'action ' => ['workflow_type ' => 'GhostNamespaceWorkflow ' ],
129+ ], 'ghost-namespace ' );
130+
131+ $ response ->assertStatus (503 )
132+ ->assertJsonPath ('reason ' , 'workflow_v2_blocked ' )
133+ ->assertJsonMissing (['reason ' => 'namespace_not_found ' ]);
134+ }
135+
87136 public function test_worker_registration_can_recover_fail_mode_compatibility_health (): void
88137 {
89138 config ()->set ('queue.default ' , 'redis ' );
@@ -111,11 +160,74 @@ public function test_worker_registration_can_recover_fail_mode_compatibility_hea
111160
112161 private function blockWorkflowBootstrap (): void
113162 {
114- \ Illuminate \ Support \ Facades \ DB ::table ('migrations ' )
163+ DB ::table ('migrations ' )
115164 ->where ('migration ' , '2026_04_21_000300_add_workflow_definition_fingerprints_to_worker_registrations ' )
116165 ->delete ();
117166 }
118167
168+ /**
169+ * @return list<array{0: string, 1: string, 2: array<string, mixed>}>
170+ */
171+ private function blockedScheduleMutations (): array
172+ {
173+ return [
174+ [
175+ 'POST ' ,
176+ '/api/schedules ' ,
177+ [
178+ 'schedule_id ' => 'bootstrap-created-schedule ' ,
179+ 'spec ' => ['cron_expressions ' => ['0 * * * * ' ]],
180+ 'action ' => ['workflow_type ' => 'BootstrapWorkflow ' ],
181+ ],
182+ ],
183+ [
184+ 'PUT ' ,
185+ '/api/schedules/bootstrap-blocked-schedule ' ,
186+ ['note ' => 'Blocked update ' ],
187+ ],
188+ [
189+ 'DELETE ' ,
190+ '/api/schedules/bootstrap-blocked-schedule ' ,
191+ [],
192+ ],
193+ [
194+ 'POST ' ,
195+ '/api/schedules/bootstrap-blocked-schedule/pause ' ,
196+ ['note ' => 'Blocked pause ' ],
197+ ],
198+ [
199+ 'POST ' ,
200+ '/api/schedules/bootstrap-blocked-schedule/resume ' ,
201+ ['note ' => 'Blocked resume ' ],
202+ ],
203+ [
204+ 'POST ' ,
205+ '/api/schedules/bootstrap-blocked-schedule/trigger ' ,
206+ [],
207+ ],
208+ [
209+ 'POST ' ,
210+ '/api/schedules/bootstrap-blocked-schedule/backfill ' ,
211+ [
212+ 'start_time ' => '2026-04-18T10:00:00Z ' ,
213+ 'end_time ' => '2026-04-18T11:00:00Z ' ,
214+ ],
215+ ],
216+ ];
217+ }
218+
219+ private function createSchedule (string $ scheduleId ): WorkflowSchedule
220+ {
221+ return WorkflowSchedule ::query ()->create ([
222+ 'schedule_id ' => $ scheduleId ,
223+ 'namespace ' => 'default ' ,
224+ 'spec ' => ['cron_expressions ' => ['0 * * * * ' ], 'timezone ' => 'UTC ' ],
225+ 'action ' => ['workflow_type ' => 'BootstrapWorkflow ' ],
226+ 'overlap_policy ' => 'skip ' ,
227+ 'status ' => 'active ' ,
228+ ]);
229+ }
230+
119231 /**
120232 * @return array<string, string>
121233 */
@@ -139,4 +251,16 @@ private function controlHeaders(string $token, string $namespace = 'default'): a
139251 ControlPlaneProtocol::HEADER => ControlPlaneProtocol::VERSION ,
140252 ];
141253 }
254+
255+ /**
256+ * @param array<string, mixed> $body
257+ */
258+ private function sendControlPlaneJson (
259+ string $ method ,
260+ string $ path ,
261+ array $ body = [],
262+ string $ namespace = 'default ' ,
263+ ): TestResponse {
264+ return $ this ->json ($ method , $ path , $ body , $ this ->controlHeaders ('operator-token ' , $ namespace ));
265+ }
142266}
0 commit comments