@@ -41,8 +41,10 @@ class RotationRepositoryTest extends TestCase
4141 /**
4242 * Build a real PDOStatement yielding the given rows
4343 *
44- * `Connection::select()` is declared to return a {@see PDOStatement} and the ORM both calls `setFetchMode()` on
45- * and iterates the result. A genuine SQLite-backed statement satisfies all of that without a production database.
44+ * `Connection::select()` is declared to return a {@see PDOStatement} and the result is both iterated and, in the
45+ * ORM's case, reconfigured via `setFetchMode()`. A genuine SQLite-backed statement satisfies all of that without a
46+ * production database. The default fetch mode mirrors the module's connection (`PDO::FETCH_OBJ`, see
47+ * `Common\Database`), which is what `move()` relies on; the ORM overrides it to `PDO::FETCH_ASSOC` itself.
4648 *
4749 * @param list<array<string, mixed>> $rows All rows must share the same keys, which become the result's columns
4850 *
@@ -53,6 +55,7 @@ private function selectResult(array $rows): PDOStatement
5355 $ columns = empty ($ rows ) ? ['id ' ] : array_keys ($ rows [0 ]);
5456
5557 $ pdo = new PDO ('sqlite::memory: ' );
58+ $ pdo ->setAttribute (PDO ::ATTR_DEFAULT_FETCH_MODE , PDO ::FETCH_OBJ );
5659 $ pdo ->exec ('CREATE TABLE result ( ' . implode (', ' , array_map (fn ($ c ) => '" ' . $ c . '" ' , $ columns )) . ') ' );
5760
5861 if (! empty ($ rows )) {
@@ -449,4 +452,189 @@ public function testDeleteSoftDeletesVersionAndShiftsSiblings(): void
449452
450453 $ this ->assertTrue ($ siblingShifted , 'The higher-priority sibling was not shifted down ' );
451454 }
455+
456+ /**
457+ * Run a directional move and verify the issued statements
458+ *
459+ * A move always frees up the rotation's current priority first, then shifts the siblings between the old and the
460+ * new priority (queried via a plain {@see Select}), and finally places the rotation at its new priority.
461+ *
462+ * @param int $currentPriority The rotation's current priority
463+ * @param int $newPriority The priority to move the rotation to
464+ * @param array<string, int> $expectedSelectWhere The conditions the sibling query is expected to carry
465+ * @param string $expectedOrder The order the siblings are expected to be queried in
466+ * @param string $expectedExpression The expression the siblings' priority is expected to be adjusted by
467+ * @param list<int> $siblingIds The ids of the siblings to be shifted, in the order they're queried
468+ *
469+ * @return void
470+ */
471+ private function assertDirectionalMove (
472+ int $ currentPriority ,
473+ int $ newPriority ,
474+ array $ expectedSelectWhere ,
475+ string $ expectedOrder ,
476+ string $ expectedExpression ,
477+ array $ siblingIds
478+ ): void {
479+ $ start = (int ) (new DateTime ())->format ('Uv ' );
480+
481+ $ rotation = (new Rotation ())->setProperties ([
482+ 'id ' => 42 ,
483+ 'schedule_id ' => 1 ,
484+ 'priority ' => $ currentPriority
485+ ]);
486+
487+ $ databaseMock = $ this ->createMock (Connection::class);
488+ $ databaseMock ->method ('quoteIdentifier ' )
489+ ->willReturnArgument (0 );
490+
491+ $ databaseMock ->expects ($ this ->never ())
492+ ->method ('insert ' );
493+
494+ $ databaseMock ->expects ($ this ->once ())
495+ ->method ('select ' )
496+ ->with ($ this ->callback (function (Select $ select ) use ($ expectedSelectWhere , $ expectedOrder ) {
497+ $ this ->assertSame (['id ' ], $ select ->getColumns ());
498+ $ this ->assertSame (['rotation ' ], $ select ->getFrom ());
499+ $ this ->assertSame ([[$ expectedOrder , null ]], $ select ->getOrderBy ());
500+ $ this ->assertSame (['AND ' , [['AND ' , $ expectedSelectWhere ]]], $ select ->getWhere ());
501+
502+ return true ;
503+ }))
504+ ->willReturn ($ this ->selectResult (array_map (fn ($ id ) => ['id ' => $ id ], $ siblingIds )));
505+
506+ $ sequence = [];
507+ $ shifted = [];
508+ $ databaseMock ->expects ($ this ->exactly (count ($ siblingIds ) + 2 ))
509+ ->method ('update ' )
510+ ->willReturnCallback (
511+ function (
512+ $ table ,
513+ $ data ,
514+ $ where
515+ ) use (
516+ $ start ,
517+ $ newPriority ,
518+ $ expectedExpression ,
519+ &$ sequence ,
520+ &$ shifted
521+ ) {
522+ $ this ->assertSame ('rotation ' , $ table );
523+
524+ if ($ where === ['id = ? ' => 42 ] && ($ data ['deleted ' ] ?? null ) === 'y ' ) {
525+ // Free up the rotation's current priority (note: this one carries no changed_at)
526+ $ this ->assertSame (['priority ' => null , 'deleted ' => 'y ' ], $ data );
527+ $ sequence [] = 'free ' ;
528+ } elseif ($ where === ['id = ? ' => 42 ]) {
529+ // Place the rotation at its new priority
530+ $ this ->assertArrayHasKey ('changed_at ' , $ data );
531+ $ this ->assertGreaterThanOrEqual ($ start , $ data ['changed_at ' ]);
532+ unset($ data ['changed_at ' ]);
533+ $ this ->assertSame (['priority ' => $ newPriority , 'deleted ' => 'n ' ], $ data );
534+ $ sequence [] = 'place ' ;
535+ } else {
536+ // Shift a sibling out of the way
537+ $ this ->assertInstanceOf (Expression::class, $ data ['priority ' ]);
538+ $ this ->assertSame ($ expectedExpression , $ data ['priority ' ]->getStatement ());
539+ $ this ->assertArrayHasKey ('changed_at ' , $ data );
540+ $ this ->assertGreaterThanOrEqual ($ start , $ data ['changed_at ' ]);
541+ $ shifted [] = (int ) $ where ['id = ? ' ];
542+ $ sequence [] = 'shift ' ;
543+ }
544+
545+ return $ this ->createStub (PDOStatement::class);
546+ }
547+ );
548+
549+ (new RotationRepository ($ databaseMock ))->move ($ rotation , $ newPriority );
550+
551+ $ this ->assertSame ('free ' , $ sequence [0 ] ?? null , 'The current priority must be freed up first ' );
552+ $ this ->assertSame ('place ' , end ($ sequence ), 'The rotation must be placed at its new priority last ' );
553+ $ this ->assertSame ($ siblingIds , $ shifted , 'The expected siblings were not shifted in the queried order ' );
554+ }
555+
556+ /**
557+ * Covers moving a rotation to a higher priority (a lower number): the siblings in between are shifted down.
558+ *
559+ * @return void
560+ */
561+ public function testMoveToHigherPriorityShiftsInBetweenSiblingsDown (): void
562+ {
563+ $ this ->assertDirectionalMove (
564+ 3 ,
565+ 1 ,
566+ ['schedule_id = ? ' => 1 , 'priority >= ? ' => 1 , 'priority < ? ' => 3 ],
567+ 'priority DESC ' ,
568+ 'priority + 1 ' ,
569+ [10 , 11 ]
570+ );
571+ }
572+
573+ /**
574+ * Covers moving a rotation to a lower priority (a higher number): the siblings in between are shifted up.
575+ *
576+ * @return void
577+ */
578+ public function testMoveToLowerPriorityShiftsInBetweenSiblingsUp (): void
579+ {
580+ $ this ->assertDirectionalMove (
581+ 1 ,
582+ 3 ,
583+ ['schedule_id = ? ' => 1 , 'priority > ? ' => 1 , 'priority <= ? ' => 3 ],
584+ 'priority ASC ' ,
585+ 'priority - 1 ' ,
586+ [20 , 21 ]
587+ );
588+ }
589+
590+ /**
591+ * Covers moving a rotation to its current priority: no siblings are queried or shifted, the rotation is merely
592+ * freed up and placed again.
593+ *
594+ * @return void
595+ */
596+ public function testMoveToSamePriorityDoesNotShiftSiblings (): void
597+ {
598+ $ start = (int ) (new DateTime ())->format ('Uv ' );
599+
600+ $ rotation = (new Rotation ())->setProperties ([
601+ 'id ' => 42 ,
602+ 'schedule_id ' => 1 ,
603+ 'priority ' => 2
604+ ]);
605+
606+ $ databaseMock = $ this ->createMock (Connection::class);
607+ $ databaseMock ->method ('quoteIdentifier ' )
608+ ->willReturnArgument (0 );
609+
610+ $ databaseMock ->expects ($ this ->never ())
611+ ->method ('select ' );
612+ $ databaseMock ->expects ($ this ->never ())
613+ ->method ('insert ' );
614+
615+ $ sequence = [];
616+ $ databaseMock ->expects ($ this ->exactly (2 ))
617+ ->method ('update ' )
618+ ->willReturnCallback (function ($ table , $ data , $ where ) use ($ start , &$ sequence ) {
619+ $ this ->assertSame ('rotation ' , $ table );
620+ $ this ->assertSame (['id = ? ' => 42 ], $ where );
621+
622+ if (($ data ['deleted ' ] ?? null ) === 'y ' ) {
623+ $ this ->assertSame (['priority ' => null , 'deleted ' => 'y ' ], $ data );
624+ $ sequence [] = 'free ' ;
625+ } else {
626+ $ this ->assertArrayHasKey ('changed_at ' , $ data );
627+ $ this ->assertGreaterThanOrEqual ($ start , $ data ['changed_at ' ]);
628+ unset($ data ['changed_at ' ]);
629+ $ this ->assertSame (['priority ' => 2 , 'deleted ' => 'n ' ], $ data );
630+ $ sequence [] = 'place ' ;
631+ }
632+
633+ return $ this ->createStub (PDOStatement::class);
634+ });
635+
636+ (new RotationRepository ($ databaseMock ))->move ($ rotation , 2 );
637+
638+ $ this ->assertSame (['free ' , 'place ' ], $ sequence );
639+ }
452640}
0 commit comments