added invoice archive & configurable invoice numbers (#1541)

This commit is contained in:
Kevin Papst
2020-03-14 01:16:58 +01:00
committed by GitHub
parent dd64eb98f9
commit e6e6a1eeea
106 changed files with 2587 additions and 339 deletions

View File

@@ -321,12 +321,12 @@ abstract class ControllerBaseTest extends WebTestCase
*/
protected function assertIsRedirect(HttpKernelBrowser $client, $url = null)
{
self::assertTrue($client->getResponse()->isRedirect());
self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect');
if (null === $url) {
return;
}
self::assertTrue($client->getResponse()->headers->has('Location'));
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'));
self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header');
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'), 'Redirect URL does not match');
}
}

View File

@@ -16,12 +16,44 @@ use App\Form\Type\DateRangeType;
use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @group integration
*/
class InvoiceControllerTest extends ControllerBaseTest
{
protected function setUp(): void
{
parent::setUp();
$path = __DIR__ . '/../_data/invoices/';
if (is_dir($path)) {
$files = glob($path . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->clearInvoiceFiles();
}
private function clearInvoiceFiles()
{
$path = __DIR__ . '/../_data/invoices/';
if (is_dir($path)) {
$files = glob($path . '*');
foreach ($files as $file) {
unlink($file);
}
}
}
public function testIsSecure()
{
$this->assertUrlIsSecured('/invoice/');
@@ -76,7 +108,6 @@ class InvoiceControllerTest extends ControllerBaseTest
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);
@@ -111,7 +142,6 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertEquals($template->getCompany(), $values['company']);
$this->assertEquals($template->getAddress(), $values['address']);
$this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
$this->assertEquals($template->getNumberGenerator(), $values['numberGenerator']);
}
public function testPrintAction()
@@ -153,8 +183,8 @@ class InvoiceControllerTest extends ControllerBaseTest
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
// but the datatable with all timesheets
$this->assertDataTableRowCount($client, 'datatable_invoice', 20);
// but the datatable with all timesheets + 1 row for the total
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
@@ -180,6 +210,92 @@ class InvoiceControllerTest extends ControllerBaseTest
}
}
public function testPrintActionAsAdminWithDownloadAndStatusChange()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($client, $fixture);
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_ADMIN))
->setAmount(20)
->setStartDate($begin)
;
$this->importFixture($client, $fixture);
$this->request($client, '/invoice/');
$this->assertTrue($client->getResponse()->isSuccessful());
$dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d');
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'daterange' => $dateRange,
'customer' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
// but the datatable with all timesheets + 1 row for the total
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?create='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'daterange' => $dateRange,
'customer' => 1,
'project' => 1,
'markAsExported' => 1,
]);
$this->assertIsRedirect($client, '/invoice/show?id=1');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_invoices', 1);
// make sure the invoice is saved
$this->request($client, '/invoice/download/1');
$response = $client->getResponse();
$this->assertTrue($response->isSuccessful());
self::assertInstanceOf(BinaryFileResponse::class, $response);
self::assertFileExists($response->getFile());
$this->request($client, '/invoice/change-status/1/pending');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->request($client, '/invoice/change-status/1/paid');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->request($client, '/invoice/change-status/1/new');
$this->assertIsRedirect($client, '/invoice/show');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
}
public function testEditTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -196,7 +312,6 @@ class InvoiceControllerTest extends ControllerBaseTest
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);

View File

@@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 109);
$this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -68,6 +68,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
{
return [
['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')],
['form[name=system_configuration_form_invoice]', $this->createUrl('/admin/system-config/update/invoice')],
['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')],
['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')],
['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')],

View File

