configurable csv/xlsx export templates (#5531)

This commit is contained in:
Kevin Papst
2025-06-11 13:06:33 +02:00
committed by GitHub
parent 46129c7ab9
commit 05aaa1950a
83 changed files with 2632 additions and 412 deletions

View File

@@ -48,7 +48,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
}
}
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice'];
$expectedKeys = ['Actions', 'Activity', 'Default', 'Customer', 'Project', 'Tag', 'Team', 'Timesheet', 'User', 'Invoice', 'Export'];
$actual = array_keys($tags);
sort($actual);
@@ -73,6 +73,7 @@ class ApiDocControllerTest extends AbstractControllerBaseTestCase
'/api/customers/{id}/meta',
'/api/customers/{id}/rates',
'/api/customers/{id}/rates/{rateId}',
'/api/export/{id}',
'/api/invoices',
'/api/invoices/{id}',
'/api/projects',

View File

@@ -0,0 +1,80 @@
<?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\API;
use App\Entity\ExportTemplate;
use App\Entity\User;
use App\Repository\ExportTemplateRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @group integration
*/
class ExportControllerTest extends APIControllerBaseTestCase
{
private function importExportTemplate(): ExportTemplate
{
/** @var ExportTemplateRepository $repository */
$repository = $this->getEntityManager()->getRepository(ExportTemplate::class);
$template = new ExportTemplate();
$template->setRenderer('csv');
$template->setTitle('csv');
$template->setColumns(['activity.name', 'project.number', 'project.name', 'customer.name', 'user.account_number', 'duration', 'date', 'rate', 'currency']);
$template->setLanguage('en');
$template->setLanguage('en');
$repository->saveExportTemplate($template);
return $template;
}
public function testDeleteIsSecure(): void
{
$this->assertUrlIsSecured('/api/export/1', Request::METHOD_DELETE);
}
public function testDeleteActionWithUnknownTemplate(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/export/' . PHP_INT_MAX);
}
public function testDeleteEntityIsSecure(): void
{
$client = $this->createClient();
$template = $this->importExportTemplate();
$this->assertRequestIsSecured($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
}
public function testDeleteActionWithoutAuthorization(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$template = $this->importExportTemplate();
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
$response = $client->getResponse();
$this->assertApiResponseAccessDenied($response);
}
public function testDeleteAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$template = $this->importExportTemplate();
$this->request($client, '/api/export/' . $template->getId(), Request::METHOD_DELETE);
self::assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
self::assertEmpty($client->getResponse()->getContent());
}
}

View File

@@ -9,11 +9,14 @@
namespace App\Tests\Controller;
use App\Entity\ExportTemplate;
use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ExportTemplateFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
use Symfony\Component\DomCrawler\Field\FormField;
/**
* @group integration
@@ -25,7 +28,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
$this->assertUrlIsSecured('/export/');
}
public function testIsSecureForrole(): void
public function testIsSecureForRole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/');
}
@@ -90,6 +93,16 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 22);
$header = $client->getCrawler()->filter('section.content div.datatable_export table.dataTable thead th');
$titles = [];
/** @var \DOMElement $th */
foreach ($header as $th) {
$titles[] = trim($th->textContent);
}
self::assertEquals([
'', 'Date', 'User', 'Project', 'Activity', 'Description', 'Tags', 'Duration', 'Unit price', 'Internal price', 'Total price', '',
], $titles);
// assert export type buttons are available
$expected = [
'csv' => 'csv',
@@ -246,4 +259,71 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
self::assertTrue($timesheet->isExported());
}
}
public function testCreateTemplateIsSecure(): void
{
$this->assertUrlIsSecured('/export/template-create');
}
public function testCreateTemplateIsSecureForRole(): void
{
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/export/template-create');
}
public function testCreateTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/export/template-create');
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$client->submit($form, [
'export_template_spreadsheet_form' => [
'title' => 'My temaplte name',
'renderer' => 'xlsx',
'language' => 'de',
'columns' => 'date',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/export/'));
$templates = $this->getEntityManager()->getRepository(ExportTemplate::class)->findAll();
self::assertCount(1, $templates);
$template = array_pop($templates);
$id = $template->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
self::assertTrue($client->getResponse()->isSuccessful());
$editForm = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$field = $editForm->get('export_template_spreadsheet_form[title]');
self::assertInstanceOf(FormField::class, $field);
self::assertEquals('My temaplte name', $field->getValue());
}
public function testEditTemplateAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var ExportTemplate[] $templates */
$templates = $this->importFixture(new ExportTemplateFixtures());
$id = $templates[0]->getId();
$this->request($client, $this->createUrl('/export/template-edit/' . $id));
self::assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=export_template_spreadsheet_form]')->form();
$field = $form->get('export_template_spreadsheet_form[title]');
self::assertInstanceOf(FormField::class, $field);
self::assertEquals('CSV Test', $field->getValue());
$client->submit($form, [
'export_template_spreadsheet_form' => [
'title' => 'My temaplte name',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/export/'));
/** @var ExportTemplate $template */
$template = $this->getEntityManager()->getRepository(ExportTemplate::class)->find($id);
self::assertEquals('My temaplte name', $template->getTitle());
}
}

View File

@@ -34,7 +34,7 @@ class PermissionControllerTest extends AbstractControllerBaseTestCase
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 135);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 136);
$this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
]);

View File

@@ -35,12 +35,9 @@ class CustomerServiceTest extends TestCase
private function getSut(
?EventDispatcherInterface $dispatcher = null,
?ValidatorInterface $validator = null,
?CustomerRepository $repository = null,
?SystemConfiguration $configuration = null
): CustomerService {
if ($repository === null) {
$repository = $this->createMock(CustomerRepository::class);
}
$repository = $this->createMock(CustomerRepository::class);
if ($dispatcher === null) {
$dispatcher = $this->createMock(EventDispatcherInterface::class);
@@ -163,7 +160,7 @@ class CustomerServiceTest extends TestCase
]
]);
$sut = $this->getSut(null, null, null, $configuration);
$sut = $this->getSut(null, null, $configuration);
$customer = $sut->createNewCustomer('Test');
self::assertEquals((string) $expected, $customer->getNumber());

