timesheet lockdown with grace period (#1644)

This commit is contained in:
Honza Kopecký
2020-07-02 18:54:17 +02:00
committed by GitHub
parent e22830790b
commit 091740f407
32 changed files with 1180 additions and 148 deletions

View File

@@ -0,0 +1,88 @@
<?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\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Validator\Constraints\TimesheetFutureTimes;
use App\Validator\Constraints\TimesheetFutureTimesValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetFutureTimesValidator
*/
class TimesheetFutureTimesValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator(false);
}
protected function createMyValidator(bool $allowFutureTimes = false)
{
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = new TimesheetConfiguration($loader, [
'rules' => [
'allow_future_times' => $allowFutureTimes,
],
'rounding' => [
'default' => [
'begin' => 1
]
]
]);
return new TimesheetFutureTimesValidator($config);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetFutureTimes(['message' => 'myMessage']));
}
public function testFutureBeginIsDisallowed()
{
$begin = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$this->validator->validate($timesheet, new TimesheetFutureTimes(['message' => 'myMessage']));
$this->buildViolation('The begin date cannot be in the future.')
->atPath('property.path.begin')
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
->assertRaised();
}
public function testFutureBeginIsAllowed()
{
$this->validator = $this->createMyValidator(true);
$this->validator->initialize($this->context);
$begin = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$this->validator->validate($timesheet, new TimesheetFutureTimes(['message' => 'myMessage']));
self::assertEmpty($this->context->getViolations());
}
}

View File

@@ -0,0 +1,224 @@
<?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\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Validator\Constraints\TimesheetLockdown;
use App\Validator\Constraints\TimesheetLockdownValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetLockdownValidator
*/
class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator(false, false, null, null, null);
}
protected function createMyValidator(bool $allowOverwriteFull, bool $allowOverwriteGrace, ?string $start, ?string $end, ?string $grace)
{
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->method('isGranted')->willReturnCallback(
function ($attributes, $subject = null) use ($allowOverwriteFull, $allowOverwriteGrace) {
switch ($attributes) {
case 'lockdown_override_timesheet':
return $allowOverwriteFull;
case 'lockdown_grace_timesheet':
return $allowOverwriteGrace;
}
return false;
}
);
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = new TimesheetConfiguration($loader, [
'rules' => [
'lockdown_period_start' => $start,
'lockdown_period_end' => $end,
'lockdown_grace_period' => $grace,
],
]);
return new TimesheetLockdownValidator($auth, $config);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetLockdown(['message' => 'myMessage']));
}
public function testValidatorWithoutNowConstraint()
{
$this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
$this->validator->initialize($this->context);
$begin = new \DateTime('first day of last month');
$begin->modify('-5 days');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$constraint = new TimesheetLockdown(['message' => 'myMessage']);
$this->validator->validate($timesheet, $constraint);
$this->buildViolation('This period is locked, please choose a later date.')
->atPath('property.path.begin')
->setCode(TimesheetLockdown::PERIOD_LOCKED)
->assertRaised();
}
public function testValidatorWithEmptyTimesheet()
{
$this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
$this->validator->initialize($this->context);
$constraint = new TimesheetLockdown(['message' => 'myMessage']);
$this->validator->validate(new Timesheet(), $constraint);
self::assertEmpty($this->context->getViolations());
}
public function testValidatorWithoutNowStringConstraint()
{
$this->validator = $this->createMyValidator(false, false, 'first day of last month', 'last day of last month', '+10 days');
$this->validator->initialize($this->context);
$begin = new \DateTime('first day of last month');
$begin->modify('+5 days');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => 'first day of this month']);
$this->validator->validate($timesheet, $constraint);
self::assertEmpty($this->context->getViolations());
}
public function testValidatorWithEndBeforeStartPeriod()
{
$this->validator = $this->createMyValidator(false, false, 'first day of this month', 'last day of last month', '+10 days');
$this->validator->initialize($this->context);
$begin = new \DateTime('first day of last month');
$begin->modify('+5 days');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => 'first day of this month']);
$this->validator->validate($timesheet, $constraint);
self::assertEmpty($this->context->getViolations());
}
/**
* @dataProvider getTestData
*/
public function testLockdown(bool $allowOverwriteFull, bool $allowOverwriteGrace, string $beginModifier, string $nowModifier, bool $isViolation)
{
$this->validator = $this->createMyValidator($allowOverwriteFull, $allowOverwriteGrace, 'first day of last month', 'last day of last month', '+10 days');
$this->validator->initialize($this->context);
$begin = new \DateTime('first day of last month');
$begin->modify($beginModifier);
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$now = new \DateTime('first day of this month');
$now->modify($nowModifier);
$constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => $now]);
$this->validator->validate($timesheet, $constraint);
if ($isViolation) {
$this->buildViolation('This period is locked, please choose a later date.')
->atPath('property.path.begin')
->setCode(TimesheetLockdown::PERIOD_LOCKED)
->assertRaised();
} else {
self::assertEmpty($this->context->getViolations());
}
}
public function getTestData()
{
// changing before last dockdown period is not allowed
yield [false, false, '-5 days', '+5 days', true];
// changing before last dockdown period is not allowed with grace permission
yield [false, true, '-5 days', '+5 days', true];
// changing before last dockdown period is allowed with full permission
yield [true, true, '-5 days', '+5 days', false];
yield [true, false, '-5 days', '+5 days', false];
// changing a value in the last lockdown period is allowed during grace period
yield [false, false, '+5 days', '+5 days', false];
// changing outside grace period is not allowed
yield [false, false, '+5 days', '+11 days', true];
// changing outside grace period is allowed with grace and full permission
yield [false, true, '+5 days', '+11 days', false];
yield [true, false, '+5 days', '+11 days', false];
yield [true, true, '+5 days', '+11 days', false];
}
/**
* @dataProvider getConfigTestData
*/
public function testLockdownConfig(bool $allowOverwriteFull, bool $allowOverwriteGrace, ?string $lockdownBegin, ?string $lockdownEnd, ?string $grace, bool $isViolation)
{
$this->validator = $this->createMyValidator($allowOverwriteFull, $allowOverwriteGrace, $lockdownBegin, $lockdownEnd, $grace);
$this->validator->initialize($this->context);
$begin = new \DateTime('first day of last month');
$begin->modify('+5 days');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$now = new \DateTime('first day of this month');
$constraint = new TimesheetLockdown(['message' => 'myMessage', 'now' => $now]);
$this->validator->validate($timesheet, $constraint);
if ($isViolation) {
$this->buildViolation('This period is locked, please choose a later date.')
->atPath('property.path.begin')
->setCode(TimesheetLockdown::PERIOD_LOCKED)
->assertRaised();
} else {
self::assertEmpty($this->context->getViolations());
}
}
public function getConfigTestData()
{
yield [false, false, null, null, null, false];
yield [false, false, '+5 days', null, null, false];
yield [false, false, null, '+5 days', null, false];
yield [false, true, 'öööö', '+11 days', null, false];
yield [false, true, '+5 days', '+5 of !!!!', null, false];
}
}