@@ -289,6 +289,8 @@ class ConfigurationTest extends TestCase
0 => 'var/invoices/',
1 => 'templates/invoice/renderer/',
],
'simple_form' => true,
'number_format' => '{Y}/{cy,3}',
],
'languages' => [],
'calendar' => [

View File

@@ -0,0 +1,169 @@
<?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\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\Invoice
*/
class InvoiceTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Invoice();
self::assertNull($sut->getCreatedAt());
self::assertNull($sut->getCurrency());
self::assertNull($sut->getCustomer());
self::assertNull($sut->getDueDate());
self::assertEquals(30, $sut->getDueDays());
self::assertNull($sut->getId());
self::assertNull($sut->getInvoiceFilename());
self::assertNull($sut->getInvoiceNumber());
self::assertEquals(0.0, $sut->getTax());
self::assertEquals(0.0, $sut->getTotal());
self::assertNull($sut->getUser());
self::assertEquals(0.0, $sut->getVat());
self::assertTrue($sut->isNew());
self::assertFalse($sut->isPending());
self::assertFalse($sut->isPaid());
self::assertFalse($sut->isOverdue());
}
public function testSetterAndGetter()
{
$date = new \DateTime('-2 months');
$sut = new Invoice();
$sut->setIsPending();
self::assertFalse($sut->isNew());
self::assertTrue($sut->isPending());
self::assertFalse($sut->isPaid());
$sut->setIsPaid();
self::assertFalse($sut->isNew());
self::assertFalse($sut->isPending());
self::assertTrue($sut->isPaid());
$sut->setIsNew();
self::assertTrue($sut->isNew());
self::assertFalse($sut->isPending());
self::assertFalse($sut->isPaid());
self::assertFalse($sut->isOverdue());
$sut->setModel($this->getInvoiceModel($date));
self::assertTrue($sut->isOverdue());
self::assertEquals($date, $sut->getCreatedAt());
self::assertEquals('USD', $sut->getCurrency());
self::assertNotNull($sut->getCustomer());
self::assertNotNull($sut->getDueDate());
self::assertEquals(9, $sut->getDueDays());
self::assertNull($sut->getId());
self::assertNull($sut->getInvoiceFilename());
self::assertEquals(date('ymd', $date->getTimestamp()), $sut->getInvoiceNumber());
self::assertEquals(55.72, $sut->getTax());
self::assertEquals(348.99, $sut->getTotal());
self::assertNotNull($sut->getUser());
self::assertEquals(19, $sut->getVat());
}
protected function getInvoiceModel(\DateTime $created): InvoiceModel
{
$user = new User();
$user->setUsername('one-user');
$user->setTitle('user title');
$user->setAlias('genious alias');
$user->setEmail('fantastic@four');
$user->addPreference((new UserPreference())->setName('kitty')->setValue('kat'));
$user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
$customer = new Customer();
$customer->setName('customer,with/special#name');
$customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
$customer->setVatId('kjuo8967');
$template = new InvoiceTemplate();
$template->setTitle('a test invoice template title');
$template->setVat(19);
$template->setDueDays(9);
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$timesheet = new Timesheet();
$timesheet
->setDuration(3600)
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
->setProject($project)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entries = [$timesheet];
$query = new InvoiceQuery();
$query->setActivity($activity);
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
$model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->addEntries($entries);
$model->setQuery($query);
$model->setUser($user);
$model->setInvoiceDate($created);
$calculator = new DefaultCalculator();
$calculator->setModel($model);
$model->setCalculator($calculator);
$numberGenerator = new DateNumberGenerator();
$numberGenerator->setModel($model);
$model->setNumberGenerator($numberGenerator);
return $model;
}
}

View File

@@ -33,33 +33,6 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEmptyModel(new ActivityInvoiceCalculator());
}
public function testExceptionNoActivity()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot work with invoice items that do not have an activity');
$timesheet = new Timesheet();
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testExceptionNoId()
{
$this->expectException('Exception');
$this->expectExceptionMessage('Cannot handle un-persisted activities');
$timesheet = new Timesheet();
$timesheet->setActivity(new Activity());
$sut = new ActivityInvoiceCalculator();
$model = $this->getEmptyModel();
$model->addEntries([$timesheet]);
$sut->setModel($model);
$sut->getEntries();
}
public function testWithMultipleEntries()
{
$customer = new Customer();
@@ -128,7 +101,35 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
->setActivity($activity3)
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$timesheet6 = new Timesheet();
$timesheet6
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$timesheet7 = new Timesheet();
$timesheet7
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setActivity(new Activity())
->setProject((new Project())->setName('bar'));
$timesheet8 = new Timesheet();
$timesheet8
->setBegin(new \DateTime())
->setEnd(new \DateTime())
->setDuration(0)
->setRate(0)
->setUser(new User())
->setProject((new Project())->setName('bar'));
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6, $timesheet7, $timesheet8];
$query = new InvoiceQuery();
$query->setActivity($activity1);
@@ -148,7 +149,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$this->assertEquals('EUR', $model->getCurrency());
$this->assertEquals(2521.12, $sut->getSubtotal());
$this->assertEquals(6600, $sut->getTimeWorked());
$this->assertEquals(3, count($sut->getEntries()));
$this->assertEquals(5, count($sut->getEntries()));
$entries = $sut->getEntries();
$this->assertEquals(404.38, $entries[0]->getRate());

View File