View File

@@ -0,0 +1,40 @@
<?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\DataFixtures;
use App\Entity\ExportTemplate;
use Doctrine\Persistence\ObjectManager;
final class ExportTemplateFixtures implements TestFixture
{
/**
* @return ExportTemplate[]
*/
public function load(ObjectManager $manager): array
{
$template1 = new ExportTemplate();
$template1->setRenderer('csv');
$template1->setLanguage('de');
$template1->setTitle('CSV Test');
$template1->setColumns(['date', 'user.name', 'duration', 'customer.name']);
$manager->persist($template1);
$template2 = new ExportTemplate();
$template2->setRenderer('xlsx');
$template2->setLanguage('en');
$template2->setTitle('Excel Test');
$template2->setColumns(['date', 'user.name', 'duration_seconds', 'project.name']);
$manager->persist($template2);
$manager->flush();
return [$template1, $template2];
}
}

View File

@@ -0,0 +1,73 @@
<?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\Entity;
use App\Entity\ExportTemplate;
/**
* @covers \App\Entity\ExportTemplate
*/
class ExportTemplateTest extends AbstractEntityTestCase
{
public function testDefaultValues(): void
{
$sut = new ExportTemplate();
self::assertNull($sut->getId());
self::assertNull($sut->getTitle());
self::assertEquals('csv', $sut->getRenderer());
self::assertNull($sut->getLanguage());
self::assertEquals([], $sut->getColumns());
self::assertEquals([], $sut->getOptions());
}
public function testSetter(): void
{
$sut = new ExportTemplate();
self::assertEquals('New', (string) $sut);
$sut->setTitle('foo');
self::assertEquals('foo', $sut->getTitle());
self::assertEquals('foo', (string) $sut);
$sut->setTitle(null);
self::assertNull($sut->getTitle());
self::assertEquals('New', (string) $sut);
$sut->setRenderer('xlsx');
self::assertEquals('xlsx', $sut->getRenderer());
$sut->setLanguage('de');
self::assertEquals('de', $sut->getLanguage());
$sut->setLanguage(null);
self::assertNull($sut->getLanguage());
$sut->setColumns(['foo', 'bar', 'WORLD']);
self::assertEquals(['foo', 'bar', 'WORLD'], $sut->getColumns());
$sut->setColumns(null);
self::assertEquals([], $sut->getColumns());
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions());
$sut->setOptions(null);
self::assertEquals([], $sut->getOptions());
}
public function testClone(): void
{
$sut = new ExportTemplate();
$r = new \ReflectionObject($sut);
$p = $r->getProperty('id');
$p->setAccessible(true);
$p->setValue($sut, 13);
self::assertEquals(13, $sut->getId());
$sut2 = clone $sut;
self::assertNull($sut2->getId());
}
}

View File

@@ -53,6 +53,7 @@ class UserTest extends TestCase
$user->setUserIdentifier('foo');
self::assertEquals('foo', $user->getUserIdentifier());
self::assertEquals('foo', $user->getIdentifier());
self::assertEquals('foo', $user->getDisplayName());
$user->setAlias('BAR');
self::assertEquals('BAR', $user->getDisplayName());
@@ -89,6 +90,7 @@ class UserTest extends TestCase
public function testWorkContract(): void
{
$user = new User();
self::assertFalse($user->hasContractSettings());
self::assertEquals(0, $user->getWorkHoursMonday());
self::assertEquals(0, $user->getWorkHoursTuesday());
@@ -127,6 +129,7 @@ class UserTest extends TestCase
$user->setWorkHoursSunday(7800);
$user->setHolidaysPerYear(10.7);
self::assertTrue($user->hasWorkHourConfiguration());
self::assertTrue($user->hasContractSettings());
self::assertEquals(7200, $user->getWorkHoursMonday());
self::assertEquals(7300, $user->getWorkHoursTuesday());
@@ -158,6 +161,16 @@ class UserTest extends TestCase
$user->setPublicHolidayGroup('DE-NRW');
self::assertEquals('DE-NRW', $user->getPublicHolidayGroup());
self::assertNull($user->getWorkStartingDay());
$workStart = new \DateTimeImmutable('2018-07-23');
$user->setWorkStartingDay($workStart);
self::assertEquals($workStart, $user->getWorkStartingDay());
self::assertNull($user->getLastWorkingDay());
$workStart = new \DateTimeImmutable('2021-02-13');
$user->setLastWorkingDay($workStart);
self::assertEquals($workStart, $user->getLastWorkingDay());
}
public function testColor(): void
@@ -248,6 +261,21 @@ class UserTest extends TestCase
$user->setPreferenceValue('export_decimal', true);
self::assertTrue($user->isExportDecimal());
$prefs = $user->getPreferences();
self::assertCount(3, $prefs);
self::assertInstanceOf(UserPreference::class, $prefs[0]);
self::assertEquals('test', $prefs[0]->getName());
self::assertInstanceOf(UserPreference::class, $prefs[1]);
self::assertEquals('test2', $prefs[1]->getName());
self::assertInstanceOf(UserPreference::class, $prefs[2]);
self::assertEquals('export_decimal', $prefs[2]->getName());
$user->setPreferences(new ArrayCollection([]));
self::assertCount(0, $user->getPreferences());
}
public function testDisplayName(): void
@@ -332,6 +360,12 @@ class UserTest extends TestCase
self::assertTrue($sut->isTeamleadOf($team2));
self::assertTrue($sut->isInTeam($team2));
self::assertTrue($sut->isTeamleadOf($team2));
$user2 = new User();
self::assertFalse($sut->isTeamleadOfUser($user2));
$team2->addUser($user2);
self::assertTrue($sut->isTeamleadOfUser($user2));
self::assertCount(2, $sut->getTeams());
$sut->removeMembership(new TeamMember());
self::assertCount(2, $sut->getTeams());
@@ -624,4 +658,21 @@ class UserTest extends TestCase
self::assertNotNull($user->getSupervisor());
self::assertSame($supervisor, $user->getSupervisor());
}
public function testLastLogin(): void
{
$dateTime = new \DateTime('now', new \DateTimeZone('UTC'));
$user = new User();
$user->setTimezone('Europe/Berlin');
$lastLogin = $user->getLastLogin();
self::assertNull($lastLogin);
$user->setLastLogin($dateTime);
$lastLogin = $user->getLastLogin();
self::assertNotNull($lastLogin);
self::assertInstanceOf(\DateTime::class, $lastLogin);
self::assertEquals('Europe/Berlin', $lastLogin->getTimezone()->getName());
}
}