View File

@@ -0,0 +1,120 @@
<?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\Validator\Constraints;
use App\Configuration\ConfigLoaderInterface;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Repository\TimesheetRepository;
use App\Validator\Constraints\TimesheetOverlapping;
use App\Validator\Constraints\TimesheetOverlappingValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetOverlappingValidator
*/
class TimesheetOverlappingValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator(false, true);
}
protected function createMyValidator(bool $allowOverlappingRecords = false, bool $hasRecords = true)
{
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = new TimesheetConfiguration($loader, [
'rules' => [
'allow_overlapping_records' => $allowOverlappingRecords,
],
]);
$repository = $this->createMock(TimesheetRepository::class);
$repository->method('hasRecordForTime')->willReturn($hasRecords);
return new TimesheetOverlappingValidator($config, $repository);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetOverlapping(['message' => 'myMessage']));
}
public function testOverlappingDisallowedWithRecords()
{
$begin = new \DateTime();
$end = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
$this->buildViolation('You already have an entry for this time.')
->atPath('property.path.begin')
->setCode(TimesheetOverlapping::RECORD_OVERLAPPING)
->assertRaised();
}
public function testOverlappingDisallowedWithoutRecords()
{
$this->validator = $this->createMyValidator(false, false);
$this->validator->initialize($this->context);
$begin = new \DateTime();
$end = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
self::assertEmpty($this->context->getViolations());
}
public function testOverlappingAllowedWithRecords()
{
$this->validator = $this->createMyValidator(true, true);
$this->validator->initialize($this->context);
$begin = new \DateTime();
$end = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
self::assertEmpty($this->context->getViolations());
}
public function testOverlappingAllowedWithoutRecords()
{
$this->validator = $this->createMyValidator(true, false);
$this->validator->initialize($this->context);
$begin = new \DateTime();
$end = new \DateTime('+10 hour');
$timesheet = new Timesheet();
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$this->validator->validate($timesheet, new TimesheetOverlapping(['message' => 'myMessage']));
self::assertEmpty($this->context->getViolations());
}
}

View File