@@ -0,0 +1,53 @@
<?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\Invoice;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\InvoiceFilename
*/
class InvoiceFilenameTest extends TestCase
{
public function testInvoiceFilename()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$model = new InvoiceModel(new DebugFormatter());
$model->setNumberGenerator(new DateNumberGenerator());
$model->setTemplate($template);
$model->setCustomer($customer);
$datePrefix = date('ymd');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix, $sut->getFilename());
self::assertEquals($datePrefix, (string) $sut);
$customer->setName('foo');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-foo', $sut->getFilename());
self::assertEquals($datePrefix . '-foo', (string) $sut);
$customer->setCompany('barß / laölala # ldksjf 123');
$sut = new InvoiceFilename($model);
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', $sut->getFilename());
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', (string) $sut);
}
}

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\Invoice\NumberGenerator;
use App\Configuration\SystemConfiguration;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
use App\Repository\InvoiceRepository;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\NumberGenerator\ConfigurableNumberGenerator
*/
class ConfigurableNumberGeneratorTest extends TestCase
{
private function getSut(string $format)
{
$config = $this->createMock(SystemConfiguration::class);
$config->expects($this->any())
->method('find')
->willReturn($format);
$repository = $this->createMock(InvoiceRepository::class);
$repository
->expects($this->any())
->method('getCounterForAllTime')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForYear')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForMonth')
->willReturn(1);
$repository
->expects($this->any())
->method('getCounterForDay')
->willReturn(1);
return new ConfigurableNumberGenerator($repository, $config);
}
public function getTestData()
{
$timestamp = time();
return [
// simple tests for single calls
['{date}', date('ymd'), $timestamp],
['{Y}', date('Y'), $timestamp],
['{y}', date('y'), $timestamp],
['{M}', date('m'), $timestamp],
['{m}', date('n'), $timestamp],
['{D}', date('d'), $timestamp],
['{d}', date('j'), $timestamp],
['{c}', '2', $timestamp],
['{cy}', '2', $timestamp],
['{cm}', '2', $timestamp],
['{cd}', '2', $timestamp],
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
['{date,10}', '0000' . date('ymd'), $timestamp],
['{Y,6}', '00' . date('Y'), $timestamp],
['{M,3}', '0' . date('m'), $timestamp],
['{D,3}', '0' . date('d'), $timestamp],
['{c,2}', '02', $timestamp],
['{cy,2}', '02', $timestamp],
['{cm,2}', '02', $timestamp],
['{cd,2}', '02', $timestamp],
// mixing identifiers
['{Y}{cy}', date('Y') . '2', $timestamp],
['{Y}{cy}{m}', date('Y') . '2' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
['{Y,5}/{cy,5}', '0' . date('Y') . '/00002', $timestamp],
];
}
/**
* @dataProvider getTestData
*/
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, int $timestamp)
{
$sut = $this->getSut($format);
$model = new InvoiceModel(new DebugFormatter());
$model->setInvoiceDate((new \DateTime())->setTimestamp($timestamp));
$sut->setModel($model);
$this->assertEquals($expectedInvoiceNumber, $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
}
}

View File

@@ -25,6 +25,6 @@ class DateNumberGeneratorTest extends TestCase
$sut->setModel(new InvoiceModel(new DebugFormatter()));
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
$this->assertEquals('date', $sut->getId());
}
}

View File

@@ -0,0 +1,65 @@
<?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\Invoice\Renderer;
use App\Invoice\Renderer\PdfRenderer;
use App\Utils\MPdfConverter;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Invoice\Renderer\PdfRenderer
* @group integration
*/
class PdfRendererTest extends KernelTestCase
{
use RendererTestTrait;
public function testSupports()
{
$loader = new FilesystemLoader();
$env = new Environment($loader);
$sut = new PdfRenderer($env, $this->createMock(MPdfConverter::class));
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.pdf.twig', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
$sut = new PdfRenderer($twig, new MPdfConverter($cacheDir));
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('default.pdf.twig', true);
$response = $sut->render($document, $model);
$this->assertEquals('application/pdf', $response->headers->get('Content-Type'));
}
}

View File

