Add Symfony payload mapping, fixtures, and QA tooling
This commit is contained in:
+11
@@ -24,3 +24,14 @@ APP_SECRET=
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
DEFAULT_URI=http://localhost
|
||||
###< symfony/routing ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/points.sqlite"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> nelmio/cors-bundle ###
|
||||
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||
###< nelmio/cors-bundle ###
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
CORS_ALLOW_ORIGIN='^https?://(?:localhost|127\\.0\\.0\\.1)(?::[0-9]+)?$'
|
||||
@@ -8,3 +8,12 @@
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
|
||||
Executable
+23
@@ -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';
|
||||
}
|
||||
+17
-2
@@ -7,15 +7,22 @@
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.17",
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.1",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.5",
|
||||
"nelmio/cors-bundle": "^2.5",
|
||||
"symfony/console": "7.3.*",
|
||||
"symfony/dotenv": "7.3.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "7.3.*",
|
||||
"symfony/property-access": "7.3.*",
|
||||
"symfony/property-info": "7.3.*",
|
||||
"symfony/runtime": "7.3.*",
|
||||
"symfony/serializer": "^7.3",
|
||||
"symfony/yaml": "7.3.*"
|
||||
},
|
||||
"require-dev": {
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
@@ -65,5 +72,13 @@
|
||||
"allow-contrib": false,
|
||||
"require": "7.3.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/phpunit": "^12.4",
|
||||
"rector/rector": "^2.2",
|
||||
"symfony/browser-kit": "7.3.*",
|
||||
"symfony/css-selector": "7.3.*",
|
||||
"symplify/easy-coding-standard": "^12.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4164
-4
File diff suppressed because it is too large
Load Diff
@@ -2,4 +2,8 @@
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
identity_generation_preferences:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
@@ -0,0 +1,6 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
@@ -0,0 +1,10 @@
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
origin_regex: true
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization']
|
||||
expose_headers: ['Link']
|
||||
max_age: 3600
|
||||
paths:
|
||||
'^/': null
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -0,0 +1,7 @@
|
||||
imports:
|
||||
- { resource: ../doctrine.yaml }
|
||||
|
||||
doctrine:
|
||||
dbal:
|
||||
url: 'sqlite:///%kernel.project_dir%/var/test.sqlite'
|
||||
use_savepoints: true
|
||||
@@ -15,6 +15,11 @@ services:
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/Kernel.php'
|
||||
|
||||
App\Controller\:
|
||||
resource: '../src/Controller/'
|
||||
tags: ['controller.service_arguments']
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PhpCsFixer\Fixer\FunctionNotation\MethodArgumentSpaceFixer;
|
||||
use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer;
|
||||
use PhpCsFixer\Fixer\Operator\ConcatSpaceFixer;
|
||||
use Symplify\EasyCodingStandard\Config\ECSConfig;
|
||||
|
||||
return ECSConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->withRules([
|
||||
NoUnusedImportsFixer::class,
|
||||
])
|
||||
->withConfiguredRule(MethodArgumentSpaceFixer::class, [
|
||||
'on_multiline' => 'ensure_fully_multiline',
|
||||
'attribute_placement' => 'same_line'
|
||||
])
|
||||
->withSkip([
|
||||
ConcatSpaceFixer::class
|
||||
])
|
||||
->withPreparedSets(
|
||||
psr12: true,
|
||||
common: true,
|
||||
cleanCode: true,
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20240919000000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create signals table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE signals (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user_key VARCHAR(64) NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, created_at DATETIME NOT NULL)');
|
||||
$this->addSql('CREATE INDEX idx_signals_created_at ON signals (created_at)');
|
||||
$this->addSql('CREATE INDEX idx_signals_user_key ON signals (user_key)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE signals');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpstan/phpstan-symfony/rules.neon
|
||||
# - vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
# - vendor/phpstan/phpstan-doctrine/rules.neon
|
||||
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- bin/
|
||||
- config/
|
||||
- public/
|
||||
- src/
|
||||
- tests/
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
ignoreErrors:
|
||||
- identifier: missingType.iterableValue
|
||||
# doctrine:
|
||||
# objectManagerLoader: tests/object-manager.php
|
||||
# allowNullablePropertyForRequiredField: true
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<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" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Exception\Configuration\InvalidConfigurationException;
|
||||
use Rector\ValueObject\PhpVersion;
|
||||
|
||||
try {
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->withImportNames(
|
||||
importDocBlockNames: false,
|
||||
importShortClasses: false,
|
||||
removeUnusedImports: true
|
||||
)
|
||||
->withPhpVersion(PhpVersion::PHP_84)
|
||||
->withPhpSets(php84: true)
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
codingStyle: true,
|
||||
typeDeclarations: true,
|
||||
privatization: true,
|
||||
instanceOf: true,
|
||||
earlyReturn: true,
|
||||
doctrineCodeQuality: true
|
||||
)
|
||||
->withSkip([
|
||||
CatchExceptionNameMatchingTypeRector::class
|
||||
]);
|
||||
} catch (InvalidConfigurationException $e) {
|
||||
echo $e->getMessage();
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Dto\SignalPayload;
|
||||
use App\Entity\Signal;
|
||||
use App\Repository\SignalRepository;
|
||||
use App\Service\SignalSnapshotBuilder;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
#[Route(path: '/api/signals')]
|
||||
class SignalController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SignalRepository $signals,
|
||||
private readonly SignalSnapshotBuilder $snapshotBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '', name: 'api_signals_index', methods: ['GET'])]
|
||||
public function index(Request $request, #[MapQueryParameter('limit', flags: FILTER_NULL_ON_FAILURE)] ?int $limit = null): JsonResponse
|
||||
{
|
||||
$limit = $this->normalizeLimit($limit);
|
||||
|
||||
$clientKey = $this->hashIp($this->extractClientIp($request));
|
||||
|
||||
$signals = $this->signals->findRecent($limit);
|
||||
$snapshot = $this->snapshotBuilder->build($signals);
|
||||
|
||||
$payload = [
|
||||
'clientKey' => $clientKey,
|
||||
'points' => $snapshot['points'],
|
||||
'density' => $snapshot['density'],
|
||||
'latestByUser' => $snapshot['latestByUser'],
|
||||
'totals' => $snapshot['totals'],
|
||||
'updatedAt' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format(DATE_ATOM),
|
||||
];
|
||||
|
||||
return new JsonResponse($payload);
|
||||
}
|
||||
|
||||
#[Route(path: '', name: 'api_signals_store', methods: ['POST'])]
|
||||
public function store(#[MapRequestPayload(validationFailedStatusCode: JsonResponse::HTTP_UNPROCESSABLE_ENTITY)] SignalPayload $payload, Request $request): JsonResponse
|
||||
{
|
||||
$lat = $payload->lat;
|
||||
$lng = $payload->lng;
|
||||
|
||||
if (! is_finite($lat) || ! is_finite($lng)) {
|
||||
return $this->errorResponse('invalid_coordinates', 'Latitude and longitude must be numbers.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
|
||||
return $this->errorResponse('out_of_bounds', 'Latitude or longitude out of range.', JsonResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$clientIp = $this->extractClientIp($request);
|
||||
$clientKey = $this->hashIp($clientIp);
|
||||
|
||||
$signal = new Signal()
|
||||
->setUserKey($clientKey)
|
||||
->setLat($lat)
|
||||
->setLng($lng)
|
||||
->setCreatedAt(new DateTimeImmutable('now', new DateTimeZone('UTC')));
|
||||
|
||||
$this->signals->save($signal);
|
||||
|
||||
return new JsonResponse([
|
||||
'status' => 'stored',
|
||||
'clientKey' => $clientKey,
|
||||
'point' => [
|
||||
'id' => $signal->getId(),
|
||||
'lat' => $signal->getLat(),
|
||||
'lng' => $signal->getLng(),
|
||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||
'userKey' => $signal->getUserKey(),
|
||||
],
|
||||
], JsonResponse::HTTP_CREATED);
|
||||
}
|
||||
|
||||
private function errorResponse(string $error, string $message, int $statusCode): JsonResponse
|
||||
{
|
||||
return new JsonResponse([
|
||||
'error' => $error,
|
||||
'message' => $message,
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
private function normalizeLimit(?int $limit): int
|
||||
{
|
||||
$limit ??= 750;
|
||||
if ($limit < 1 || $limit > 5000) {
|
||||
return 750;
|
||||
}
|
||||
|
||||
return $limit;
|
||||
}
|
||||
|
||||
private function extractClientIp(Request $request): string
|
||||
{
|
||||
$forwarded = $request->headers->get('X-Forwarded-For');
|
||||
if ($forwarded !== null && $forwarded !== '') {
|
||||
$parts = array_filter(array_map('trim', explode(',', $forwarded)));
|
||||
if ($parts !== []) {
|
||||
return $parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $request->getClientIp() ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return substr(hash('sha256', $ip), 0, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// $product = new Product();
|
||||
// $manager->persist($product);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Signal;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class SignalFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$baseTime = new DateTimeImmutable('2024-08-01 12:00:00', new DateTimeZone('UTC'));
|
||||
$coordinates = [
|
||||
[
|
||||
'user' => 'user-alpha',
|
||||
'lat' => -11.6877,
|
||||
'lng' => 27.5026,
|
||||
'offset' => 0,
|
||||
],
|
||||
[
|
||||
'user' => 'user-beta',
|
||||
'lat' => -11.6895,
|
||||
'lng' => 27.5081,
|
||||
'offset' => 3,
|
||||
],
|
||||
[
|
||||
'user' => 'user-alpha',
|
||||
'lat' => -11.6852,
|
||||
'lng' => 27.4974,
|
||||
'offset' => 6,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($coordinates as $config) {
|
||||
$signal = new Signal()
|
||||
->setUserKey($config['user'])
|
||||
->setLat($config['lat'])
|
||||
->setLng($config['lng'])
|
||||
->setCreatedAt($baseTime->add(new DateInterval(sprintf('PT%dM', $config['offset']))));
|
||||
|
||||
$manager->persist($signal);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
final readonly class SignalPayload
|
||||
{
|
||||
public function __construct(
|
||||
public float $lat,
|
||||
public float $lng,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\SignalRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SignalRepository::class)]
|
||||
#[ORM\Table(name: 'signals')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_signals_created_at')]
|
||||
#[ORM\Index(columns: ['user_key'], name: 'idx_signals_user_key')]
|
||||
class Signal
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
private ?int $id = null; // @phpstan-ignore property.unusedType
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 64)]
|
||||
private string $userKey;
|
||||
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
private float $lat;
|
||||
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
private float $lng;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUserKey(): string
|
||||
{
|
||||
return $this->userKey;
|
||||
}
|
||||
|
||||
public function setUserKey(string $userKey): self
|
||||
{
|
||||
$this->userKey = $userKey;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLat(): float
|
||||
{
|
||||
return $this->lat;
|
||||
}
|
||||
|
||||
public function setLat(float $lat): self
|
||||
{
|
||||
$this->lat = $lat;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLng(): float
|
||||
{
|
||||
return $this->lng;
|
||||
}
|
||||
|
||||
public function setLng(float $lng): self
|
||||
{
|
||||
$this->lng = $lng;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Signal;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Signal>
|
||||
*/
|
||||
class SignalRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Signal::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Signal>
|
||||
*/
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
return $this->createQueryBuilder('signal')
|
||||
->orderBy('signal.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(Signal $signal): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($signal);
|
||||
$entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Signal;
|
||||
|
||||
class SignalSnapshotBuilder
|
||||
{
|
||||
/**
|
||||
* @param list<Signal> $signals
|
||||
*
|
||||
* @return array{
|
||||
* points: list<array{id: int, lat: float, lng: float, createdAt: string, userKey: string}>,
|
||||
* density: list<array{lat: float, lng: float, intensity: int}>,
|
||||
* latestByUser: list<array{id: int, lat: float, lng: float, createdAt: string, userKey: string}>,
|
||||
* totals: array{points: int, contributors: int}
|
||||
* }
|
||||
*/
|
||||
public function build(array $signals): array
|
||||
{
|
||||
$points = [];
|
||||
$densityBuckets = [];
|
||||
$latestByUser = [];
|
||||
|
||||
foreach ($signals as $signal) {
|
||||
$point = [
|
||||
'id' => (int) $signal->getId(),
|
||||
'lat' => $signal->getLat(),
|
||||
'lng' => $signal->getLng(),
|
||||
'createdAt' => $signal->getCreatedAt()->format(DATE_ATOM),
|
||||
'userKey' => $signal->getUserKey(),
|
||||
];
|
||||
|
||||
$points[] = $point;
|
||||
|
||||
$bucketLat = round($signal->getLat(), 3);
|
||||
$bucketLng = round($signal->getLng(), 3);
|
||||
$bucketKey = $bucketLat . ':' . $bucketLng;
|
||||
|
||||
if (! isset($densityBuckets[$bucketKey])) {
|
||||
$densityBuckets[$bucketKey] = [
|
||||
'lat' => $bucketLat,
|
||||
'lng' => $bucketLng,
|
||||
'intensity' => 0,
|
||||
'latestPoint' => $point,
|
||||
];
|
||||
}
|
||||
|
||||
$densityBuckets[$bucketKey]['intensity']++;
|
||||
if ($point['createdAt'] > $densityBuckets[$bucketKey]['latestPoint']['createdAt']) {
|
||||
$densityBuckets[$bucketKey]['latestPoint'] = $point;
|
||||
}
|
||||
|
||||
$existingLatest = $latestByUser[$point['userKey']] ?? null;
|
||||
if ($existingLatest === null || $point['createdAt'] > $existingLatest['createdAt']) {
|
||||
$latestByUser[$point['userKey']] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
usort($points, static fn (array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
|
||||
|
||||
$density = array_values(array_map(
|
||||
static fn (array $bucket): array => [
|
||||
'lat' => $bucket['lat'],
|
||||
'lng' => $bucket['lng'],
|
||||
'intensity' => $bucket['intensity'],
|
||||
],
|
||||
$densityBuckets,
|
||||
));
|
||||
|
||||
usort($density, static fn (array $a, array $b): int => $b['intensity'] <=> $a['intensity']);
|
||||
|
||||
$latest = array_values($latestByUser);
|
||||
usort($latest, static fn (array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
|
||||
|
||||
return [
|
||||
'points' => $points,
|
||||
'density' => $density,
|
||||
'latestByUser' => $latest,
|
||||
'totals' => [
|
||||
'points' => count($points),
|
||||
'contributors' => count($latestByUser),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,91 @@
|
||||
{
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "2.17",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.13",
|
||||
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-fixtures-bundle": {
|
||||
"version": "4.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||
},
|
||||
"files": [
|
||||
"src/DataFixtures/AppFixtures.php"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "3.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"nelmio/cors-bundle": {
|
||||
"version": "2.5",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.5",
|
||||
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"phpstan/phpstan": {
|
||||
"version": "2.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
|
||||
},
|
||||
"files": [
|
||||
"phpstan.dist.neon"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "12.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
@@ -44,6 +131,18 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\DataFixtures\SignalFixtures;
|
||||
use App\Entity\Signal;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SignalControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
$entityManager = $this->client->getContainer()->get(EntityManagerInterface::class);
|
||||
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
||||
$this->entityManager = $entityManager;
|
||||
|
||||
$metadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
|
||||
$schemaTool = new SchemaTool($this->entityManager);
|
||||
$schemaTool->dropDatabase();
|
||||
if ($metadata !== []) {
|
||||
$schemaTool->createSchema($metadata);
|
||||
}
|
||||
|
||||
new SignalFixtures()->load($this->entityManager);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
if (isset($this->entityManager)) {
|
||||
$this->entityManager->close();
|
||||
unset($this->entityManager);
|
||||
}
|
||||
}
|
||||
|
||||
public function testIndexReturnsRecentSignals(): void
|
||||
{
|
||||
$this->client->request('GET', '/api/signals');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$content = $this->client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertSame(substr(hash('sha256', '127.0.0.1'), 0, 12), $payload['clientKey']);
|
||||
self::assertCount(3, $payload['points']);
|
||||
self::assertSame(3, $payload['totals']['points']);
|
||||
self::assertSame(2, $payload['totals']['contributors']);
|
||||
self::assertSame(-11.6852, $payload['points'][0]['lat']);
|
||||
}
|
||||
|
||||
public function testIndexRespectsLimitQueryParameter(): void
|
||||
{
|
||||
$this->client->request('GET', '/api/signals?limit=1');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$content = $this->client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertCount(1, $payload['points']);
|
||||
self::assertSame(1, $payload['totals']['points']);
|
||||
}
|
||||
|
||||
public function testStorePersistsNewSignal(): void
|
||||
{
|
||||
$body = [
|
||||
'lat' => -11.6901,
|
||||
'lng' => 27.4959,
|
||||
];
|
||||
|
||||
$this->client->request('POST', '/api/signals', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode($body, JSON_THROW_ON_ERROR));
|
||||
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
|
||||
|
||||
$content = $this->client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('stored', $payload['status']);
|
||||
self::assertSame($body['lat'], $payload['point']['lat']);
|
||||
self::assertSame($body['lng'], $payload['point']['lng']);
|
||||
|
||||
$repository = $this->entityManager->getRepository(Signal::class);
|
||||
$signals = $repository->findAll();
|
||||
self::assertCount(4, $signals);
|
||||
}
|
||||
|
||||
public function testStoreRejectsInvalidCoordinates(): void
|
||||
{
|
||||
$body = [
|
||||
'lat' => 181,
|
||||
'lng' => 10,
|
||||
];
|
||||
|
||||
$this->client->request('POST', '/api/signals', [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode($body, JSON_THROW_ON_ERROR));
|
||||
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
$content = $this->client->getResponse()->getContent();
|
||||
self::assertIsString($content);
|
||||
$payload = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertSame('out_of_bounds', $payload['error']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
|
||||
|
||||
if ($_SERVER['APP_DEBUG']) {
|
||||
umask(0000);
|
||||
}
|
||||
Reference in New Issue
Block a user