View File

@@ -13,7 +13,8 @@ use App\Entity\User;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -37,12 +38,13 @@ class CsvRendererTest extends AbstractRendererTestCase
$security->expects($this->any())->method('getUser')->willReturn($user);
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->createMock(TranslatorInterface::class);
$translator = $this->getContainer()->get(TranslatorInterface::class);
self::assertInstanceOf(TranslatorInterface::class, $translator);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class)), $translator);
}
public function testConfiguration(): void
@@ -50,23 +52,43 @@ class CsvRendererTest extends AbstractRendererTestCase
$sut = $this->getAbstractRenderer();
self::assertEquals('csv', $sut->getId());
self::assertEquals('csv', $sut->getTitle());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
}
public static function getTestModel(): array
{
$en = [
'Date', 'From', 'To', 'Duration', 'Currency', 'Price', 'Internal price', 'Hourly price', 'Fixed price', 'Name',
'User', 'Staff number', 'Customer', 'Project', 'Activity', 'Description', 'Billable', 'Tags',
'Type', 'category', 'Account', 'Project number', 'VAT-ID', 'Order number',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
$de = [
'Datum', 'Von', 'Bis', 'Dauer', 'Währung', 'Preis', 'Interner Preis', 'Preis pro Stunde', 'Festpreis', 'Name',
'Benutzer', 'Personalnummer', 'Kunde', 'Projekt', 'Tätigkeit', 'Beschreibung', 'Abrechenbar', 'Schlagworte',
'Typ', 'category', 'Kundennummer', 'Projektnummer', 'Umsatzsteuer-ID', 'Bestellnummer',
'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'Working place', 'mypref',
];
return [
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false],
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true]
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, false, null, $en],
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2, true, 'de', $de]
];
}
/**
* @dataProvider getTestModel
*/
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal): void
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal, ?string $locale, array $header): void
{
$sut = $this->getAbstractRenderer($exportDecimal);
$sut->setLocale($locale);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
@@ -102,11 +124,13 @@ class CsvRendererTest extends AbstractRendererTestCase
$all[] = str_getcsv($row);
}
self::assertEquals($header, $all[0]);
$expected = [
'2019-06-16',
'12:00',
'12:06',
($exportDecimal ? '0.11' : '0:06:40'),
($exportDecimal ? '0.11' : '0:06'),
//'0.11',
'EUR',
'0',
@@ -134,13 +158,14 @@ class CsvRendererTest extends AbstractRendererTestCase
'',
'project-foo2',
'activity-bar',
'',
];
$expected2 = [
'2019-06-16',
'12:00',
'12:06',
($exportDecimal ? '0.11' : '0:06:40'),
($exportDecimal ? '0.11' : '0:06'),
//'0.11',
'EUR',
'0',
@@ -168,6 +193,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'',
'project-foo2',
'activity-bar',
'',
];
self::assertEquals(7, \count($all));

View File

@@ -0,0 +1,56 @@
<?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\Export\Base;
use App\Export\Base\PDFRenderer;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use Twig\Environment;
/**
* @covers \App\Export\Base\PDFRenderer
* @covers \App\Export\Base\RendererTrait
* @covers \App\Pdf\PdfRendererTrait
* @group integration
*/
class PdfRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(bool $exportDecimal = false): PDFRenderer
{
$twig = $this->createMock(Environment::class);
$converter = $this->createMock(HtmlToPdfConverter::class);
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
return new PDFRenderer($twig, $converter, $projectStatisticService);
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
self::assertEquals('pdf', $sut->getId());
self::assertEquals('pdf', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertEquals([], $sut->getPdfOptions());
$sut->setPdfOption('foo', 'bar');
self::assertEquals(['foo' => 'bar'], $sut->getPdfOptions());
$sut->setPdfOption('foo', 'bar2');
self::assertEquals(['foo' => 'bar2'], $sut->getPdfOptions());
$sut->setPdfOption('hello', 'world');
self::assertEquals(['foo' => 'bar2', 'hello' => 'world'], $sut->getPdfOptions());
}
}

View File