@@ -0,0 +1,100 @@
<?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\Validator\Constraints;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Tests\Mocks\TrackingModeServiceFactory;
use App\Validator\Constraints\TimesheetOverlapping;
use App\Validator\Constraints\TimesheetRestart;
use App\Validator\Constraints\TimesheetRestartValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\TimesheetRestartValidator
*/
class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator()
{
return $this->createMyValidator(false, 'default');
}
protected function createMyValidator(bool $allowed, string $trackingMode)
{
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->method('isGranted')->willReturn($allowed);
$service = (new TrackingModeServiceFactory($this))->create($trackingMode);
return new TimesheetRestartValidator($service, $auth);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetOverlapping(['message' => 'myMessage']));
}
/**
* @dataProvider getTestData
*/
public function testRestartDisallowed(bool $allowed, ?string $property, string $trackingMode)
{
$this->validator = $this->createMyValidator($allowed, $trackingMode);
$this->validator->initialize($this->context);
$begin = new \DateTime('-10 hour');
$customer = new Customer();
$activity = new Activity();
$project = new Project();
$project->setCustomer($customer);
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setActivity($activity)
->setProject($project)
;
$this->validator->validate($timesheet, new TimesheetRestart(['message' => 'myMessage']));
if (null !== $property) {
$this->buildViolation('You are not allowed to start this timesheet record.')
->atPath('property.path.' . $property)
->setCode(TimesheetRestart::START_DISALLOWED)
->assertRaised();
} else {
self::assertEmpty($this->context->getViolations());
}
}
public function getTestData()
{
yield [false, 'end', 'default'];
yield [true, null, 'default'];
yield [false, 'duration', 'duration_only'];
yield [false, 'start', 'punch'];
}
}

View File

@@ -18,6 +18,10 @@ use App\Entity\Timesheet;
use App\Repository\TimesheetRepository;
use App\Tests\Mocks\TrackingModeServiceFactory;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\TimesheetFutureTimesValidator;
use App\Validator\Constraints\TimesheetLockdownValidator;
use App\Validator\Constraints\TimesheetOverlappingValidator;
use App\Validator\Constraints\TimesheetRestartValidator;
use App\Validator\Constraints\TimesheetValidator;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
@@ -30,10 +34,15 @@ use Symfony\Component\Validator\Test\ConstraintViolationAssertion;
*/
class TimesheetValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator($isGranted = true)
protected function createValidator()
{
$authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock();
$authMock->method('isGranted')->willReturn($isGranted);
return $this->createMyValidator();
}
protected function createMyValidator(bool $isGranted = true)
{
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->method('isGranted')->willReturn($isGranted);
$loader = $this->createMock(ConfigLoaderInterface::class);
$config = new TimesheetConfiguration($loader, [
@@ -50,14 +59,28 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
$service = (new TrackingModeServiceFactory($this))->create('default');
$repository = $this->createMock(TimesheetRepository::class);
return new TimesheetValidator($authMock, $config, $service, $repository);
$constraints = [
new TimesheetFutureTimesValidator($config),
new TimesheetLockdownValidator($auth, $config),
new TimesheetOverlappingValidator($config, $repository),
new TimesheetRestartValidator($service, $auth),
];
return new TimesheetValidator($constraints);
}
public function testConstraintIsInvalid()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('foo', new NotBlank());
$this->validator->validate(new Timesheet(), new NotBlank());
}
public function testInvalidValueThrowsException()
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate(new NotBlank(), new TimesheetConstraint(['message' => 'myMessage']));
}
public function testEmptyTimesheet()
@@ -85,42 +108,20 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('The begin date cannot be in the future.')
->atPath('property.path.begin')
->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
->buildNextViolation('A timesheet must have an activity.')
$this
->buildViolation('A timesheet must have an activity.')
->atPath('property.path.activity')
->setCode(TimesheetConstraint::MISSING_ACTIVITY_ERROR)
->buildNextViolation('A timesheet must have a project.')
->atPath('property.path.project')
->setCode(TimesheetConstraint::MISSING_PROJECT_ERROR)
->assertRaised();
}
public function testRestartDisallowed()
{
$this->validator = $this->createValidator(false);
$this->validator->initialize($this->context);
$begin = new \DateTime('-10 hour');
$customer = new Customer();
$activity = new Activity();
$project = new Project();
$project->setCustomer($customer);
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
->setBegin($begin)
->setActivity($activity)
->setProject($project)
;
$this->validator->validate($timesheet, new TimesheetConstraint(['message' => 'myMessage']));
$this->buildViolation('You are not allowed to start this timesheet record.')
->atPath('property.path.end')
->setCode(TimesheetConstraint::START_DISALLOWED)
// The test context is not able to handle calls to validate() - see ConstraintValidatorTestCase::createContext()
// therefor sub-constraints will not be executed :-(
/*
->buildNextViolation('The begin date cannot be in the future.')
->atPath('property.path.begin')
->setCode(TimesheetFutureTimes::BEGIN_IN_FUTURE_ERROR)
*/
->assertRaised();
}