Merge remote-tracking branch 'origin/feature_form'

This commit is contained in:
Ondrej Vlach 2024-01-17 19:16:06 +01:00
commit be7e1b7180
Signed by: ovlach
GPG Key ID: 4FF1A23B4914DE70
54 changed files with 4121 additions and 15 deletions

6
.env.test Normal file
View File

@ -0,0 +1,6 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots

15
.gitignore vendored
View File

@ -15,3 +15,18 @@ TODO.rs
###> phpstan/phpstan ### ###> phpstan/phpstan ###
phpstan.neon phpstan.neon
###< phpstan/phpstan ### ###< phpstan/phpstan ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor
###< symfony/asset-mapper ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###

View File

@ -2,7 +2,7 @@ FROM php:8.3-fpm
RUN apt-get update && apt-get install -y unzip libzip-dev && rm -rf /var/cache/apt/* RUN apt-get update && apt-get install -y unzip libzip-dev && rm -rf /var/cache/apt/*
RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \ RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \
opentelemetry-php/ext-opentelemetry@main opcache zip grpc opentelemetry-php/ext-opentelemetry@main opcache zip grpc intl
RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer
RUN composer self-update RUN composer self-update
RUN usermod -a -G www-data root RUN usermod -a -G www-data root

3
README.md Normal file
View File

@ -0,0 +1,3 @@
TODO:
-----
- [ ] Chybí speciální slovník nebo vypnutí slovníku pro testy

9
assets/app.js Normal file
View File

@ -0,0 +1,9 @@
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
import 'bootstrap/dist/css/bootstrap.min.css';

BIN
assets/images/wip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

11
assets/styles/app.css Normal file
View File

@ -0,0 +1,11 @@
body {
background-color: skyblue;
}
.header {
background-color: skyblue;
}
.header-another-code {
font-size: 2rem;
}

23
bin/phpunit Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

View File

@ -18,6 +18,8 @@
"open-telemetry/sdk": "^1.0", "open-telemetry/sdk": "^1.0",
"open-telemetry/transport-grpc": "^1.0", "open-telemetry/transport-grpc": "^1.0",
"php-http/httplug": "*", "php-http/httplug": "*",
"symfony/asset": "7.0.*",
"symfony/asset-mapper": "7.0.*",
"symfony/console": "7.0.*", "symfony/console": "7.0.*",
"symfony/dotenv": "7.0.*", "symfony/dotenv": "7.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
@ -26,8 +28,12 @@
"symfony/http-client": "*", "symfony/http-client": "*",
"symfony/options-resolver": "7.0.*", "symfony/options-resolver": "7.0.*",
"symfony/runtime": "7.0.*", "symfony/runtime": "7.0.*",
"symfony/translation": "7.0.*",
"symfony/twig-bundle": "7.0.*", "symfony/twig-bundle": "7.0.*",
"symfony/yaml": "7.0.*" "symfony/validator": "7.0.*",
"symfony/yaml": "7.0.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {
@ -60,7 +66,8 @@
"scripts": { "scripts": {
"auto-scripts": { "auto-scripts": {
"cache:clear": "symfony-cmd", "cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd" "assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
}, },
"post-install-cmd": [ "post-install-cmd": [
"@auto-scripts" "@auto-scripts"
@ -79,6 +86,12 @@
} }
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^1.10" "phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "7.0.*",
"symfony/css-selector": "7.0.*",
"symfony/phpunit-bridge": "^7.0",
"symfony/stopwatch": "7.0.*",
"symfony/web-profiler-bundle": "7.0.*"
} }
} }

2717
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,4 +3,6 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
]; ];

View File

@ -0,0 +1,5 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/

View File

@ -0,0 +1,13 @@
framework:
default_locale: 'cs'
translator:
default_path: '%kernel.project_dir%/translations'
# providers:
# crowdin:
# dsn: '%env(CROWDIN_DSN)%'
# loco:
# dsn: '%env(LOCO_DSN)%'
# lokalise:
# dsn: '%env(LOKALISE_DSN)%'
# phrase:
# dsn: '%env(PHRASE_DSN)%'

View File

@ -1,6 +1,6 @@
twig: twig:
default_path: '%kernel.project_dir%/templates' default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_5_layout.html.twig']
when@test: when@test:
twig: twig:
strict_variables: true strict_variables: true

View File

@ -0,0 +1,15 @@
framework:
validation:
email_validation_mode: html5
enabled: true
translation_domain: validation_errors
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

View File

@ -0,0 +1,17 @@
when@dev:
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect_serializer_data: true
when@test:
web_profiler:
toolbar: false
intercept_redirects: false
framework:
profiler: { collect: false }

View File

@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@ -4,6 +4,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
env(SYMFONY_EXAMPLE_APP_CURRENCIES): '["CZK", "EUR", "USD", "GBP"]'
app.currencies: '%env(json:SYMFONY_EXAMPLE_APP_CURRENCIES)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
@ -21,5 +23,17 @@ services:
- '../src/Entity/' - '../src/Entity/'
- '../src/Kernel.php' - '../src/Kernel.php'
App\Service\StaticCurrencyLister:
arguments:
$available_currencies: '%app.currencies%'
App\Service\StubQRCodeGenerator:
arguments:
$imagePath: '%kernel.project_dir%/assets/images/wip.png'
App\Service\CurrencyListerInterface: '@App\Service\StaticCurrencyLister'
App\Service\QRCodeQROptionsProviderInterface: '@App\Service\QRCodeQROptionsDefaultProvider'
App\Service\QRCodeGeneratorInterface: '@App\Service\StubQRCodeGenerator'
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones

12
config/services_test.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: true
App\Service\QRCodeGeneratorInterface:
lazy: true
public: true
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

25
importmap.php Normal file
View File

@ -0,0 +1,25 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*
* This file has been auto-generated by the importmap commands.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'bootstrap/dist/css/bootstrap.min.css' => [
'version' => '5.3.2',
'type' => 'css',
],
];

View File

@ -1,5 +1,5 @@
parameters: parameters:
level: 6 level: 9
paths: paths:
- bin/ - bin/
- config/ - config/

38
phpunit.xml.dist Normal file
View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.6" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions>
</extensions>
</phpunit>

View File

@ -2,16 +2,51 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\QRCode\QRCode;
use App\Form\Type\QRCodeType;
use App\Service\QRCodeGeneratorInterface;
use App\Service\QRCodeQROptionsProviderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractController { class IndexController extends AbstractController {
public function __construct(private readonly QRCodeQROptionsProviderInterface $qrCodeQROptionsFactory) {}
#[Route('/', name: 'homepage')] #[Route('/', name: 'homepage')]
public function indexAction(): Response public function indexAction(
Request $request,
QRCodeGeneratorInterface $qrCodeGenerator,
): Response
{ {
$result = $this->render('index/homepage.html.twig'); $qrCodeImage = null;
return $result;
$qrCode = $this->createQrCodeEntity();
$form = $this->createForm(QRCodeType::class, $qrCode);
if ($request->isMethod('POST')) {
$form->submit($request->request->all($form->getName()));
if ($form->isSubmitted() && $form->isValid()) {
/* Puvodne jsem premyslel nad redirectem a ulozenim obrazku
do session, nicmene u takovehleho typu aplikace me to prijde naopak kontraproduktivni.
a zadani o tom mlci. Navic v pripade ze aplikace bude provozovana ve vice instancich
by se musela resit memcache, redis... */
$qrCodeImage = $qrCodeGenerator->generateQRCodeFromEntity($qrCode);
$form = $this->createForm(QRCodeType::class, $this->createQrCodeEntity());
}
}
return $this->render('index/homepage.html.twig', [
'form' => $form,
'qrCodeImage' => $qrCodeImage
]);
} }
}
private function createQrCodeEntity(): QRCode {
return new QRCode(
codeQROptions: $this->qrCodeQROptionsFactory->getDefault()
);
}
}

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
use Symfony\Component\Validator\Constraints as Assert;
/**
* QRCode description
*/
class QRCode
{
#[Assert\NotBlank]
#[Assert\Iban(
message: 'messages.not_a_iban_number',
)]
private ?string $iban;
#[Assert\NotBlank(message: 'messages.fill_value')]
#[Assert\GreaterThanOrEqual(
'today', message: 'messages.invalid_date'
)]
private ?\DateTime $dueDate;
#[Assert\NotBlank(message: 'messages.fill_value')]
private ?string $message;
#[Assert\Valid]
#[Assert\NotBlank]
private ?QRCodeMoney $money;
#[Assert\Valid]
#[Assert\NotBlank]
private ?QRCodePaymentIdentification $paymentIdentification;
#[Assert\Valid]
#[Assert\NotBlank]
private readonly QRCodeQROptions $codeQROptions;
/**
* @param QRCodeQROptions $codeQROptions
* @param string|null $iban
* @param \DateTime|null $dueDate
* @param string|null $message
* @param QRCodeMoney|null $money
*/
public function __construct(
QRCodeQROptions $codeQROptions,
?string $iban = null,
?\DateTime $dueDate = null,
?string $message = null,
?QRCodeMoney $money = null)
{
$this->iban = $iban;
$this->dueDate = $dueDate;
$this->message = $message;
$this->money = $money;
$this->codeQROptions = $codeQROptions;
}
public function getIban(): ?string
{
return $this->iban;
}
public function setIban(?string $iban): void
{
$this->iban = $iban;
}
public function getDueDate(): ?\DateTime
{
return $this->dueDate;
}
public function setDueDate(?\DateTime $dueDate): void
{
$this->dueDate = $dueDate;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(?string $message): void
{
$this->message = $message;
}
public function getMoney(): ?QRCodeMoney
{
return $this->money;
}
public function setMoney(?QRCodeMoney $money): void
{
$this->money = $money;
}
public function getCodeQROptions(): ?QRCodeQROptions
{
return $this->codeQROptions;
}
public function getPaymentIdentification(): ?QRCodePaymentIdentification
{
return $this->paymentIdentification;
}
public function setPaymentIdentification(?QRCodePaymentIdentification $paymentIdentification): void
{
$this->paymentIdentification = $paymentIdentification;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
use App\Validator\Currency;
use App\Validator\Money;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Money type for QR code
*/
class QRCodeMoney
{
#[Assert\Positive(
message: 'messages.not_valid_amount',
)]
#[Assert\NotBlank(message: 'messages.fill_value')]
#[Money(message: 'messages.not_valid_amount')]
private ?string $amount;
// TODO: getCurrencies validation
#[Assert\NotBlank(message: 'messages.fill_value')]
#[Currency(message: 'messages.not_currency')]
private ?string $currency;
/**
* @param string|null $amount
* @param string|null $currency
*/
public function __construct(?string $amount = null, ?string $currency = null)
{
$this->amount = $amount;
$this->currency = $currency;
}
public function getAmount(): ?string
{
return $this->amount;
}
public function setAmount(?string $amount): void
{
$this->amount = $amount;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function setCurrency(?string $currency): void
{
$this->currency = $currency;
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
use App\Validator\BankPaymentIdentificationNumber;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Payment identification for QR code
*/
class QRCodePaymentIdentification {
#[BankPaymentIdentificationNumber(
message: 'messages.not_variable_symbol',
)]
private ?string $variableSymbol;
#[BankPaymentIdentificationNumber(
message: 'messages.not_specific_symbol',
)]
private ?string $specificSymbol;
#[BankPaymentIdentificationNumber(
message: 'messages.not_constant_symbol',
)] // https://www.hyponamiru.cz/en/glossary/constant-symbol/
private ?string $constantSymbol;
public function getVariableSymbol(): ?string
{
return $this->variableSymbol;
}
public function setVariableSymbol(?string $variableSymbol): void
{
$this->variableSymbol = $variableSymbol;
}
public function getSpecificSymbol(): ?string
{
return $this->specificSymbol;
}
public function setSpecificSymbol(?string $specificSymbol): void
{
$this->specificSymbol = $specificSymbol;
}
public function getConstantSymbol(): ?string
{
return $this->constantSymbol;
}
public function setConstantSymbol(?string $constantSymbol): void
{
$this->constantSymbol = $constantSymbol;
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Entity\QRCode;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Options for generating QR code
*/
readonly class QRCodeQROptions {
#[Assert\Positive(
message: 'messages.scale_must_be_positive',
)]
#[Assert\NotBlank(message: 'messages.fill_value')]
private int $scale;
#[Assert\PositiveOrZero(
message: 'messages.margin_must_be_positive',
)]
#[Assert\NotBlank(message: 'messages.fill_value')]
private int $margin;
/**
* @param int $scale scaling of png image (2 = 1px is mapped onto 2px, 3 = 1px onto 3px etc.)
* @param int $margin white borders of png image in pixels
*/
public function __construct(int $scale, int $margin)
{
$this->scale = $scale;
$this->margin = $margin;
}
public function getScale(): int
{
return $this->scale;
}
public function getMargin(): int
{
return $this->margin;
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCodeMoney;
use App\Service\CurrencyListerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class QRCodeMoneyType extends AbstractType
{
private readonly CurrencyListerInterface $currencyLister;
public function __construct(CurrencyListerInterface $currencyLister) {
$this->currencyLister = $currencyLister;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('amount', NumberType::class)
->add('currency', ChoiceType::class, [
'choices' => array_combine(
iterator_to_array($this->currencyLister->getCurrencies()),
iterator_to_array($this->currencyLister->getCurrencies())
)
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => QRCodeMoney::class,
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCodePaymentIdentification;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class QRCodePaymentIdentificationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('variableSymbol', IntegerType::class, ['required' => false])
->add('specificSymbol', IntegerType::class, ['required' => false])
->add('constantSymbol', IntegerType::class, ['required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => QRCodePaymentIdentification::class,
]);
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\QRCode\QRCode;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class QRCodeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('iban', TextType::class)
->add('dueDate', DateType::class)
->add('message', TextType::class)
->add('money', QRCodeMoneyType::class)
->add('paymentIdentification', QRCodePaymentIdentificationType::class)
->add('generate_qr_code', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => QRCode::class,
]);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service;
interface CurrencyListerInterface
{
/**
* @return string[]
*/
public function getCurrencies(): iterable;
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCode;
interface QRCodeGeneratorInterface
{
/**
* Generates QR code from entity
* @param QRCode $entity
* @return string base64 encoded PNG
*/
public function generateQRCodeFromEntity(QRCode $entity): string;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCodeQROptions;
readonly final class QRCodeQROptionsDefaultProvider implements QRCodeQROptionsProviderInterface {
private QRCodeQROptions $qrCodeDefaultOptions;
public function __construct() {
$this->qrCodeDefaultOptions = new QRCodeQROptions(
1,
0
);
}
public function getDefault(): QRCodeQROptions {
return $this->qrCodeDefaultOptions;
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCodeQROptions;
interface QRCodeQROptionsProviderInterface {
public function getDefault(): QRCodeQROptions;
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Represents currency lister loaded from configuration
*/
readonly final class StaticCurrencyLister implements CurrencyListerInterface {
/**
* @param String[] $available_currencies
*/
public function __construct(private array $available_currencies) {
}
public function getCurrencies(): iterable
{
return $this->available_currencies;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\QRCode\QRCode;
readonly final class StubQRCodeGenerator implements QRCodeGeneratorInterface
{
public function __construct(private string $imagePath) {}
public function generateQRCodeFromEntity(QRCode $entity): string
{
$content = file_get_contents($this->imagePath);
return match ($content) {
false => throw new \InvalidArgumentException('Image path is invalid'),
default => base64_encode($content)
};
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class BankPaymentIdentificationNumber extends Constraint
{
public const NOT_CURRENCY_ERROR = '478a18e7-95ba-473d-9101-cabd45e49115';
protected const ERROR_NAMES = [
self::NOT_CURRENCY_ERROR => 'NOT_BANKID_NUMBER_ERROR',
];
public string $message = 'This value should be bank identification number.';
/**
* @param mixed|null $options
* @param string|null $message
* @param array<string>|null $groups
* @param mixed|null $payload
*/
public function __construct(mixed $options = null, string $message = null, array $groups = null, mixed $payload = null)
{
parent::__construct($options ?? [], $groups, $payload);
$this->message = $message ?? $this->message;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class BankPaymentIdentificationNumberValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof BankPaymentIdentificationNumber) {
throw new UnexpectedTypeException($constraint, Money::class);
}
if (null === $value || '' === $value) {
return ;
}
if (is_string($value) === false) {
throw new UnexpectedValueException($value, "string");
}
if (strlen($value) <= 10 && filter_var($value, FILTER_VALIDATE_INT) !== false) {
return ;
}
$this->context->buildViolation($constraint->message)
->setParameter('{{ string }}', $value)
->addViolation();
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Currency extends Constraint
{
public const NOT_CURRENCY_ERROR = '548618e7-95ba-473d-9101-cabd45e49115';
protected const ERROR_NAMES = [
self::NOT_CURRENCY_ERROR => 'NOT_CURRENCY_ERROR',
];
public string $message = 'This value should be currency.';
/**
* @param mixed|null $options
* @param string|null $message
* @param array<string>|null $groups
* @param mixed|null $payload
*/
public function __construct(mixed $options = null, string $message = null, array $groups = null, mixed $payload = null)
{
parent::__construct($options ?? [], $groups, $payload);
$this->message = $message ?? $this->message;
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use App\Service\CurrencyListerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class CurrencyValidator extends ConstraintValidator {
public function __construct(private readonly CurrencyListerInterface $currencyLister) {}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Currency) {
throw new UnexpectedTypeException($constraint, Currency::class);
}
if (null === $value || '' === $value) {
return ;
}
if (is_string($value) === false) {
throw new UnexpectedValueException($value, "string");
}
$availCurrencies = iterator_to_array($this->currencyLister->getCurrencies());
if (in_array($value, $availCurrencies, true)) {
return ;
}
$this->context->buildViolation($constraint->message)
->setParameter('{{ string }}', $value)
->addViolation();
}
}

31
src/Validator/Money.php Normal file
View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Money extends Constraint
{
public const NOT_CURRENCY_ERROR = '478618e7-95ba-473d-9101-cabd45e49115';
protected const ERROR_NAMES = [
self::NOT_CURRENCY_ERROR => 'NOT_CURRENCY_ERROR',
];
public string $message = 'This value should be currency.';
/**
* @param mixed|null $options
* @param string|null $message
* @param array<string>|null $groups
* @param mixed|null $payload
*/
public function __construct(mixed $options = null, string $message = null, array $groups = null, mixed $payload = null)
{
parent::__construct($options ?? [], $groups, $payload);
$this->message = $message ?? $this->message;
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class MoneyValidator extends ConstraintValidator {
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Money) {
throw new UnexpectedTypeException($constraint, Money::class);
}
if (null === $value || '' === $value) {
return ;
}
if (is_string($value) === false) {
throw new UnexpectedValueException($value, "string");
}
if (preg_match('/^\d+([\.,]\d{0,2})?$/', $value)) {
return ;
}
$this->context->buildViolation($constraint->message)
->setParameter('{{ string }}', $value)
->addViolation();
}
}

View File

@ -35,6 +35,35 @@
"phpstan.dist.neon" "phpstan.dist.neon"
] ]
}, },
"phpunit/phpunit": {
"version": "9.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/asset-mapper": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "568d44f71388f41f49dc382768fee20d52569359"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": { "symfony/console": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@ -78,6 +107,21 @@
"src/Kernel.php" "src/Kernel.php"
] ]
}, },
"symfony/phpunit-bridge": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "1f5830c331065b6e4c9d5fa2105e322d29fcd573"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/routing": { "symfony/routing": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@ -91,6 +135,19 @@
"config/routes.yaml" "config/routes.yaml"
] ]
}, },
"symfony/translation": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "64fe617084223633e1dedf9112935d8c95410d3e"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": { "symfony/twig-bundle": {
"version": "7.0", "version": "7.0",
"recipe": { "recipe": {
@ -103,5 +160,33 @@
"config/packages/twig.yaml", "config/packages/twig.yaml",
"templates/base.html.twig" "templates/base.html.twig"
] ]
},
"symfony/validator": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"twig/extra-bundle": {
"version": "v3.8.0"
} }
} }