@@ -12,10 +12,15 @@ namespace App\Tests\Export\Base;
use App\Entity\ExportableItem;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Package\MemoryPackage;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
@@ -31,7 +36,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::once())->method('setColumns');
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
}
@@ -60,7 +65,7 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
}
@@ -93,4 +98,96 @@ class SpreadsheetRendererTest extends AbstractRendererTestCase
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
}
public static function getTestData(): iterable
{
yield [null, [
'date' => 'date',
'begin' => 'begin',
'end' => 'end',
'duration' => 'duration',
'currency' => 'currency',
'rate' => 'rate',
'internalRate' => 'internalRate',
'hourlyRate' => 'hourlyRate',
'fixedRate' => 'fixedRate',
'alias' => 'alias',
'username' => 'username',
'account_number' => 'account_number',
'customer' => 'customer',
'project' => 'project',
'activity' => 'activity',
'description' => 'description',
'billable' => 'billable',
'tags' => 'tags',
'type' => 'type',
'category' => 'category',
'number' => 'number',
'project_number' => 'project_number',
'vat_id' => 'vat_id',
'orderNumber' => 'orderNumber',
'timesheet.meta.foo' => 'Working place',
'timesheet.meta.foo2' => 'Working place',
'customer.meta.customer-foo' => 'Working place',
'project.meta.project-foo' => 'Working place',
'project.meta.project-foo2' => 'Working place',
'activity.meta.activity-foo' => 'Working place',
'user.meta.mypref' => 'mypref',
]];
$template = new Template('test', 'Testing');
$template->setLocale('de');
$template->setColumns(['date', 'user.name', 'duration_decimal', 'customer.name', 'exported', 'user.meta.mypref']);
yield [$template, [
'date' => 'date',
'duration' => 'duration',
'username' => 'username',
'customer' => 'customer',
'exported' => 'exported',
'user.meta.mypref' => 'mypref',
]];
}
/**
* @dataProvider getTestData
*/
public function testWriteSpreadsheetCsv(?Template $template, array $expectedColumns): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$security = $this->createMock(Security::class);
$spreadsheetPackage = new MemoryPackage();
$exportItem = $this->createMock(ExportableItem::class);
$exportItem->method('getBegin')->willReturn(new \DateTime());
$exportItem->method('getEnd')->willReturn(new \DateTime());
$exportItem->method('getDuration')->willReturn(3600);
$exportItem->method('getRate')->willReturn(100.0);
$exportItem->method('getInternalRate')->willReturn(80.0);
$exportItem->method('getHourlyRate')->willReturn(50.0);
$exportItem->method('getFixedRate')->willReturn(200.0);
$exportItem->method('getUser')->willReturn(null);
$exportItem->method('getProject')->willReturn(null);
$exportItem->method('getActivity')->willReturn(null);
$exportItem->method('getDescription')->willReturn('Test description');
$exportItem->method('isBillable')->willReturn(true);
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->setTemplate($template);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
$columnNames = [];
foreach ($spreadsheetPackage->getColumns() as $column) {
$columnNames[$column->getName()] = $column->getHeader();
}
self::assertEquals(null, $spreadsheetPackage->getFilename());
self::assertEquals($expectedColumns, $columnNames);
self::assertCount(2, $spreadsheetPackage->getRows());
}
}

View File

@@ -13,7 +13,7 @@ use App\Entity\User;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -38,7 +38,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
$translator->method('trans')->willReturnArgument(0);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
}
@@ -46,9 +46,16 @@ class XlsxRendererTest extends AbstractRendererTestCase
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
$sut->setLocale('de');
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('xlsx', $sut->getTitle());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
}
public function testRender(): void

View File

@@ -19,13 +19,13 @@ class DurationFormatterTest extends TestCase
{
public function testGetFormat(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
self::assertEquals('[hh]:mm:ss', $formatter->getFormat());
}
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue(701213);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('194:46:53', $result->format('%r%H:%I:%S'));
@@ -33,7 +33,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue(7213);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('02:00:13', $result->format('%r%H:%I:%S'));
@@ -41,7 +41,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue('not a number');
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
@@ -49,7 +49,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue(4521.5);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('01:15:21', $result->format('%r%H:%I:%S'));
@@ -57,7 +57,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue(null);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('00:00:00', $result->format('%r%H:%I:%S'));
@@ -65,7 +65,7 @@ class DurationFormatterTest extends TestCase
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationFormatter();
$formatter = new DurationFormatter('[hh]:mm:ss');
$result = $formatter->formatValue(-3600);
self::assertInstanceOf(\DateInterval::class, $result);
self::assertEquals('-01:00:00', $result->format('%r%H:%I:%S'));

View File

@@ -19,42 +19,50 @@ class DurationPlainFormatterTest extends TestCase
{
public function testFormatValueReturnsFormattedDurationQuiteLong(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue(701213);
self::assertEquals('194:46:53', $result);
$formatter = new DurationPlainFormatter(false);
$result = $formatter->formatValue(701213);
self::assertEquals('194:46', $result);
}
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue(8246);
self::assertEquals('2:17:26', $result);
}
public function testFormatValueReturnsZeroForNonNumericValue(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue('not a number');
self::assertEquals('0:00:00', $result);
$formatter = new DurationPlainFormatter(false);
$result = $formatter->formatValue('not a number');
self::assertEquals('0:00', $result);
}
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue(44513.5);
self::assertEquals('12:21:53', $result);
}
public function testFormatValueReturnsZeroForNullValue(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue(null);
self::assertEquals('0:00:00', $result);
}
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
{
$formatter = new DurationPlainFormatter();
$formatter = new DurationPlainFormatter(true);
$result = $formatter->formatValue(-3600);
self::assertEquals('-1:00:00', $result);
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Package;
use App\Export\Package\Column;
use App\Export\Package\SpreadsheetPackage;
/**
* @covers \App\Export\Package\SpoutSpreadsheet
*/
class MemoryPackage implements SpreadsheetPackage
{
private ?string $filename = null;
/** @var array<Column> */
private array $columns = [];
private array $rows = [];
private bool $saved = false;
public function open(string $filename): void
{
$this->filename = $filename;
}
public function save(): void
{
$this->saved = true;
}
/**
* @param array<Column> $columns
*/
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
public function addRow(array $columns, array $options = []): void
{
$this->rows[] = ['columns' => $columns, 'options' => $options];
}
public function getFilename(): ?string
{
return $this->filename;
}
/**
* @return Column[]
*/
public function getColumns(): array
{
return $this->columns;
}
public function getRows(): array
{
return $this->rows;
}
public function isSaved(): bool
{
return $this->saved;
}
}

View File

@@ -13,22 +13,15 @@ use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ExportRendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractRendererTestCase extends KernelTestCase
@@ -145,47 +138,3 @@ abstract class AbstractRendererTestCase extends KernelTestCase
return $renderer->render($entries, $query);
}
}
class MetaFieldColumnSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
];
}
public function loadTimesheetField(TimesheetMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo'));
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo2'));
}
public function loadCustomerField(CustomerMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new CustomerMeta(), 'customer-foo'));
}
public function loadProjectField(ProjectMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo'));
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo2')->setIsVisible(false));
}
public function loadActivityField(ActivityMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ActivityMeta(), 'activity-foo'));
}
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
{
return $meta
->setLabel('Working place')
->setName($name)
->setType(TextType::class)
->setIsVisible(true);
}
}

