@@ -233,7 +233,165 @@ public function testGetActiveSummitsSponsorMembershipsIncludesWithPermission():
233233 }
234234
235235 // -------------------------------------------------------------------------
236- // 6. Sponsor::addUser — multi-sponsor membership
236+ // 6. Member::addSponsorPermission — concurrency
237+ // -------------------------------------------------------------------------
238+
239+ /**
240+ * Concurrent calls to addSponsorPermission for the same (member, sponsor, slug)
241+ * must not introduce duplicate entries in the Permissions JSON array.
242+ * The SELECT … FOR UPDATE row lock serialises the writers so that the
243+ * second caller reads the committed value and IF(JSON_CONTAINS(…)) is a no-op.
244+ */
245+ public function testConcurrentAddSponsorPermissionProducesNoDuplicates (): void
246+ {
247+ if (!function_exists ('pcntl_fork ' )) {
248+ $ this ->markTestSkipped ('pcntl_fork() is not available in this environment ' );
249+ }
250+
251+ $ sponsor_id = self ::$ sponsors [0 ]->getId ();
252+ $ member_id = self ::$ member ->getId ();
253+ $ concurrency = 5 ;
254+
255+ // Flush and disconnect the parent before forking so children each
256+ // get a clean connection — inherited sockets are not fork-safe.
257+ self ::$ em ->flush ();
258+ self ::$ em ->clear ();
259+ self ::$ em ->getConnection ()->close ();
260+
261+ $ pids = [];
262+ for ($ i = 0 ; $ i < $ concurrency ; $ i ++) {
263+ $ pid = pcntl_fork ();
264+ if ($ pid === -1 ) {
265+ $ this ->fail ('pcntl_fork() failed ' );
266+ }
267+ if ($ pid === 0 ) {
268+ // Child: DBAL auto-reconnects on the first query after close().
269+ try {
270+ $ conn = self ::$ em ->getConnection ();
271+ $ conn ->beginTransaction ();
272+ $ member = self ::$ member_repository ->find ($ member_id );
273+ $ member ->addSponsorPermission ($ sponsor_id , IGroup::Sponsors);
274+ $ conn ->commit ();
275+ exit (0 );
276+ } catch (\Throwable $ e ) {
277+ exit (1 );
278+ }
279+ }
280+ $ pids [] = $ pid ;
281+ }
282+
283+ // Parent: wait for all children and collect exit codes.
284+ $ failed = 0 ;
285+ foreach ($ pids as $ pid ) {
286+ pcntl_waitpid ($ pid , $ status );
287+ if (pcntl_wexitstatus ($ status ) !== 0 ) {
288+ $ failed ++;
289+ }
290+ }
291+
292+ // Reconnect the parent for the assertion query.
293+ self ::$ em ->getConnection ()->close ();
294+
295+ $ raw = self ::$ em ->getConnection ()->executeQuery (
296+ 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ? ' ,
297+ [$ sponsor_id , $ member_id ]
298+ )->fetchOne ();
299+
300+ $ permissions = json_decode ($ raw , true ) ?? [];
301+ $ occurrences = array_filter ($ permissions , fn ($ p ) => $ p === IGroup::Sponsors);
302+
303+ $ this ->assertSame (0 , $ failed , 'One or more concurrent workers exited with an error. ' );
304+ $ this ->assertCount (
305+ 1 ,
306+ $ occurrences ,
307+ 'Concurrent addSponsorPermission calls must not produce duplicate slugs in Permissions. '
308+ );
309+ }
310+
311+ // -------------------------------------------------------------------------
312+ // 7. Member::removeSponsorPermission — concurrency
313+ // -------------------------------------------------------------------------
314+
315+ /**
316+ * Concurrent calls to removeSponsorPermission for the same (member, sponsor, slug)
317+ * must leave the slug completely absent from the Permissions JSON array.
318+ * The pre-loaded Permissions intentionally contains duplicate slugs to verify
319+ * that the JSON_ARRAYAGG-based remove eliminates all occurrences in one shot
320+ * and that concurrent workers do not leave stale entries behind.
321+ */
322+ public function testConcurrentRemoveSponsorPermissionLeavesNoStaleEntries (): void
323+ {
324+ if (!function_exists ('pcntl_fork ' )) {
325+ $ this ->markTestSkipped ('pcntl_fork() is not available in this environment ' );
326+ }
327+
328+ $ sponsor_id = self ::$ sponsors [0 ]->getId ();
329+ $ member_id = self ::$ member ->getId ();
330+ $ concurrency = 5 ;
331+
332+ // Seed Permissions with duplicate slugs to exercise the remove-all path.
333+ self ::$ em ->getConnection ()->executeStatement (
334+ 'UPDATE Sponsor_Users SET Permissions = ? WHERE SponsorID = ? AND MemberID = ? ' ,
335+ [
336+ json_encode ([IGroup::Sponsors, IGroup::Sponsors, IGroup::Sponsors]),
337+ $ sponsor_id ,
338+ $ member_id ,
339+ ]
340+ );
341+
342+ self ::$ em ->flush ();
343+ self ::$ em ->clear ();
344+ self ::$ em ->getConnection ()->close ();
345+
346+ $ pids = [];
347+ for ($ i = 0 ; $ i < $ concurrency ; $ i ++) {
348+ $ pid = pcntl_fork ();
349+ if ($ pid === -1 ) {
350+ $ this ->fail ('pcntl_fork() failed ' );
351+ }
352+ if ($ pid === 0 ) {
353+ try {
354+ $ conn = self ::$ em ->getConnection ();
355+ $ conn ->beginTransaction ();
356+ $ member = self ::$ member_repository ->find ($ member_id );
357+ $ member ->removeSponsorPermission ($ sponsor_id , IGroup::Sponsors);
358+ $ conn ->commit ();
359+ exit (0 );
360+ } catch (\Throwable $ e ) {
361+ exit (1 );
362+ }
363+ }
364+ $ pids [] = $ pid ;
365+ }
366+
367+ $ failed = 0 ;
368+ foreach ($ pids as $ pid ) {
369+ pcntl_waitpid ($ pid , $ status );
370+ if (pcntl_wexitstatus ($ status ) !== 0 ) {
371+ $ failed ++;
372+ }
373+ }
374+
375+ self ::$ em ->getConnection ()->close ();
376+
377+ $ raw = self ::$ em ->getConnection ()->executeQuery (
378+ 'SELECT Permissions FROM Sponsor_Users WHERE SponsorID = ? AND MemberID = ? ' ,
379+ [$ sponsor_id , $ member_id ]
380+ )->fetchOne ();
381+
382+ $ permissions = json_decode ($ raw , true ) ?? [];
383+ $ occurrences = array_filter ($ permissions , fn ($ p ) => $ p === IGroup::Sponsors);
384+
385+ $ this ->assertSame (0 , $ failed , 'One or more concurrent workers exited with an error. ' );
386+ $ this ->assertCount (
387+ 0 ,
388+ $ occurrences ,
389+ 'Concurrent removeSponsorPermission calls must leave no stale slug occurrences in Permissions. '
390+ );
391+ }
392+
393+ // -------------------------------------------------------------------------
394+ // 8. Sponsor::addUser — multi-sponsor membership
237395 // -------------------------------------------------------------------------
238396
239397 /**
0 commit comments