diff --git a/.env b/.env index 8ccf894..11d946d 100644 --- a/.env +++ b/.env @@ -29,5 +29,5 @@ APP_SECRET=5ea76603566babff85ae4fa3e7d44071 # For an SQLite database, use: "sqlite:///%kernel.project_dir%/var/data.db" # For a PostgreSQL database, use: "postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=11&charset=utf8" # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml -DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7 +DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db ###< doctrine/doctrine-bundle ### diff --git a/.env.test b/.env.test index d048686..58ce8ef 100644 --- a/.env.test +++ b/.env.test @@ -3,3 +3,4 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther +DATABASE_URL=sqlite:///%kernel.project_dir%/var/test.db diff --git a/composer.json b/composer.json index 5e07b41..ca8800f 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "twig/twig": "^2.12|^3.0" }, "require-dev": { + "dama/doctrine-test-bundle": "^6.3", "symfony/browser-kit": "^5.1", "symfony/css-selector": "^5.1", "symfony/debug-bundle": "^5.1", diff --git a/composer.lock b/composer.lock index fe21e51..ff2c8ab 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "42c5e66445821e40a4194fe5a0a8a7b7", + "content-hash": "a571222fe89b2d15a25ad09857c44cc6", "packages": [ { "name": "composer/package-versions-deprecated", @@ -7256,6 +7256,63 @@ } ], "packages-dev": [ + { + "name": "dama/doctrine-test-bundle", + "version": "v6.3.3", + "source": { + "type": "git", + "url": "https://github.com/dmaicher/doctrine-test-bundle.git", + "reference": "a364cfee35acb7d37698c4749f7dd34d04646535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dmaicher/doctrine-test-bundle/zipball/a364cfee35acb7d37698c4749f7dd34d04646535", + "reference": "a364cfee35acb7d37698c4749f7dd34d04646535", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^2.9,>=2.9.3", + "doctrine/doctrine-bundle": "^1.11 || ^2.0", + "php": "^7.1", + "symfony/framework-bundle": "^3.4 || ^4.3 || ^5.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "symfony/phpunit-bridge": "^4.3 || ^5.0", + "symfony/yaml": "^3.4 || ^4.3 || ^5.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "7.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "DAMA\\DoctrineTestBundle\\": "src/DAMA/DoctrineTestBundle" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Maicher", + "email": "mail@dmaicher.de" + } + ], + "description": "Symfony bundle to isolate doctrine database tests and improve test performance", + "keywords": [ + "doctrine", + "isolation", + "performance", + "symfony", + "tests" + ], + "time": "2020-09-04T05:50:21+00:00" + }, { "name": "nikic/php-parser", "version": "v4.10.2", diff --git a/config/bundles.php b/config/bundles.php index 70fae29..e222496 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,5 @@ return [ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], ]; diff --git a/config/packages/test/dama_doctrine_test_bundle.yaml b/config/packages/test/dama_doctrine_test_bundle.yaml new file mode 100644 index 0000000..80b0091 --- /dev/null +++ b/config/packages/test/dama_doctrine_test_bundle.yaml @@ -0,0 +1,4 @@ +dama_doctrine_test: + enable_static_connection: true + enable_static_meta_data_cache: true + enable_static_query_cache: true diff --git a/migrations/Version20201020204121.php b/migrations/Version20201020204121.php new file mode 100644 index 0000000..0965b6c --- /dev/null +++ b/migrations/Version20201020204121.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE entry (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, title VARCHAR(255) NOT NULL, start DATETIME NOT NULL, "end" DATETIME NOT NULL)'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE entry'); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 29f86af..378ddb6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -33,4 +33,8 @@ + + + + diff --git a/src/Controller/TimeController.php b/src/Controller/TimeController.php index 895ae0a..be05e0b 100644 --- a/src/Controller/TimeController.php +++ b/src/Controller/TimeController.php @@ -21,6 +21,7 @@ namespace App\Controller; * 02110-1301, USA. */ +use App\Persistence\Entries; use App\Service\ActiveEntry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -33,15 +34,24 @@ class TimeController extends AbstractController */ private $activeEntry; - public function __construct(ActiveEntry $activeEntry) - { + /** + * @var Entries + */ + private $entries; + + public function __construct( + ActiveEntry $activeEntry, + Entries $entries + ) { $this->activeEntry = $activeEntry; + $this->entries = $entries; } public function index(): Response { return $this->render('time/index.html.twig', [ 'entry' => $this->activeEntry->get(), + 'entries' => $this->entries->get(10), ]); } diff --git a/src/Entity/Entry.php b/src/Entity/Entry.php index 5a31ef9..903152c 100644 --- a/src/Entity/Entry.php +++ b/src/Entity/Entry.php @@ -21,22 +21,37 @@ namespace App\Entity; * 02110-1301, USA. */ +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity() + */ class Entry { + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + */ + private $id; + /** * @var string + * @ORM\Column(type="string", length=255) */ private $title = ''; /** * @var \DateTimeImmutable|null + * @ORM\Column(type="datetime") */ private $start; /** * @var \DateTimeImmutable|null + * @ORM\Column(type="datetime") */ - private $stop; + private $end; /** * @var bool @@ -49,6 +64,20 @@ class Entry $this->title = $title; } + public static function fromPersistence( + string $title, + \DateTimeImmutable $start, + \DateTimeImmutable $end + ): self { + $entry = new static(); + + $entry->title = $title; + $entry->start = $start; + $entry->end = $end; + + return $entry; + } + public function start(): void { $this->start = new \DateTimeImmutable(); @@ -57,7 +86,7 @@ class Entry public function stop(): void { - $this->stop = new \DateTimeImmutable(); + $this->end = new \DateTimeImmutable(); $this->running = false; } @@ -70,4 +99,19 @@ class Entry { return $this->title; } + + public function getBegan(): ?\DateTimeImmutable + { + return $this->start; + } + + public function getEnded(): ?\DateTimeImmutable + { + return $this->end; + } + + public function getDuration(): \DateInterval + { + return $this->start->diff($this->end); + } } diff --git a/src/Persistence/Entries.php b/src/Persistence/Entries.php new file mode 100644 index 0000000..8c6989b --- /dev/null +++ b/src/Persistence/Entries.php @@ -0,0 +1,40 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use App\Entity\Entry; + +interface Entries +{ + /** + * Adds the given entry to the persistence. + * Those entries don't have identifiers and are always new. + */ + public function add(Entry $entry): void; + + /** + * Get amount of recent entries from persistence. + * + * @return array + */ + public function get(int $limit): array; +} diff --git a/src/Persistence/LocalDatabase.php b/src/Persistence/LocalDatabase.php new file mode 100644 index 0000000..cd61139 --- /dev/null +++ b/src/Persistence/LocalDatabase.php @@ -0,0 +1,71 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use App\Entity\Entry; +use Doctrine\DBAL\Connection; + +class LocalDatabase implements Entries +{ + /** + * @var Connection + */ + private $connection; + + public function __construct( + Connection $connection + ) { + $this->connection = $connection; + } + + public function add(Entry $entry): void + { + $this->connection->insert('entry', [ + 'title' => $entry->getTitle(), + 'start' => $entry->getBegan()->format('U'), + 'end' => $entry->getEnded()->format('U'), + ]); + } + + public function get(int $limit): array + { + $queryBuilder = $this->connection->createQueryBuilder(); + + $queryBuilder->select('*'); + $queryBuilder->from('entry'); + $queryBuilder->setMaxResults($limit); + $queryBuilder->orderBy('id', 'desc'); + + $entries = []; + + $result = $queryBuilder->execute(); + while ($entry = $result->fetch()) { + $entries[] = Entry::fromPersistence( + $entry['title'], + new \DateTimeImmutable('@' . $entry['start']), + new \DateTimeImmutable('@' . $entry['end']) + ); + } + + return $entries; + } +} diff --git a/src/Service/ActiveEntry.php b/src/Service/ActiveEntry.php index 22bf33e..4b48a89 100644 --- a/src/Service/ActiveEntry.php +++ b/src/Service/ActiveEntry.php @@ -22,6 +22,7 @@ namespace App\Service; */ use App\Entity\Entry; +use App\Persistence\Entries; use Symfony\Component\HttpFoundation\Session\SessionInterface; class ActiveEntry @@ -31,9 +32,17 @@ class ActiveEntry */ private $session; - public function __construct(SessionInterface $session) - { + /** + * @var Entries + */ + private $persistence; + + public function __construct( + SessionInterface $session, + Entries $persistence + ) { $this->session = $session; + $this->persistence = $persistence; } public function startNew( @@ -51,7 +60,12 @@ class ActiveEntry public function stopRunning(): void { - $this->get()->stop(); + $entry = $this->get(); + + $entry->stop(); + + $this->persistence->add($entry); + $this->session->remove('runningEntry'); } diff --git a/symfony.lock b/symfony.lock index 995a50f..9b5322d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -2,6 +2,18 @@ "composer/package-versions-deprecated": { "version": "1.11.99" }, + "dama/doctrine-test-bundle": { + "version": "4.0", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "master", + "version": "4.0", + "ref": "56eaa387b5e48ebcc7c95a893b47dfa1ad51449c" + }, + "files": [ + "config/packages/test/dama_doctrine_test_bundle.yaml" + ] + }, "doctrine/annotations": { "version": "1.0", "recipe": { diff --git a/templates/time/entries.html.twig b/templates/time/entries.html.twig new file mode 100644 index 0000000..7088208 --- /dev/null +++ b/templates/time/entries.html.twig @@ -0,0 +1,12 @@ +
+ {% for entry in entries %} +
+
{{ entry.title }}
+ + + - + + +
+ {% endfor %} +
diff --git a/templates/time/index.html.twig b/templates/time/index.html.twig index 88fa861..b58c0e4 100644 --- a/templates/time/index.html.twig +++ b/templates/time/index.html.twig @@ -4,4 +4,5 @@