View File

@@ -0,0 +1,44 @@
<?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\Export\Renderer;
use App\Export\Base\CsvRenderer;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Renderer\CsvRendererFactory
*/
class CsvRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
$renderer = $sut->create($template);
self::assertInstanceOf(CsvRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
}
}

View File

@@ -0,0 +1,44 @@
<?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\Export\Renderer;
use App\Export\Base\XlsxRenderer;
use App\Export\Renderer\XlsxRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Renderer\XlsxRendererFactory
*/
class XlsxRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
$renderer = $sut->create($template);
self::assertInstanceOf(XlsxRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
}
}

View File

@@ -10,15 +10,22 @@
namespace App\Tests\Export;
use App\Activity\ActivityStatisticService;
use App\Entity\ExportTemplate;
use App\Export\Base\CsvRenderer;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ExportRepositoryInterface;
use App\Export\ServiceExport;
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
use App\Project\ProjectStatisticService;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use App\Tests\Mocks\Export\CsvRendererFactoryMock;
use App\Tests\Mocks\Export\HtmlRendererFactoryMock;
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use App\Tests\Mocks\Export\XlsxRendererFactoryMock;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
@@ -28,12 +35,48 @@ use Twig\Environment;
*/
class ServiceExportTest extends TestCase
{
private function createSut(): ServiceExport
private function createSut(bool $withTemplates = false, int $failureCount = 1): ServiceExport
{
$repository = $this->createMock(ExportTemplateRepository::class);
$templates = [];
$logger = $this->createMock(LoggerInterface::class);
if ($withTemplates) {
$template1 = $this->createMock(ExportTemplate::class);
$template1->method('getId')->willReturn(1);
$template1->method('getTitle')->willReturn('CSV Test');
$template1->method('getLanguage')->willReturn('de');
$template1->method('getRenderer')->willReturn('csv');
$template1->method('getColumns')->willReturn(['date', 'customer.name', 'duration', 'rate']);
$template2 = $this->createMock(ExportTemplate::class);
$template2->method('getId')->willReturn(2);
$template2->method('getTitle')->willReturn('XLSX Test');
$template2->method('getLanguage')->willReturn('it');
$template2->method('getRenderer')->willReturn('xlsx');
$template2->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
$template3 = $this->createMock(ExportTemplate::class);
$template3->method('getTitle')->willReturn('XLSX Test');
$template3->method('getLanguage')->willReturn('it');
$template3->method('getRenderer')->willReturn('foo'); // invalid renderer will be ignored
$template3->method('getColumns')->willReturn(['date', 'begin', 'duration', 'rate', 'user.name']);
$logger->expects($this->exactly($failureCount))->method('error')->with('Unknown export template type: ' . $template3->getRenderer());
$templates = [$template1, $template2, $template3];
}
$repository->method('findAll')->willReturn($templates);
return new ServiceExport(
$this->createMock(EventDispatcherInterface::class),
(new HtmlRendererFactoryMock($this))->create(),
(new PdfRendererFactoryMock($this))->create(),
(new CsvRendererFactoryMock($this))->create(),
(new XlsxRendererFactoryMock($this))->create(),
$repository,
$logger,
);
}
@@ -88,4 +131,18 @@ class ServiceExportTest extends TestCase
self::assertEquals([], $items);
}
public function testWithTemplates(): void
{
$sut = $this->createSut(true, 5);
$renderer = $sut->getRenderer();
self::assertCount(2, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
self::assertInstanceOf(CsvRenderer::class, $sut->getRendererById('1'));
self::assertNull($sut->getRendererById('default'));
self::assertNull($sut->getRendererById('csv'));
self::assertNull($sut->getRendererById('xlsx'));
}
}

View File

@@ -0,0 +1,63 @@
<?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\Export;
use App\Export\Template;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Export\Template
*/
class TemplateTest extends TestCase
{
public function testDefaultValues(): void
{
$template = new Template('id', 'title');
self::assertEquals('id', $template->getId());
self::assertEquals('title', $template->getTitle());
self::assertNull($template->getLocale());
self::assertEquals([], $template->getColumns());
self::assertEquals([], $template->getOptions());
}
public function testSetsAndGetsColumnsCorrectly(): void
{
$template = new Template('id', 'title');
$columns = ['Column1', 'Column2'];
$template->setColumns($columns);
self::assertEquals($columns, $template->getColumns());
$template->setColumns([]);
self::assertEquals([], $template->getColumns());
}
public function testSetsAndGetsOptionsCorrectly(): void
{
$template = new Template('id', 'title');
$options = ['key1' => 'value1', 'key2' => 'value2'];
$template->setOptions($options);
self::assertEquals($options, $template->getOptions());
$template->setOptions([]);
self::assertEquals([], $template->getOptions());
}
public function testSetsAndGetsLocaleCorrectly(): void
{
$template = new Template('id', 'title');
$template->setLocale('en_US');
self::assertEquals('en_US', $template->getLocale());
}
public function testHandlesNullLocaleGracefully(): void
{
$template = new Template('id', 'title');
$template->setLocale(null);
self::assertNull($template->getLocale());
}
}

View File