View File

@ -2,15 +2,19 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}{{ "QR code generator" | trans }}{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
{% block stylesheets %} {% block stylesheets %}
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<div class="d-flex w-100 p-3 justify-content-center header">
{{ "QR code generator" | trans }}
</div>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,5 +1,104 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block body %} {% block body %}
Homepage <div class="container">
{% endblock %} {% if qrCodeImage %}
<div class="d-flex p-3 justify-content-center qr-code-image">
<img src="data:image/png;base64,{{ qrCodeImage }}" alt="QR code" />
</div>
<h2 class="header-another-code">{{ "You can generate another QR code here" | trans }}:</h2>
{% endif %}
{{ form_start(form) }}
<div class="mb-3 col-auto">
{{ form_label(form.iban) }}
{{ form_widget(form.iban) }}
<small>{{ form_help(form.iban) }}</small>
<div class="form-error">
{{ form_errors(form.iban) }}
</div>
</div>
<div class="mb-3 col-auto">
{{ form_label(form.dueDate) }}
{{ form_widget(form.dueDate) }}
<small>{{ form_help(form.dueDate) }}</small>
<div class="form-error">
{{ form_errors(form.dueDate) }}
</div>
</div>
<div class="mb-3 col-auto">
{{ form_label(form.message) }}
{{ form_widget(form.message) }}
<small>{{ form_help(form.message) }}</small>
<div class="form-error">
{{ form_errors(form.message) }}
</div>
</div>
{# money #}
<div class="mb-3 col-auto">
<div class="d-flex">
<div class="p-2">
{{ form_label(form.money.amount) }}
{{ form_widget(form.money.amount) }}
<small>{{ form_help(form.money.amount) }}</small>
<div class="form-error">
{{ form_errors(form.money.amount) }}
</div>
</div>
<div class="p-2">
{{ form_label(form.money.currency) }}
{{ form_widget(form.money.currency) }}
<small>{{ form_help(form.money.currency) }}</small>
<div class="form-error">
{{ form_errors(form.money.currency) }}
</div>
</div>
</div>
</div>
{# payment identification #}
<div class="mb-3 col-auto">
<div class="d-flex">
<div class="p-2">
{{ form_label(form.paymentIdentification.variableSymbol) }}
{{ form_widget(form.paymentIdentification.variableSymbol) }}
<small>{{ form_help(form.paymentIdentification.variableSymbol) }}</small>
<div class="form-error">
{{ form_errors(form.paymentIdentification.variableSymbol) }}
</div>
</div>
<div class="p-2">
{{ form_label(form.paymentIdentification.constantSymbol) }}
{{ form_widget(form.paymentIdentification.constantSymbol) }}
<small>{{ form_help(form.paymentIdentification.constantSymbol) }}</small>
<div class="form-error">
{{ form_errors(form.paymentIdentification.constantSymbol) }}
</div>
</div>
<div class="p-2">
{{ form_label(form.paymentIdentification.specificSymbol) }}
{{ form_widget(form.paymentIdentification.specificSymbol) }}
<small>{{ form_help(form.paymentIdentification.specificSymbol) }}</small>
<div class="form-error">
{{ form_errors(form.paymentIdentification.specificSymbol) }}
</div>
</div>
</div>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Service\QRCodeGeneratorInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class IndexControllerTest extends WebTestCase
{
public function testFormIsPresentAfterLoadHomepage(): void
{
$client = static::createClient();
$client->request('GET', '/');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('form');
}
public function testImageIsPresentAfterSubmitForm(): void {
$client = $this->generateClientForSubmitTest();
$response = $client->request('GET', '/');
$this->assertResponseIsSuccessful();
$form = $response->selectButton('Vygenerovat QR kód')->form();
$dt = new \DateTime('now+1 hour'); // In case of 23:59:59
$client->submit($form, [
'qr_code' => [
'iban' => 'CZ5508000000001234567899',
'dueDate' => $dt->format('Y-m-d'),
'message' => 'foo',
'money' => [
'currency' => 'CZK',
'amount' => '100'
],
'paymentIdentification' => [
'variableSymbol' => '123',
'constantSymbol' => '123',
'specificSymbol' => '123'
]
]
]);
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('form');
$this->assertSelectorExists('img');
}
public function testImageIsNotPresentAfterFailSubmitForm(): void {
$client = $this->generateClientForSubmitTest();
$response = $client->request('GET', '/');
$this->assertResponseIsSuccessful();
$form = $response->selectButton('Vygenerovat QR kód')->form();
$dt = new \DateTime('now+1 hour'); // In case of 23:59:59
$client->submit($form, [
'qr_code' => [
'iban' => 'IBAN_THAT_NOT_EXISTS',
'dueDate' => $dt->format('Y-m-d'),
'message' => 'foo',
'money' => [
'currency' => 'CZK',
'amount' => '100'
],
'paymentIdentification' => [
'variableSymbol' => '123',
'constantSymbol' => '123',
'specificSymbol' => '123'
]
]
]);
$this->assertResponseIsUnprocessable();
$this->assertSelectorExists('form');
$this->assertSelectorNotExists('img');
}
private function generateClientForSubmitTest(): KernelBrowser
{
$mock = $this->createMock(QRCodeGeneratorInterface::class);
$mock->expects($this->any())
->method('generateQRCodeFromEntity')
->will($this->returnValue('foo'));
$client = static::createClient();
$client->disableReboot();
self::getContainer()->set('App\Service\QRCodeGeneratorInterface', $mock);
return $client;
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator;
use App\Validator\BankPaymentIdentificationNumber;
use App\Validator\BankPaymentIdentificationNumberValidator;
class BankPaymentIdentificationNumberValidatorTest extends ValidatorTestCase
{
/**
* @dataProvider successDp
* @param string $generatedValue
* @return void
*/
public function testSuccess(string $generatedValue): void {
$bankPaymentIdentificationValidator = new BankPaymentIdentificationNumberValidator();
$bankPaymentIdentificationValidator->initialize($this->createExecutionContext(false));
$bankPaymentIdentificationValidator->validate($generatedValue, new BankPaymentIdentificationNumber(['message' => 'foo']));
}
private function successDp(): array {
return [['122'], ['1234567890'], ['1']];
}
/**
* @dataProvider failureDp
* @param string $generatedValue
* @return void
*/
public function testFailure(string $generatedValue): void {
$bankPaymentIdentificationValidator = new BankPaymentIdentificationNumberValidator();
$bankPaymentIdentificationValidator->initialize($this->createExecutionContext(true));
$bankPaymentIdentificationValidator->validate($generatedValue, new BankPaymentIdentificationNumber(['message' => 'foo']));
}
private function failureDp(): array {
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a'], ['122.1'], ['12345678901']];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator;
use App\Service\CurrencyListerInterface;
use App\Validator\Currency;
use App\Validator\CurrencyValidator;
class CurrencyValidatorTest extends ValidatorTestCase
{
public function testSuccess(): void {
$currencyValidator = $this->createCurrencyValidator();
$currencyValidator->initialize($this->createExecutionContext(false));
$currencyValidator->validate('USD', new Currency(['message' => 'foo']));
}
public function testFailure(): void {
$currencyValidator = $this->createCurrencyValidator();
$currencyValidator->initialize($this->createExecutionContext(true));
$currencyValidator->validate('EUR', new Currency(['message' => 'foo']));
}
private function createCurrencyValidator(): CurrencyValidator {
$mock = $this->createMock(CurrencyListerInterface::class);
$mock->expects($this->once())
->method('getCurrencies')
->will($this->returnValue(['USD', 'CZK']));
return new CurrencyValidator($mock);
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator;
use App\Validator\Money;
use App\Validator\MoneyValidator;
class MoneyValidatorTest extends ValidatorTestCase
{
/**
* @dataProvider successDp
* @param string $generatedValue
* @return void
*/
public function testSuccess(string $generatedValue): void {
$moneyValidator = new MoneyValidator();
$moneyValidator->initialize($this->createExecutionContext(false));
$moneyValidator->validate($generatedValue, new Money(['message' => 'foo']));
}
private function successDp(): array {
return [['122'], ['122.0'], ['122,00'], ['122'], ['2.05'], ['2,04']];
}
/**
* @dataProvider failureDp
* @param string $generatedValue
* @return void
*/
public function testFailure(string $generatedValue): void {
$moneyValidator = new MoneyValidator();
$moneyValidator->initialize($this->createExecutionContext(true));
$moneyValidator->validate($generatedValue, new Money(['message' => 'foo']));
}
private function failureDp(): array {
return [['122.b'], ['a.a'], ['a'], ['2.040'], ['2,a']];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Tests\Validator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
abstract class ValidatorTestCase extends TestCase
{
protected function createExecutionContext(bool $violation): ExecutionContextInterface {
$expected = $violation ? $this->once() : $this->never();
$context = $this->createMock(ExecutionContextInterface::class);
$context->expects($expected)
->method('buildViolation');
return $context;
}
}

15
tests/bootstrap.php Normal file
View File

@ -0,0 +1,15 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}

0
translations/.gitignore vendored Normal file
View File

View File

@ -0,0 +1,11 @@
Due date: "Datum splatnosti"
Message: "Zpráva pro přijemce"
Amount: "Částka"
Currency: "Měna"
Variable symbol: "Variabilní symbol"
Constant symbol: "Konstantní symbol"
Specific symbol: "Specifický symbol"
Generate qr code: "Vygenerovat QR kód"
Iban: "IBAN"
QR code generator: "Generátor bankovních QR kódů"
You can generate another QR code here: "Další QR kód si můžete vygenerovat zde"

View File

@ -0,0 +1,10 @@
messages:
not_variable_symbol: "Neplatný variabilní symbol. Variabilní symbol musí být max. 10 číslic"
not_specific_symbol: "Neplatný specifický symbol. Specifický symbol musí být max. 10 číslic"
not_constant_symbol: "Neplatný konstantní symbol. Konstatní symbol musí být max. 10 číslic"
not_a_iban_number: "Neplatný formát IBAN"
not_valid_amount: "Neplatná částka"
invalid_date: "Neplatné datum. Datum musí být v budoucnosti nebo dnes"
fill_value: "Prosím vyplňte hodnotu"
scale_must_be_positive: "Číslo musí být > 0."
margin_must_be_positive: "Margin musí být > 0."