@@ -173,6 +173,194 @@ public function installerUsesSharedSeedDefinitionsAndSeedsMenusAfterSystemModule
173173 );
174174 }
175175
176+ #[Test]
177+ public function installerInvokesForeignKeyHelperAfterSuccessfulMenuSeeding (): void
178+ {
179+ $ source = $ this ->readSourceFile ('install/include/makedata.php ' );
180+
181+ $ this ->assertMatchesRegularExpression (
182+ '/if \\s* \\( \\s*!system_menu_install_seed_defaults \\( \\$dbm, \\$groups, 1 \\) \\s* \\).*? '
183+ . 'system_menu_install_add_category_fk \\( \\$dbm \\);/s ' ,
184+ $ source ,
185+ 'make_data() must call system_menu_install_add_category_fk() after the seed-success guard '
186+ );
187+ }
188+
189+ #[Test]
190+ public function installerForeignKeyHelperBuildsPrefixedAlterTableStatement (): void
191+ {
192+ $ source = $ this ->readSourceFile ('install/include/makedata.php ' );
193+
194+ $ this ->assertStringContainsString (
195+ 'function system_menu_install_add_category_fk($dbm): bool ' ,
196+ $ source ,
197+ 'Helper must be defined in install/include/makedata.php '
198+ );
199+ $ this ->assertStringContainsString (
200+ "\$db->prefix('menusitems') " ,
201+ $ source
202+ );
203+ $ this ->assertStringContainsString (
204+ "\$db->prefix('menuscategory') " ,
205+ $ source
206+ );
207+ $ this ->assertStringContainsString (
208+ "\$db->prefix('fk_items_category') " ,
209+ $ source
210+ );
211+ $ this ->assertMatchesRegularExpression (
212+ '/ALTER TABLE `[^`]*` ADD CONSTRAINT `[^`]*`/ ' ,
213+ $ source
214+ );
215+ $ this ->assertStringContainsString (
216+ 'FOREIGN KEY (`items_cid`) REFERENCES ' ,
217+ $ source
218+ );
219+ $ this ->assertStringContainsString (
220+ 'ON DELETE CASCADE ' ,
221+ $ source
222+ );
223+ }
224+
225+ #[Test]
226+ public function installerForeignKeyHelperIsIdempotentAndNonFatal (): void
227+ {
228+ $ source = $ this ->readSourceFile ('install/include/makedata.php ' );
229+
230+ $ this ->assertStringContainsString (
231+ 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS ' ,
232+ $ source ,
233+ 'Helper must check INFORMATION_SCHEMA to stay idempotent '
234+ );
235+ $ this ->assertMatchesRegularExpression (
236+ '/getRowsNum \\( \\$result \\) \\s*> \\s*0 \\s* \\) \\s* \\{ \\s*return true;/ ' ,
237+ $ source ,
238+ 'Helper must short-circuit when the FK already exists '
239+ );
240+ $ this ->assertMatchesRegularExpression (
241+ '/trigger_error \\( \\s*[ \'"][^ \'"]*foreign key[^ \'"]*[ \'"] \\s*, \\s*E_USER_WARNING \\s* \\)/i ' ,
242+ $ source ,
243+ 'Helper must surface exec failure via trigger_error(E_USER_WARNING) '
244+ );
245+ $ this ->assertMatchesRegularExpression (
246+ '/false === \\$db->exec \\( \\$sql \\) \\) \\s* \\{[^}]*return false;/s ' ,
247+ $ source ,
248+ 'Helper must return false on exec failure without aborting install '
249+ );
250+ }
251+
252+ #[Test]
253+ public function foreignKeyHelperEmitsPrefixedAlterTableWhenFkMissing (): void
254+ {
255+ $ dbm = new class {
256+ public object $ db ;
257+ public function __construct ()
258+ {
259+ // Stubs declare no parameters; PHP accepts extra positional args silently.
260+ // This keeps the helper's $db->query($sql) etc. call sites working while
261+ // not declaring (therefore not leaving unused) the args the stub ignores.
262+ $ this ->db = new class {
263+ public array $ execCalls = [];
264+ public function prefix (string $ name ): string
265+ {
266+ return 'xo_ ' . $ name ;
267+ }
268+ public function quote (string $ value ): string
269+ {
270+ return "' " . addslashes ($ value ) . "' " ;
271+ }
272+ public function query (): bool
273+ {
274+ // Returning false makes the existence-short-circuit fall through
275+ // to the ALTER branch (the path being tested).
276+ return false ;
277+ }
278+ public function isResultSet (): bool
279+ {
280+ return false ;
281+ }
282+ public function getRowsNum (): int
283+ {
284+ return 0 ;
285+ }
286+ public function exec (string $ sql )
287+ {
288+ $ this ->execCalls [] = $ sql ;
289+ return 1 ;
290+ }
291+ };
292+ }
293+ };
294+
295+ $ result = system_menu_install_add_category_fk ($ dbm );
296+
297+ $ this ->assertTrue ($ result , 'Helper must return true when the ALTER succeeds ' );
298+ $ this ->assertCount (1 , $ dbm ->db ->execCalls , 'Helper must issue exactly one ALTER ' );
299+ $ alter = $ dbm ->db ->execCalls [0 ];
300+ $ this ->assertStringContainsString ('ALTER TABLE `xo_menusitems` ' , $ alter );
301+ $ this ->assertStringContainsString ('ADD CONSTRAINT `xo_fk_items_category` ' , $ alter );
302+ $ this ->assertStringContainsString ('FOREIGN KEY (`items_cid`) ' , $ alter );
303+ $ this ->assertStringContainsString ('REFERENCES `xo_menuscategory` (`category_id`) ' , $ alter );
304+ $ this ->assertStringContainsString ('ON DELETE CASCADE ' , $ alter );
305+ }
306+
307+ #[Test]
308+ public function foreignKeyHelperWarnsAndReturnsFalseWhenAlterFails (): void
309+ {
310+ $ dbm = new class {
311+ public object $ db ;
312+ public function __construct ()
313+ {
314+ $ this ->db = new class {
315+ public int $ execCallCount = 0 ;
316+ public function prefix (string $ name ): string
317+ {
318+ return 'xo_ ' . $ name ;
319+ }
320+ public function quote (string $ value ): string
321+ {
322+ return "' " . addslashes ($ value ) . "' " ;
323+ }
324+ public function query (): bool
325+ {
326+ return false ;
327+ }
328+ public function isResultSet (): bool
329+ {
330+ return false ;
331+ }
332+ public function getRowsNum (): int
333+ {
334+ return 0 ;
335+ }
336+ public function exec (): bool
337+ {
338+ $ this ->execCallCount ++;
339+ return false ;
340+ }
341+ };
342+ }
343+ };
344+
345+ $ warnings = [];
346+ set_error_handler (static function (int $ errno , string $ errstr ) use (&$ warnings ): bool {
347+ $ warnings [] = [$ errno , $ errstr ];
348+ return true ;
349+ });
350+
351+ try {
352+ $ result = system_menu_install_add_category_fk ($ dbm );
353+ } finally {
354+ restore_error_handler ();
355+ }
356+
357+ $ this ->assertFalse ($ result , 'Helper must return false when the ALTER fails ' );
358+ $ this ->assertSame (1 , $ dbm ->db ->execCallCount , 'Helper must attempt the ALTER exactly once ' );
359+ $ this ->assertCount (1 , $ warnings , 'Helper must emit exactly one E_USER_WARNING on failure ' );
360+ $ this ->assertSame (E_USER_WARNING , $ warnings [0 ][0 ]);
361+ $ this ->assertStringContainsString ('foreign key ' , $ warnings [0 ][1 ]);
362+ }
363+
176364 #[Test]
177365 public function systemModuleRegistersInstallAndUpdateHooksToSameScript (): void
178366 {
0 commit comments