@@ -0,0 +1,61 @@
<?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\Form;
use App\Configuration\LocaleService;
use App\Entity\ExportTemplate;
use App\Form\ExportTemplateSpreadsheetForm;
use App\Form\Type\ExportColumnsType;
use App\Form\Type\LanguageType;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Form\ExportTemplateSpreadsheetForm
*/
class ExportTemplateSpreadsheetFormTest extends TypeTestCase
{
/**
* @return FormTypeInterface[]
*/
protected function getTypes(): array // @phpstan-ignore missingType.generics
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$translator = $this->createMock(TranslatorInterface::class);
return [
new ExportColumnsType($dispatcher, $translator),
new LanguageType(new LocaleService([]))
];
}
public function testWithGlobalNewActivity(): void
{
$model = new ExportTemplate();
$form = $this->factory->createBuilder(ExportTemplateSpreadsheetForm::class, $model);
$attr = $form->getFormConfig()->getOption('attr');
self::assertIsArray($attr);
self::assertArrayHasKey('data-form-event', $attr);
self::assertEquals('kimai.exportTemplate', $attr['data-form-event']);
self::assertTrue($form->has('title'));
self::assertTrue($form->has('renderer'));
self::assertTrue($form->has('language'));
self::assertTrue($form->has('columns'));
self::assertTrue($form->get('title')->getRequired());
self::assertTrue($form->get('renderer')->getRequired());
self::assertFalse($form->get('language')->getRequired());
self::assertTrue($form->get('columns')->getRequired());
}
}

View File

@@ -12,6 +12,8 @@ namespace App\Tests\Form\Extension;
use App\Form\Extension\DocumentationLinkExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -43,4 +45,18 @@ class DocumentationLinkExtensionTest extends TestCase
$this->expectException(InvalidOptionsException::class);
$resolver->resolve(['docu_chapter' => true]);
}
public function testBuildView(): void
{
$sut = new DocumentationLinkExtension();
$form = $this->createMock(FormInterface::class);
$view = new FormView();
$sut->buildView($view, $form, ['docu_chapter' => null]);
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => null], $view->vars);
$view = new FormView();
$sut->buildView($view, $form, ['docu_chapter' => 'customers']);
self::assertEquals(['attr' => [], 'value' => null, 'docu_chapter' => 'customers'], $view->vars);
}
}

View File

@@ -13,6 +13,8 @@ use App\Form\Extension\EnhancedChoiceTypeExtension;
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -30,15 +32,52 @@ class EnhancedChoiceTypeExtensionTest extends TestCase
$resolver = new OptionsResolver();
$sut = new EnhancedChoiceTypeExtension();
$sut->configureOptions($resolver);
self::assertEquals(['selectpicker', 'width', 'search'], $resolver->getDefinedOptions());
self::assertEquals(['selectpicker', 'width', 'search', 'order'], $resolver->getDefinedOptions());
self::assertTrue($resolver->hasDefault('selectpicker'));
self::assertTrue($resolver->hasDefault('width'));
self::assertTrue($resolver->hasDefault('search'));
self::assertTrue($resolver->hasDefault('order'));
self::assertFalse($resolver->isRequired('selectpicker'));
self::assertFalse($resolver->isRequired('width'));
self::assertFalse($resolver->isRequired('search'));
$result = $resolver->resolve([]);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true], $result);
self::assertEquals(['selectpicker' => true, 'width' => '100%', 'search' => true, 'order' => false], $result);
}
public static function getTestData(): iterable
{
yield [
['expanded' => true],
['value' => null, 'attr' => []]
];
yield [
['multiple' => false, 'width' => false, 'search' => true, 'selectpicker' => true, 'order', 'required' => false],
['value' => null, 'attr' => ['class' => 'selectpicker']]
];
yield [
['multiple' => false, 'width' => '100%', 'search' => true, 'required' => false, 'order' => true],
['value' => null, 'attr' => ['class' => 'selectpicker', 'data-width' => '100%', 'data-order' => 1]]
];
yield [
['multiple' => true, 'width' => '50%', 'search' => false, 'required' => true, 'attr' => []],
['value' => null, 'attr' => ['size' => 1, 'class' => 'selectpicker', 'data-width' => '50%', 'data-disable-search' => 1, 'required' => 'required', 'placeholder' => '']]
];
}
/**
* @dataProvider getTestData
*/
public function testBuildView(array $options, array $expected): void
{
$sut = new EnhancedChoiceTypeExtension();
$view = new FormView();
$form = $this->createMock(FormInterface::class);
$sut->buildView($view, $form, $options);
self::assertEquals($expected, $view->vars);
}
}

View File

@@ -0,0 +1,72 @@
<?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\Form\Type;
use App\Form\Type\ExportColumnsType;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Form\Type\ExportColumnsType
*/
class ExportColumnsTypeTest extends TypeTestCase
{
public static function getTestData(): iterable
{
yield [['foo', 'bar'], []];
yield [
['user.name', 'customer.meta.customer-foo', 'duration', 'hello', 'user.meta.mypref'],
['user.name', 'customer.meta.customer-foo', 'duration', 'user.meta.mypref']
];
}
/**
* @return ExportColumnsType[]
*/
protected function getTypes(): array
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$translator = $this->createMock(TranslatorInterface::class);
return [
new ExportColumnsType($dispatcher, $translator)
];
}
/**
* @param array<mixed> $value
* @param array<mixed> $expected
* @dataProvider getTestData
*/
public function testSubmitValidData(array $value, array $expected): void
{
$data = ['columns' => $value];
$model = new TypeTestModel(['columns' => []]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('columns', ExportColumnsType::class);
$form = $form->getForm();
$expected = new TypeTestModel([
'columns' => $expected
]);
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals($expected, $model);
}
}

View File