Time entries

{{ include('time/timer.html.twig') }} + {{ include('time/entries.html.twig') }} {% endblock %} diff --git a/tests/Functional/Controller/TimeControllerTest.php b/tests/Functional/Controller/TimeControllerTest.php index f5d4af4..7945de9 100644 --- a/tests/Functional/Controller/TimeControllerTest.php +++ b/tests/Functional/Controller/TimeControllerTest.php @@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; /** * @covers App\Controller\TimeController + * @covers App\Persistence\LocalDatabase * @uses App\Entity\Entry * @uses App\Service\ActiveEntry */ @@ -33,7 +34,7 @@ class TimeControllerTest extends WebTestCase /** * @test */ - public function rootCanBeOpened() + public function rootCanBeOpened(): void { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -45,7 +46,7 @@ class TimeControllerTest extends WebTestCase /** * @test */ - public function newEntryCanBeStarted() + public function newEntryCanBeStarted(): void { $client = static::createClient(); @@ -62,7 +63,7 @@ class TimeControllerTest extends WebTestCase /** * @test */ - public function runningEntryCanBeStopped() + public function runningEntryCanBeStopped(): void { $client = static::createClient(); @@ -83,7 +84,7 @@ class TimeControllerTest extends WebTestCase /** * @test */ - public function noneRunningEntryCanBeStopped() + public function noneRunningEntryCanBeStopped(): void { $client = static::createClient(); @@ -95,4 +96,26 @@ class TimeControllerTest extends WebTestCase $this->assertInputValueSame('title', ''); $this->assertSelectorTextSame('button', 'Start'); } + + /** + * @test + */ + public function previousEntriesAreShownOnIndex(): void + { + $client = static::createClient(); + + $client->request('GET', '/'); + $client->submitForm('Start', ['title' => 'Test Entry 1']); + $client->request('GET', '/'); + $client->submitForm('Stop'); + + $client->request('GET', '/'); + $client->submitForm('Start', ['title' => 'Test Entry 2']); + $client->request('GET', '/'); + $client->submitForm('Stop'); + + $crawler = $client->request('GET', '/'); + $this->assertSelectorTextContains('article:nth-of-type(1)', 'Test Entry 2'); + $this->assertSelectorTextContains('article:nth-of-type(2)', 'Test Entry 1'); + } } diff --git a/tests/Unit/Entity/EntryTest.php b/tests/Unit/Entity/EntryTest.php index cca49ff..8045c18 100644 --- a/tests/Unit/Entity/EntryTest.php +++ b/tests/Unit/Entity/EntryTest.php @@ -32,7 +32,7 @@ class EntryTest extends TestCase /** * @test */ - public function canBeCreated() + public function canBeCreatedViaConstructor(): void { $subject = new Entry(); @@ -42,7 +42,25 @@ class EntryTest extends TestCase /** * @test */ - public function canBeCreatedWithTitle() + public function canBeCreatedFromPersistence(): void + { + $subject = Entry::fromPersistence( + 'Example Title', + new \DateTimeImmutable('2020-10-29 17:10:05'), + new \DateTimeImmutable('2020-10-29 18:12:15') + ); + + static::assertInstanceOf(Entry::class, $subject); + static::assertSame('Example Title', $subject->getTitle()); + static::assertSame('2020-10-29 17:10:05', $subject->getBegan()->format('Y-m-d H:i:s')); + static::assertSame('2020-10-29 18:12:15', $subject->getEnded()->format('Y-m-d H:i:s')); + static::assertSame('01:02:10', $subject->getDuration()->format('%r%H:%I:%S')); + } + + /** + * @test + */ + public function canBeCreatedWithTitle(): void { $subject = new Entry('Example title'); @@ -52,7 +70,7 @@ class EntryTest extends TestCase /** * @test */ - public function returnsProvidedTitle() + public function returnsProvidedTitle(): void { $subject = new Entry('Example title'); @@ -62,7 +80,7 @@ class EntryTest extends TestCase /** * @test */ - public function canBeStarted() + public function canBeStarted(): void { $subject = new Entry(); @@ -74,7 +92,7 @@ class EntryTest extends TestCase /** * @test */ - public function canBeStopped() + public function canBeStopped(): void { $subject = new Entry(); @@ -87,7 +105,7 @@ class EntryTest extends TestCase /** * @test */ - public function canBeStoppedEvenIfNotRunning() + public function canBeStoppedEvenIfNotRunning(): void { $subject = new Entry(); @@ -95,4 +113,59 @@ class EntryTest extends TestCase static::assertFalse($subject->isRunning()); } + + /** + * @test + */ + public function returnsNullForBeganBeforeStarted(): void + { + $subject = new Entry(); + + static::assertNull($subject->getBegan()); + } + + /** + * @test + */ + public function returnsBeganAfterStarted(): void + { + $subject = new Entry(); + $subject->start(); + + static::assertInstanceOf(\DateTimeImmutable::class, $subject->getBegan()); + } + + /** + * @test + */ + public function returnsNullForEndedBeforeStarted(): void + { + $subject = new Entry(); + + static::assertNull($subject->getEnded()); + } + + /** + * @test + */ + public function returnsEndedAfterStoped(): void + { + $subject = new Entry(); + $subject->start(); + $subject->stop(); + + static::assertInstanceOf(\DateTimeImmutable::class, $subject->getEnded()); + } + + /** + * @test + */ + public function returnsDurationAfterStopped(): void + { + $subject = new Entry(); + $subject->start(); + $subject->stop(); + + static::assertInstanceOf(\DateInterval::class, $subject->getDuration()); + } } diff --git a/tests/Unit/Service/ActiveEntryTest.php b/tests/Unit/Service/ActiveEntryTest.php index e024407..6bdebb9 100644 --- a/tests/Unit/Service/ActiveEntryTest.php +++ b/tests/Unit/Service/ActiveEntryTest.php @@ -22,12 +22,14 @@ namespace App\Tests\Service; */ use App\Entity\Entry; +use App\Persistence\Entries; use App\Service\ActiveEntry; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\HttpFoundation\Session\SessionInterface; + /** * @covers App\Service\ActiveEntry */ @@ -36,11 +38,13 @@ class ActiveEntryTest extends TestCase /** * @test */ - public function canBeCreated() + public function canBeCreated(): void { $session = $this->prophesize(SessionInterface::class); + $persistence = $this->prophesize(Entries::class); $subject = new ActiveEntry( - $session->reveal() + $session->reveal(), + $persistence->reveal() ); static::assertInstanceOf(ActiveEntry::class, $subject); @@ -49,11 +53,13 @@ class ActiveEntryTest extends TestCase /** * @test */ - public function returnsEmptyEntryAsDefault() + public function returnsEmptyEntryAsDefault(): void { $session = $this->prophesize(SessionInterface::class); + $persistence = $this->prophesize(Entries::class); $subject = new ActiveEntry( - $session->reveal() + $session->reveal(), + $persistence->reveal() ); $entry = $subject->get(); @@ -66,11 +72,13 @@ class ActiveEntryTest extends TestCase /** * @test */ - public function canStartANewEntry() + public function canStartANewEntry(): void { $session = $this->prophesize(SessionInterface::class); + $persistence = $this->prophesize(Entries::class); $subject = new ActiveEntry( - $session->reveal() + $session->reveal(), + $persistence->reveal() ); $subject->startNew('Example title'); @@ -85,12 +93,14 @@ class ActiveEntryTest extends TestCase /** * @test */ - public function returnsStartedEntry() + public function returnsStartedEntry(): void { $session = $this->prophesize(SessionInterface::class); + $persistence = $this->prophesize(Entries::class); $entry = $this->prophesize(Entry::class); $subject = new ActiveEntry( - $session->reveal() + $session->reveal(), + $persistence->reveal() ); $session->set('runningEntry', Argument::type(Entry::class))->shouldBeCalled(); @@ -104,12 +114,14 @@ class ActiveEntryTest extends TestCase /** * @test */ - public function stopsRunningEntry() + public function stopsRunningEntry(): void { $session = $this->prophesize(SessionInterface::class); + $persistence = $this->prophesize(Entries::class); $entry = $this->prophesize(Entry::class); $subject = new ActiveEntry( - $session->reveal() + $session->reveal(), + $persistence->reveal() ); $session->set('runningEntry', Argument::type(Entry::class))->shouldBeCalled(); @@ -119,5 +131,6 @@ class ActiveEntryTest extends TestCase $subject->stopRunning(); $session->remove('runningEntry')->shouldBeCalled(); + $persistence->add($entry->reveal())->shouldBeCalled(); } }