Release 2.59 (#5957)
This commit is contained in:
@@ -153,6 +153,21 @@ class ActivityServiceTest extends TestCase
|
||||
self::assertEquals((string) $expected, $activity->getNumber());
|
||||
}
|
||||
|
||||
public function testActivityNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$sut = $this->getSut(null, null, null, ['number_format' => '{ac,1}']);
|
||||
|
||||
$activity1 = $sut->createNewActivity();
|
||||
$activity2 = $sut->createNewActivity();
|
||||
$activity3 = $sut->createNewActivity();
|
||||
|
||||
// countActivity() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
|
||||
self::assertEquals('2', $activity1->getNumber());
|
||||
self::assertEquals('3', $activity2->getNumber());
|
||||
self::assertEquals('4', $activity3->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, string|\DateTime|int>>
|
||||
*/
|
||||
|
||||
@@ -39,6 +39,27 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertAccessIsGranted($client, '/wizard/profile');
|
||||
}
|
||||
|
||||
public function testPasswordWizard(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/password');
|
||||
}
|
||||
|
||||
public function testFinishWizard(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
// mark all wizards as seen so the WizardSubscriber does not interfere
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/finish');
|
||||
}
|
||||
|
||||
public function testWizardDoesNotAppearOnFirstLoginIfDisabled(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
@@ -72,12 +93,13 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testProfileWizardSubmitRedirectsToDoneAndMarksSeen(): void
|
||||
public function testProfileWizardSubmitMarksSeenAndRedirectsToNext(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setPreferenceValue('__wizards__', null);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
@@ -92,7 +114,11 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$values['form'][UserPreference::SKIN] = 'auto';
|
||||
$client->submit($form, $values);
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/done');
|
||||
// After a successful profile submit, the controller always redirects to
|
||||
// the virtual /wizard/next/ route — the WizardManager decides where
|
||||
// that ultimately lands. The route name and the _locale query string
|
||||
// make for a stable assertion target.
|
||||
$this->assertIsRedirect($client, '/wizard/next/', false);
|
||||
|
||||
$this->getEntityManager()->clear();
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
@@ -112,7 +138,7 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->assertIsRedirect($client, '/wizard/profile');
|
||||
}
|
||||
|
||||
public function testProfileWizardSubmitRedirectsToPasswordIfResetRequired(): void
|
||||
public function testPasswordWizardSubmitClearsResetFlagAndRedirectsToNext(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
@@ -121,19 +147,80 @@ class WizardControllerTest extends AbstractControllerBaseTestCase
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$crawler = $this->request($client, '/wizard/profile');
|
||||
$form = $crawler->filter('form[name=form]')->form();
|
||||
$crawler = $this->request($client, '/wizard/password');
|
||||
$form = $crawler->filter('form[name=user_password]')->form();
|
||||
$values = $form->getPhpValues();
|
||||
$values['form']['reload'] = '0';
|
||||
$values['user_password']['plainPassword']['first'] = 'new-pa$$word-123';
|
||||
$values['user_password']['plainPassword']['second'] = 'new-pa$$word-123';
|
||||
$client->submit($form, $values);
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/password');
|
||||
$this->assertIsRedirect($client, '/wizard/next/', false);
|
||||
|
||||
$this->getEntityManager()->clear();
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
self::assertFalse($user->requiresPasswordReset());
|
||||
}
|
||||
|
||||
public function testDoneWizard(): void
|
||||
public function testNextRedirectsToFirstUnseenStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/wizard/done');
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setPreferenceValue('__wizards__', null);
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->request($client, '/wizard/next/');
|
||||
|
||||
// The WizardSubscriber intercepts /wizard/next/ on kernel.request and
|
||||
// redirects to the first unseen step (intro, since we just cleared it).
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testNextRedirectsToFinishWhenAllStepsSeen(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$user = $this->loadUserFromDatabase(UserFixtures::USERNAME_USER);
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$user->setRequiresPasswordReset(false);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->request($client, '/wizard/next/');
|
||||
|
||||
// With nothing left to see the subscriber returns early and the
|
||||
// controller falls back to the finish page.
|
||||
$this->assertIsRedirect($client, '/wizard/finish');
|
||||
}
|
||||
|
||||
public function testPreviousRedirectsToPreviousStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->request($client, '/wizard/previous/profile');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testPreviousFallsBackToIntroWhenNoPreviousStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
// intro is the very first step, so there is nothing before it
|
||||
$this->request($client, '/wizard/previous/intro');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
|
||||
public function testPreviousFallsBackToIntroForUnknownStep(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
$this->request($client, '/wizard/previous/does-not-exist');
|
||||
|
||||
$this->assertIsRedirect($client, '/wizard/intro');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,34 @@ class CustomerServiceTest extends TestCase
|
||||
self::assertEquals($expected($date), $customer->getNumber());
|
||||
}
|
||||
|
||||
public function testCustomerNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'defaults' => [
|
||||
'customer' => [
|
||||
'timezone' => 'Europe/Vienna',
|
||||
'country' => 'IN',
|
||||
'currency' => 'RUB',
|
||||
]
|
||||
],
|
||||
'customer' => [
|
||||
'number_format' => '{cc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$customer1 = $sut->createNewCustomer('A');
|
||||
$customer2 = $sut->createNewCustomer('B');
|
||||
$customer3 = $sut->createNewCustomer('C');
|
||||
|
||||
// countCustomer() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. The in-instance counter must bump subsequent calls.
|
||||
self::assertEquals('2', $customer1->getNumber());
|
||||
self::assertEquals('3', $customer2->getNumber());
|
||||
self::assertEquals('4', $customer3->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
|
||||
@@ -777,6 +777,16 @@ class UserTest extends TestCase
|
||||
self::assertTrue($user->isPasswordRequestNonExpired(7200));
|
||||
}
|
||||
|
||||
public function testSignatureDate(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
self::assertEquals('', $user->getSignatureDate());
|
||||
$user->resetSecuritySignature();
|
||||
// shortest possible result: 2026-05-31T01:18:19Z
|
||||
self::assertGreaterThanOrEqual(20, \strlen($user->getSignatureDate()));
|
||||
}
|
||||
|
||||
private static function userWithId(int $id): User
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
162
tests/Event/WizardEventTest.php
Normal file
162
tests/Event/WizardEventTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Event;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(WizardEvent::class)]
|
||||
class WizardEventTest extends TestCase
|
||||
{
|
||||
public function testGetUserReturnsConstructorArgument(): void
|
||||
{
|
||||
$user = new User();
|
||||
$sut = new WizardEvent($user);
|
||||
|
||||
self::assertSame($user, $sut->getUser());
|
||||
}
|
||||
|
||||
public function testInitiallyEmpty(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
self::assertSame([], $sut->getSteps());
|
||||
self::assertSame([], $sut->getWizards());
|
||||
self::assertFalse($sut->hasStep('intro'));
|
||||
self::assertNull($sut->getStep('intro'));
|
||||
}
|
||||
|
||||
public function testAddStepAndAccessors(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$step = new WizardStep('intro', 'wizard_intro', 100);
|
||||
|
||||
$sut->addStep($step);
|
||||
|
||||
self::assertTrue($sut->hasStep('intro'));
|
||||
self::assertSame($step, $sut->getStep('intro'));
|
||||
self::assertSame([$step], $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testAddStepReplacesStepWithSameId(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$replacement = new WizardStep('intro', 'wizard_intro_v2', 100);
|
||||
|
||||
$sut->addStep($replacement);
|
||||
|
||||
self::assertSame($replacement, $sut->getStep('intro'));
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testGetStepsIsSortedByOrderAscending(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$intro = new WizardStep('intro', 'wizard_intro', 100);
|
||||
$profile = new WizardStep('profile', 'wizard_profile', 200);
|
||||
$plugin = new WizardStep('plugin', 'plugin_step', 150);
|
||||
|
||||
// intentionally added out of order
|
||||
$sut->addStep($profile);
|
||||
$sut->addStep($intro);
|
||||
$sut->addStep($plugin);
|
||||
|
||||
self::assertSame([$intro, $plugin, $profile], $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testRemoveStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
$sut->removeStep('intro');
|
||||
|
||||
self::assertFalse($sut->hasStep('intro'));
|
||||
self::assertTrue($sut->hasStep('profile'));
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testRemoveUnknownStepIsNoop(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
$sut->removeStep('does-not-exist');
|
||||
|
||||
self::assertCount(1, $sut->getSteps());
|
||||
}
|
||||
|
||||
public function testAddWizardCreatesStepsInInsertionOrder(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
$sut->addWizard('intro', 'wizard_intro');
|
||||
$sut->addWizard('profile', 'wizard_profile');
|
||||
|
||||
$steps = $sut->getSteps();
|
||||
self::assertCount(2, $steps);
|
||||
self::assertSame('intro', $steps[0]->id);
|
||||
self::assertSame('wizard_intro', $steps[0]->route);
|
||||
self::assertSame('profile', $steps[1]->id);
|
||||
self::assertSame('wizard_profile', $steps[1]->route);
|
||||
// addWizard must assign ascending order values so insertion order is preserved
|
||||
self::assertLessThan($steps[1]->order, $steps[0]->order);
|
||||
}
|
||||
|
||||
public function testGetWizardsReturnsIdToRouteMapInOrder(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
self::assertSame(
|
||||
['intro' => 'wizard_intro', 'profile' => 'wizard_profile'],
|
||||
$sut->getWizards()
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsRouteOfFollowingStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
self::assertSame('wizard_profile', $sut->getNextWizard('intro'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsFinishForLastStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$sut->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('profile'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardReturnsFinishForUnknownStep(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
$sut->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetNextWizardOnEmptyEventReturnsFinish(): void
|
||||
{
|
||||
$sut = new WizardEvent(new User());
|
||||
|
||||
self::assertSame('wizard_finish', $sut->getNextWizard('anything'));
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ class PasswordResetSubscriberTest extends TestCase
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'password'])
|
||||
->with('wizard_password')
|
||||
->willReturn('/wizard/password');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
namespace App\Tests\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\EventSubscriber\WizardSubscriber;
|
||||
use App\Tests\Mocks\SystemConfigurationFactory;
|
||||
use App\Wizard\WizardManager;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
@@ -41,7 +45,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->never())->method('getToken');
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard', false);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -58,7 +62,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn(null);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -89,7 +93,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub());
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub(), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent($uri);
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -113,7 +117,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -137,7 +141,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -163,7 +167,7 @@ class WizardSubscriberTest extends TestCase
|
||||
'user' => [
|
||||
'wizard' => false,
|
||||
]
|
||||
]));
|
||||
]), $this->createWizardManager());
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -181,7 +185,7 @@ class WizardSubscriberTest extends TestCase
|
||||
$urlGenerator
|
||||
->expects($this->once())
|
||||
->method('generate')
|
||||
->with('wizard', ['wizard' => 'profile'])
|
||||
->with('wizard_profile')
|
||||
->willReturn('/wizard/profile');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
@@ -190,11 +194,16 @@ class WizardSubscriberTest extends TestCase
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
|
||||
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
});
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]));
|
||||
]), $manager);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
@@ -204,6 +213,39 @@ class WizardSubscriberTest extends TestCase
|
||||
self::assertSame('/wizard/profile', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testOnKernelRequestDoesNotRedirectWhenAllStepsSeen(): void
|
||||
{
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
$token = $this->createUserToken($user);
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
|
||||
$security = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$security->expects($this->once())->method('isGranted')->with('IS_AUTHENTICATED_FULLY')->willReturn(true);
|
||||
|
||||
$storage = $this->createMock(TokenStorageInterface::class);
|
||||
$storage->expects($this->once())->method('getToken')->willReturn($token);
|
||||
|
||||
$manager = $this->createWizardManager(static function (WizardEvent $event): void {
|
||||
$event->addStep(new WizardStep('intro', 'wizard_intro', 100));
|
||||
$event->addStep(new WizardStep('profile', 'wizard_profile', 200));
|
||||
});
|
||||
|
||||
$sut = new WizardSubscriber($urlGenerator, $security, $storage, SystemConfigurationFactory::createStub([
|
||||
'user' => [
|
||||
'wizard' => true,
|
||||
]
|
||||
]), $manager);
|
||||
$event = $this->createRequestEvent('/dashboard');
|
||||
|
||||
$sut->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
private function createUserToken(User $user): TokenInterface
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
@@ -219,4 +261,21 @@ class WizardSubscriberTest extends TestCase
|
||||
|
||||
return new RequestEvent($kernel, $request, $mainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param (callable(WizardEvent): void)|null $stepRegistrar
|
||||
*/
|
||||
private function createWizardManager(?callable $stepRegistrar = null): WizardManager
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
|
||||
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
|
||||
$stepRegistrar($event);
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return new WizardManager($dispatcher);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
|
||||
'entry.user_alias',
|
||||
'entry.user_display',
|
||||
'entry.user_title',
|
||||
'entry.user_account',
|
||||
'entry.user_preference.foo',
|
||||
'entry.user_preference.mad',
|
||||
'entry.activity',
|
||||
|
||||
@@ -37,6 +37,7 @@ class InvoiceModelUserHydratorTest extends TestCase
|
||||
'user.email',
|
||||
'user.name',
|
||||
'user.title',
|
||||
'user.account',
|
||||
'user.meta.hello',
|
||||
'user.meta.kitty',
|
||||
];
|
||||
|
||||
@@ -205,6 +205,7 @@ class DebugRendererTest extends TestCase
|
||||
'activity.budget_open_plain',
|
||||
'activity.time_budget_open',
|
||||
'activity.time_budget_open_plain',
|
||||
'user.account',
|
||||
'user.alias',
|
||||
'user.display',
|
||||
'user.email',
|
||||
@@ -341,6 +342,7 @@ class DebugRendererTest extends TestCase
|
||||
'entry.user_display',
|
||||
'entry.user_alias',
|
||||
'entry.user_title',
|
||||
'entry.user_account',
|
||||
'entry.user_preference.foo',
|
||||
'entry.user_preference.mad',
|
||||
'entry.activity',
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Pdf\SafeRemoteContentClient;
|
||||
use Mpdf\PsrHttpMessageShim\Request;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
@@ -27,7 +28,7 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
new MockResponse('image-bytes', ['http_code' => 200])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/logo.png'));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
@@ -40,7 +41,7 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
new MockResponse('not found', ['http_code' => 404])
|
||||
);
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$sut = new SafeRemoteContentClient($client, $this->createStub(LoggerInterface::class));
|
||||
$response = $sut->sendRequest(new Request('GET', 'https://example.com/missing.png'));
|
||||
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
@@ -55,7 +56,10 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
throw new TransportException('IP blocked');
|
||||
});
|
||||
|
||||
$sut = new SafeRemoteContentClient($client);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('error');
|
||||
|
||||
$sut = new SafeRemoteContentClient($client, $logger);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
@@ -68,7 +72,10 @@ class SafeRemoteContentClientTest extends TestCase
|
||||
$inner = new MockHttpClient(new MockResponse('should-not-be-reached'));
|
||||
$safe = new NoPrivateNetworkHttpClient($inner);
|
||||
|
||||
$sut = new SafeRemoteContentClient($safe);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('error');
|
||||
|
||||
$sut = new SafeRemoteContentClient($safe, $logger);
|
||||
$response = $sut->sendRequest(new Request('GET', 'http://127.0.0.1/internal'));
|
||||
|
||||
self::assertSame(502, $response->getStatusCode());
|
||||
|
||||
@@ -193,6 +193,65 @@ class ProjectServiceTest extends TestCase
|
||||
self::assertEquals($expected($date), $project->getNumber());
|
||||
}
|
||||
|
||||
public function testProjectNumberIncrementsForMultipleCreateCallsOnSameInstance(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'project' => [
|
||||
'copy_teams_on_create' => false,
|
||||
'number_format' => '{pc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$sut = $this->getSut(null, null, $configuration);
|
||||
|
||||
$project1 = $sut->createNewProject();
|
||||
$project2 = $sut->createNewProject();
|
||||
$project3 = $sut->createNewProject();
|
||||
|
||||
// countProject() is mocked and returns 0, the formatter normalizes increaseBy=0 to 1,
|
||||
// so the first generated number is 2. Without the in-instance counter all three
|
||||
// calls would re-use "2" — which is exactly the bug reported by the importer.
|
||||
self::assertEquals('2', $project1->getNumber());
|
||||
self::assertEquals('3', $project2->getNumber());
|
||||
self::assertEquals('4', $project3->getNumber());
|
||||
}
|
||||
|
||||
public function testProjectNumberSkipsAlreadyExistingNumbers(): void
|
||||
{
|
||||
$configuration = SystemConfigurationFactory::createStub([
|
||||
'project' => [
|
||||
'copy_teams_on_create' => false,
|
||||
'number_format' => '{pc,1}',
|
||||
]
|
||||
]);
|
||||
|
||||
$repository = $this->createMock(ProjectRepository::class);
|
||||
$repository->method('countProject')->willReturn(0);
|
||||
// Pretend the database already contains projects with numbers 2 and 3 — the
|
||||
// service must skip them and only return the next unused number.
|
||||
$repository->method('findOneBy')->willReturnCallback(function (array $criteria): ?Project {
|
||||
if (\in_array($criteria['number'] ?? null, ['2', '3'], true)) {
|
||||
return new Project();
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static fn ($event) => $event);
|
||||
|
||||
$validator = $this->createMock(ValidatorInterface::class);
|
||||
$validator->method('validate')->willReturn(new ConstraintViolationList());
|
||||
|
||||
$sut = new ProjectService($repository, $configuration, $dispatcher, $validator);
|
||||
|
||||
$project1 = $sut->createNewProject();
|
||||
$project2 = $sut->createNewProject();
|
||||
|
||||
self::assertEquals('4', $project1->getNumber());
|
||||
self::assertEquals('5', $project2->getNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: \Closure(\DateTimeInterface): string}>
|
||||
*/
|
||||
|
||||
48
tests/Security/ApiAccessControlTest.php
Normal file
48
tests/Security/ApiAccessControlTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Http\AccessMapInterface;
|
||||
|
||||
/**
|
||||
* Regression test for the 2FA-API-bypass security advisory.
|
||||
*
|
||||
* The firewall-level access_control for ^/api was raised from IS_AUTHENTICATED
|
||||
* to IS_AUTHENTICATED_REMEMBERED so that a TwoFactorToken (which only satisfies
|
||||
* IS_AUTHENTICATED) can no longer reach any /api/* route, while remember_me
|
||||
* sessions used by the web frontend continue to work.
|
||||
*/
|
||||
#[Group('integration')]
|
||||
class ApiAccessControlTest extends KernelTestCase
|
||||
{
|
||||
public function testApiRouteRequiresAuthenticatedRemembered(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$accessMap = self::getContainer()->get('security.access_map');
|
||||
self::assertInstanceOf(AccessMapInterface::class, $accessMap);
|
||||
|
||||
[$attributes] = $accessMap->getPatterns(Request::create('/api/users/me'));
|
||||
|
||||
self::assertIsArray($attributes);
|
||||
self::assertContains(
|
||||
'IS_AUTHENTICATED_REMEMBERED',
|
||||
$attributes,
|
||||
'API access_control must require IS_AUTHENTICATED_REMEMBERED to keep a TwoFactorToken from reaching /api/*'
|
||||
);
|
||||
self::assertNotContains(
|
||||
'IS_AUTHENTICATED',
|
||||
$attributes,
|
||||
'API access_control must not fall back to IS_AUTHENTICATED, which a TwoFactorToken satisfies'
|
||||
);
|
||||
}
|
||||
}
|
||||
139
tests/Voter/ApiVoterTest.php
Normal file
139
tests/Voter/ApiVoterTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Voter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Voter\ApiVoter;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorToken;
|
||||
use Scheb\TwoFactorBundle\Security\Authentication\Token\TwoFactorTokenInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||
|
||||
#[CoversClass(ApiVoter::class)]
|
||||
class ApiVoterTest extends AbstractVoterTestCase
|
||||
{
|
||||
private function createApiVoter(bool $twoFactorInProgress = false, array $rolePermissions = []): ApiVoter
|
||||
{
|
||||
$checker = $this->createMock(AuthorizationCheckerInterface::class);
|
||||
$checker->method('isGranted')->willReturnCallback(
|
||||
static fn (string $attribute): bool => $attribute === 'IS_AUTHENTICATED_2FA_IN_PROGRESS' && $twoFactorInProgress
|
||||
);
|
||||
|
||||
$permissionManager = $rolePermissions === []
|
||||
? $this->getRolePermissionManager()
|
||||
: $this->getRolePermissionManager($rolePermissions, true);
|
||||
|
||||
return new ApiVoter($permissionManager, $checker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the 2FA-bypass security advisory: the session cookie
|
||||
* issued after the password step (which still carries a TwoFactorToken) must
|
||||
* not grant access to any #[IsGranted('API')] endpoint.
|
||||
*/
|
||||
public function testTwoFactorTokenIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$inner = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
$token = new TwoFactorToken($inner, null, 'secured_area', ['totp']);
|
||||
|
||||
self::assertInstanceOf(TwoFactorTokenInterface::class, $token);
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense-in-depth branch: even if a future Scheb release stopped using a
|
||||
* TwoFactorTokenInterface, the IS_AUTHENTICATED_2FA_IN_PROGRESS role check
|
||||
* must still deny.
|
||||
*/
|
||||
public function testTwoFactorInProgressFromAuthCheckerIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter(twoFactorInProgress: true)->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the security.yaml change to IS_AUTHENTICATED_REMEMBERED:
|
||||
* a remember_me-backed session (the frontend uses the API this way because
|
||||
* always_remember_me is enabled) must still pass the voter.
|
||||
*/
|
||||
public function testRememberMeSessionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new RememberMeToken($user, 'secured_area', 'secret');
|
||||
|
||||
self::assertNotInstanceOf(TwoFactorTokenInterface::class, $token); // @phpstan-ignore staticMethod.alreadyNarrowedType
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testRegularSessionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'secured_area', $user->getRoles());
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testApiTokenWithoutPermissionIsDenied(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
|
||||
$token->setAttribute('api-token', true);
|
||||
|
||||
// ROLE_USER does not carry 'api_access' in the default permission map
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testApiTokenWithPermissionIsGranted(): void
|
||||
{
|
||||
$user = self::getUser(1, User::ROLE_USER);
|
||||
$token = new UsernamePasswordToken($user, 'api', $user->getRoles());
|
||||
$token->setAttribute('api-token', true);
|
||||
|
||||
$voter = $this->createApiVoter(rolePermissions: ['ROLE_USER' => ['api_access']]);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_GRANTED,
|
||||
$voter->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testNonUserSubjectIsDenied(): void
|
||||
{
|
||||
$token = $this->createMock(TokenInterface::class);
|
||||
$token->method('getUser')->willReturn(null);
|
||||
|
||||
self::assertSame(
|
||||
VoterInterface::ACCESS_DENIED,
|
||||
$this->createApiVoter()->vote($token, null, ['API'])
|
||||
);
|
||||
}
|
||||
}
|
||||
198
tests/Wizard/WizardManagerTest.php
Normal file
198
tests/Wizard/WizardManagerTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Wizard;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\WizardEvent;
|
||||
use App\Wizard\WizardManager;
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
#[CoversClass(WizardManager::class)]
|
||||
class WizardManagerTest extends TestCase
|
||||
{
|
||||
public function testGetStepsIncludesBuiltInIntroAndProfile(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$steps = $sut->getSteps(new User());
|
||||
|
||||
self::assertCount(2, $steps);
|
||||
self::assertSame('intro', $steps[0]->id);
|
||||
self::assertSame('wizard_intro', $steps[0]->route);
|
||||
self::assertSame('profile', $steps[1]->id);
|
||||
self::assertSame('wizard_profile', $steps[1]->route);
|
||||
}
|
||||
|
||||
public function testGetStepsIncludesPluginStepsInSortedOrder(): void
|
||||
{
|
||||
$sut = $this->createManager(static function (WizardEvent $event): void {
|
||||
// plugin step inserted between intro (100) and profile (200)
|
||||
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
|
||||
});
|
||||
|
||||
$ids = array_map(static fn (WizardStep $s): string => $s->id, $sut->getSteps(new User()));
|
||||
|
||||
self::assertSame(['intro', 'plugin', 'profile'], $ids);
|
||||
}
|
||||
|
||||
public function testGetStepsDispatchesEventOncePerCall(): void
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->expects($this->exactly(2))
|
||||
->method('dispatch')
|
||||
->willReturnArgument(0);
|
||||
|
||||
$sut = new WizardManager($dispatcher);
|
||||
$sut->getSteps(new User());
|
||||
$sut->getSteps(new User());
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepReturnsFirstStepInitially(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getFirstUnseenStep(new User());
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('intro', $step->id);
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepSkipsSeenSteps(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
|
||||
$step = $sut->getFirstUnseenStep($user);
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('profile', $step->id);
|
||||
}
|
||||
|
||||
public function testGetFirstUnseenStepReturnsNullWhenAllSeen(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
$user = new User();
|
||||
$user->setWizardAsSeen('intro');
|
||||
$user->setWizardAsSeen('profile');
|
||||
|
||||
self::assertNull($sut->getFirstUnseenStep($user));
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsFollowingStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getNextStep(new User(), 'intro');
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('profile', $step->id);
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsNullForLastStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getNextStep(new User(), 'profile'));
|
||||
}
|
||||
|
||||
public function testGetNextStepReturnsNullForUnknownStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getNextStep(new User(), 'does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsPrecedingStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$step = $sut->getPreviousStep(new User(), 'profile');
|
||||
|
||||
self::assertNotNull($step);
|
||||
self::assertSame('intro', $step->id);
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsNullForFirstStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getPreviousStep(new User(), 'intro'));
|
||||
}
|
||||
|
||||
public function testGetPreviousStepReturnsNullForUnknownStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertNull($sut->getPreviousStep(new User(), 'does-not-exist'));
|
||||
}
|
||||
|
||||
public function testGetNavigationReturnsPreviousAndNextRouteNames(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
self::assertSame(
|
||||
['previous' => 'wizard_intro', 'next' => 'wizard_finish'],
|
||||
$sut->getNavigation(new User(), 'profile')
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetNavigationFallsBackToWizardFinishForLastStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$nav = $sut->getNavigation(new User(), 'profile');
|
||||
|
||||
self::assertSame('wizard_finish', $nav['next']);
|
||||
}
|
||||
|
||||
public function testGetNavigationReturnsNullPreviousForFirstStep(): void
|
||||
{
|
||||
$sut = $this->createManager();
|
||||
|
||||
$nav = $sut->getNavigation(new User(), 'intro');
|
||||
|
||||
self::assertNull($nav['previous']);
|
||||
self::assertSame('wizard_profile', $nav['next']);
|
||||
}
|
||||
|
||||
public function testGetNavigationResolvesPluginNeighbours(): void
|
||||
{
|
||||
$sut = $this->createManager(static function (WizardEvent $event): void {
|
||||
// inserts itself between intro (100) and profile (200)
|
||||
$event->addStep(new WizardStep('plugin', 'plugin_route', 150));
|
||||
});
|
||||
|
||||
self::assertSame(
|
||||
['previous' => 'wizard_intro', 'next' => 'wizard_profile'],
|
||||
$sut->getNavigation(new User(), 'plugin')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param (callable(WizardEvent): void)|null $stepRegistrar
|
||||
*/
|
||||
private function createManager(?callable $stepRegistrar = null): WizardManager
|
||||
{
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$dispatcher->method('dispatch')->willReturnCallback(static function (object $event) use ($stepRegistrar): object {
|
||||
if ($stepRegistrar !== null && $event instanceof WizardEvent) {
|
||||
$stepRegistrar($event);
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
return new WizardManager($dispatcher);
|
||||
}
|
||||
}
|
||||
34
tests/Wizard/WizardStepTest.php
Normal file
34
tests/Wizard/WizardStepTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Wizard;
|
||||
|
||||
use App\Wizard\WizardStep;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(WizardStep::class)]
|
||||
class WizardStepTest extends TestCase
|
||||
{
|
||||
public function testConstructorAssignsAllProperties(): void
|
||||
{
|
||||
$step = new WizardStep('intro', 'wizard_intro', 100);
|
||||
|
||||
self::assertSame('intro', $step->id);
|
||||
self::assertSame('wizard_intro', $step->route);
|
||||
self::assertSame(100, $step->order);
|
||||
}
|
||||
|
||||
public function testOrderDefaultsToZero(): void
|
||||
{
|
||||
$step = new WizardStep('intro', 'wizard_intro');
|
||||
|
||||
self::assertSame(0, $step->order);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user