@@ -0,0 +1,51 @@
<?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\Form\Type;
use App\Form\Type\ExportRendererType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Test\TypeTestCase;
/**
* @covers \App\Form\Type\ExportRendererType
*/
class ExportRendererTypeTest extends TypeTestCase
{
public static function getTestData(): iterable
{
yield ['foo', null];
yield ['csv', 'csv'];
yield ['csV', null];
yield ['xlsx', 'xlsx'];
yield ['XLSX', null];
}
/**
* @dataProvider getTestData
*/
public function testSubmitValidData(string $value, string|null $expected): void
{
$data = ['renderer' => $value];
$model = new TypeTestModel(['renderer' => null]);
$form = $this->factory->createBuilder(FormType::class, $model);
$form->add('renderer', ExportRendererType::class);
$form = $form->getForm();
$expected = new TypeTestModel([
'renderer' => $expected
]);
$form->submit($data);
self::assertTrue($form->isSynchronized());
self::assertEquals($expected, $model);
}
}

View File

@@ -14,7 +14,7 @@ namespace App\Tests\Form\Type;
*/
class TypeTestModel extends \ArrayObject
{
public function __set(string $name, string|int|null $value)
public function __set(string $name, string|int|null|array $value)
{
$this->offsetSet($name, $value);
}

View File

@@ -0,0 +1,30 @@
<?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\Mocks\Export;
use App\Export\Renderer\CsvRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class CsvRendererFactoryMock extends AbstractMockFactory
{
public function create(): CsvRendererFactory
{
return new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
}
}

View File

@@ -0,0 +1,30 @@
<?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\Mocks\Export;
use App\Export\Renderer\XlsxRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class XlsxRendererFactoryMock extends AbstractMockFactory
{
public function create(): XlsxRendererFactory
{
return new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
}
}

View File

@@ -0,0 +1,74 @@
<?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\Mocks;
use App\Entity\ActivityMeta;
use App\Entity\CustomerMeta;
use App\Entity\MetaTableTypeInterface;
use App\Entity\ProjectMeta;
use App\Entity\TimesheetMeta;
use App\Entity\UserPreference;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class MetaFieldColumnSubscriberMock implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
TimesheetMetaDisplayEvent::class => ['loadTimesheetField', 200],
CustomerMetaDisplayEvent::class => ['loadCustomerField', 200],
ProjectMetaDisplayEvent::class => ['loadProjectField', 200],
ActivityMetaDisplayEvent::class => ['loadActivityField', 200],
UserPreferenceDisplayEvent::class => ['loadUserField', 200],
];
}
public function loadTimesheetField(TimesheetMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo'));
$event->addField($this->prepareEntity(new TimesheetMeta(), 'foo2'));
}
public function loadCustomerField(CustomerMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new CustomerMeta(), 'customer-foo'));
}
public function loadProjectField(ProjectMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo'));
$event->addField($this->prepareEntity(new ProjectMeta(), 'project-foo2')->setIsVisible(false));
}
public function loadActivityField(ActivityMetaDisplayEvent $event): void
{
$event->addField($this->prepareEntity(new ActivityMeta(), 'activity-foo'));
}
public function loadUserField(UserPreferenceDisplayEvent $event): void
{
$event->addPreference(new UserPreference('mypref', 'hello world'));
}
private function prepareEntity(MetaTableTypeInterface $meta, string $name): MetaTableTypeInterface
{
return $meta
->setLabel('Working place')
->setName($name)
->setType(TextType::class)
->setIsVisible(true);
}
}

View File