@@ -15,6 +15,7 @@ use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Utils\FileHelper;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
@@ -23,10 +24,16 @@ use Twig\Environment;
*/
class ServiceInvoiceTest extends TestCase
{
private function getSut(array $paths): ServiceInvoice
{
$repo = new InvoiceDocumentRepository($paths);
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
}
public function testEmptyObject()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$this->assertEmpty($sut->getCalculator());
$this->assertIsArray($sut->getCalculator());
@@ -44,8 +51,7 @@ class ServiceInvoiceTest extends TestCase
public function testWithDocumentDirectory()
{
$repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut(['templates/invoice/renderer/']);
$actual = $sut->getDocuments();
$this->assertNotEmpty($actual);
@@ -59,8 +65,7 @@ class ServiceInvoiceTest extends TestCase
public function testAdd()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut = $this->getSut([]);
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator(new DateNumberGenerator());
@@ -74,7 +79,7 @@ class ServiceInvoiceTest extends TestCase
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
$this->assertEquals(1, count($sut->getNumberGenerator()));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default'));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('date'));
$this->assertEquals(1, count($sut->getRenderer()));
}

View File

@@ -0,0 +1,146 @@
{% extends 'invoice/layout.html.twig' %}
{% set language = model.template.language|default(app.request.locale) %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% block invoice %}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span>
<small class="pull-right">{{ 'label.date'|trans({}, 'messages', language) }}: {{ model.invoiceDate|date_short }}</small>
</h2>
</div>
</div>
<div class="row">
<div class="col-sm-5">
{{ 'invoice.from'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.to'|trans({}, 'messages', language) }}
<address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br>
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
{% endif %}
</address>
</div>
</div>
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
<th>{{ 'label.description'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.unit_price'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.amount'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.total_rate'|trans({}, 'messages', language) }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td>
<td nowrap class="text-nowrap text-right">{{ duration }}</td>
<td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans({}, 'messages', language) }}
</td>
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)
</td>
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|nl2br|md2html }}
</div>
{% endif %}
</div>
</div>
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>
{% endblock %}

View File

@@ -0,0 +1,43 @@
<?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\Repository\Loader;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;
abstract class AbstractLoaderTest extends TestCase
{
protected function getEntityManagerMock(int $createQueryBuilderCount)
{
$em = $this->createMock(EntityManager::class);
$qb = $this->createMock(QueryBuilder::class);
$query = $this->createMock(AbstractQuery::class);
$expr = $this->createMock(Expr::class);
$expr->expects($this->any())->method('isNotNull')->willReturn('');
$expr->expects($this->any())->method('in')->willReturn('');
$qb->expects($this->any())->method('andWhere')->willReturnSelf();
$qb->expects($this->any())->method('from')->willReturnSelf();
$qb->expects($this->any())->method('expr')->willReturn($expr);
$qb->expects($this->any())->method('from')->willReturnSelf();
$qb->expects($this->any())->method('select')->willReturnSelf();
$qb->expects($this->any())->method('leftJoin')->willReturnSelf();
$qb->expects($this->any())->method('getQuery')->willReturn($query);
$query->expects($this->any())->method('execute')->willReturn(null);
$em->expects($this->exactly($createQueryBuilderCount))->method('createQueryBuilder')->willReturn($qb);
return $em;
}
}

View File

@@ -0,0 +1,33 @@
<?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\Repository\Loader;
use App\Entity\Activity;
use App\Repository\Loader\ActivityLoader;
/**
* @covers \App\Repository\Loader\ActivityLoader
* @covers \App\Repository\Loader\ActivityIdLoader
*/
class ActivityLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
// mock needs improvements, because it should be 5
$em = $this->getEntityManagerMock(2);
$sut = new ActivityLoader($em);
$entity = $this->createMock(Activity::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Customer;
use App\Repository\Loader\CustomerLoader;
/**
* @covers \App\Repository\Loader\CustomerLoader
* @covers \App\Repository\Loader\CustomerIdLoader
*/
class CustomerLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(2);
$sut = new CustomerLoader($em);
$entity = $this->createMock(Customer::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Invoice;
use App\Repository\Loader\InvoiceLoader;
/**
* @covers \App\Repository\Loader\InvoiceLoader
* @covers \App\Repository\Loader\InvoiceIdLoader
*/
class InvoiceLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(2);
$sut = new InvoiceLoader($em);
$entity = $this->createMock(Invoice::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Project;
use App\Repository\Loader\ProjectLoader;
/**
* @covers \App\Repository\Loader\ProjectLoader
* @covers \App\Repository\Loader\ProjectIdLoader
*/
class ProjectLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(4);
$sut = new ProjectLoader($em);
$entity = $this->createMock(Project::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -0,0 +1,32 @@
<?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\Repository\Loader;
use App\Entity\Team;
use App\Repository\Loader\TeamLoader;
/**
* @covers \App\Repository\Loader\TeamLoader
* @covers \App\Repository\Loader\TeamIdLoader
*/
class TeamLoaderTest extends AbstractLoaderTest
{
public function testLoadResults()
{
$em = $this->getEntityManagerMock(1);
$sut = new TeamLoader($em);
$entity = $this->createMock(Team::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

0
tests/_data/.gitignore vendored Normal file
View File