@@ -27,7 +27,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
return new ColorChoicesValidator();
}
public static function getValidColors()
public static function getValidColors(): iterable
{
yield ['#000000'];
yield ['#fff000'];
@@ -57,7 +57,7 @@ class ColorChoicesValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation();
}
public static function getInvalidColors()
public static function getInvalidColors(): iterable
{
yield ['sdf_sdf|#000000', null, 'sdf_sdf', '#000000'];
yield ['sdfghjklöß.|#aaabbb', null, 'sdfghjklöß.', '#aaabbb'];

View File

@@ -27,7 +27,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
return new DateTimeFormatValidator();
}
public static function getValidData()
public static function getValidData(): array
{
return [
['10:00'],
@@ -56,7 +56,7 @@ class DateTimeFormatValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation();
}
public static function getInvalidData()
public static function getInvalidData(): array
{
return [
['13-13'],

View File

@@ -0,0 +1,92 @@
<?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\Validator\Constraints\ExportRenderer;
use App\Validator\Constraints\ExportRendererValidator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @covers \App\Validator\Constraints\ExportRenderer
* @covers \App\Validator\Constraints\ExportRendererValidator
* @extends ConstraintValidatorTestCase<ExportRendererValidator>
*/
class ExportRendererValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ExportRendererValidator
{
return new ExportRendererValidator();
}
public static function getValidColors(): iterable
{
yield ['csv'];
yield ['xlsx'];
yield [null];
}
public function testConstraintIsInvalid(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->validator->validate('#000', new NotBlank());
}
/**
* @dataProvider getValidColors
*/
public function testConstraintWithValidColor(?string $color): void
{
$constraint = new ExportRenderer();
$this->validator->validate($color, $constraint);
$this->assertNoViolation();
}
public static function getInvalidColors(): iterable
{
yield ['CSV'];
yield ['XLSX'];
yield ['PDF'];
yield ['HTML'];
yield ['fff000'];
yield ['000aaa'];
yield ['fffaaa'];
yield ['#f'];
yield ['#ff'];
yield ['#ffdd'];
yield ['#ffddd'];
yield ['#ffddddd'];
yield [new \stdClass(), 'object'];
yield [[], 'array'];
}
/**
* @dataProvider getInvalidColors
*/
public function testValidationError(mixed $color, ?string $parameterType = null): void
{
$constraint = new ExportRenderer();
$this->validator->validate($color, $constraint);
if (\is_string($color)) {
$expectedFormat = '"' . $color . '"';
} else {
$expectedFormat = $parameterType ?? '';
}
$this->buildViolation('Unknown exporter type.')
->setParameter('{{ value }}', $expectedFormat)
->setCode(ExportRenderer::UNKNOWN_TYPE)
->assertRaised();
}
}

View File

@@ -27,7 +27,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
return new HexColorValidator();
}
public static function getValidColors()
public static function getValidColors(): iterable
{
yield ['#000'];
yield ['#aaa'];
@@ -56,7 +56,7 @@ class HexColorValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation();
}
public static function getInvalidColors()
public static function getInvalidColors(): iterable
{
yield ['string'];
yield ['000'];

View File

@@ -32,7 +32,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
return new RoleValidator($roleService);
}
public static function getValidRoles()
public static function getValidRoles(): array
{
return [
[User::ROLE_USER],
@@ -69,7 +69,7 @@ class RoleValidatorTest extends ConstraintValidatorTestCase
->assertRaised();
}
public static function getInvalidRoles()
public static function getInvalidRoles(): array
{
return [
['foo'],

View File

@@ -52,7 +52,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation();
}
public static function getValidTimes()
public static function getValidTimes(): array
{
return [
[''],
@@ -79,7 +79,7 @@ class TimeFormatValidatorTest extends ConstraintValidatorTestCase
->assertRaised();
}
public static function getInvalidTimes()
public static function getInvalidTimes(): array
{
return [
['a'],

View File

@@ -143,7 +143,7 @@ class TimesheetBasicValidatorTest extends ConstraintValidatorTestCase
->assertRaised();
}
public static function getProjectStartEndTestData()
public static function getProjectStartEndTestData(): iterable
{
yield [new \DateTime(), new \DateTime(), [
['begin_date', TimesheetBasic::PROJECT_NOT_STARTED, 'The project has not started at that time.'],

View File

@@ -195,7 +195,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
$this->assertNoViolation();
}
public static function getViolationTestData()
public static function getViolationTestData(): array
{
return [
// activity: violations ----------------------------------------------------------------------
@@ -294,7 +294,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
string $duration,
array $rawData = [],
?Rate $rate = null
) {
): void {
$activityStatistic = new ActivityStatistic();
if ($activityDuration !== null) {
$activityStatistic->setDuration($activityDuration);

View File

@@ -171,7 +171,7 @@ class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
}
}
public static function getTestData()
public static function getTestData(): iterable
{
// changing before last dockdown period is not allowed
yield [false, false, '-5 days', '+5 days', true];
@@ -219,7 +219,7 @@ class TimesheetLockdownValidatorTest extends ConstraintValidatorTestCase
}
}
public static function getConfigTestData()
public static function getConfigTestData(): iterable
{
yield [false, false, null, null, null, false];
yield [false, false, '+5 days', null, null, false];

View File

@@ -94,7 +94,7 @@ class TimesheetRestartValidatorTest extends ConstraintValidatorTestCase
}
}
public static function getTestData()
public static function getTestData(): iterable
{
yield [false, 'end_date', 'default'];
yield [true, null, 'default'];

View File

@@ -58,7 +58,7 @@ class TimesheetZeroDurationValidatorTest extends ConstraintValidatorTestCase
$this->validator->validate(new NotBlank(), new TimesheetZeroDuration(['message' => 'Duration cannot be zero.']));
}
private function prepareTimesheet()
private function prepareTimesheet(): Timesheet
{
// creates Timesheet with same begin and endtime
$begin = new \DateTime();

View File

@@ -2816,116 +2816,31 @@ parameters:
count: 6
path: Utils/StringHelperTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\ColorChoicesValidatorTest\\:\\:getInvalidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/ColorChoicesValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\ColorChoicesValidatorTest\\:\\:getValidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/ColorChoicesValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\DateTimeFormatValidatorTest\\:\\:getInvalidData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/DateTimeFormatValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\DateTimeFormatValidatorTest\\:\\:getValidData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/DateTimeFormatValidatorTest.php
-
message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#"
count: 1
path: Validator/Constraints/DurationValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\HexColorValidatorTest\\:\\:getInvalidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/HexColorValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\HexColorValidatorTest\\:\\:getValidColors\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/HexColorValidatorTest.php
-
message: "#^Cannot cast mixed to string\\.$#"
count: 1
path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\RoleValidatorTest\\:\\:getInvalidRoles\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\RoleValidatorTest\\:\\:getValidRoles\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/RoleValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimeFormatValidatorTest\\:\\:getInvalidTimes\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimeFormatValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimeFormatValidatorTest\\:\\:getValidTimes\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimeFormatValidatorTest.php
-
message: "#^Cannot call method assertRaised\\(\\) on Symfony\\\\Component\\\\Validator\\\\Test\\\\ConstraintViolationAssertion\\|null\\.$#"
count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBasicValidatorTest\\:\\:getProjectStartEndTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBasicValidatorTest\\:\\:testEndBeforeWithProjectStartAndEnd\\(\\) has parameter \\$violations with no value type specified in iterable type array\\.$#"
count: 1
path: Validator/Constraints/TimesheetBasicValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:getViolationTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:testWithActivityTimeBudget\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetBudgetUsedValidatorTest\\:\\:testWithActivityTimeBudget\\(\\) has parameter \\$rawData with no value type specified in iterable type array\\.$#"
count: 1
path: Validator/Constraints/TimesheetBudgetUsedValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetLockdownValidatorTest\\:\\:getConfigTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetLockdownValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetLockdownValidatorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetLockdownValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetRestartValidatorTest\\:\\:getTestData\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetRestartValidatorTest.php
-
message: "#^Method App\\\\Tests\\\\Validator\\\\Constraints\\\\TimesheetZeroDurationValidatorTest\\:\\:prepareTimesheet\\(\\) has no return type specified\\.$#"
count: 1
path: Validator/Constraints/TimesheetZeroDurationValidatorTest.php
-
message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#"
count: 1