Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6ce7606f | ||
|
|
7ec21c1cac | ||
|
|
f951242465 | ||
|
|
7dadb6806a | ||
|
|
6b539f43f3 | ||
|
|
1845441bc2 | ||
|
|
33c31d088c |
No files matched your search
Generated
+164
-164
@@ -4938,16 +4938,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache.git",
|
||||
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c"
|
||||
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
|
||||
"reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/e7e604eab58639503495a46f5c194cc69a9c80a8",
|
||||
"reference": "e7e604eab58639503495a46f5c194cc69a9c80a8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5017,7 +5017,7 @@
|
||||
"psr6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/cache/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5037,7 +5037,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-08T13:28:43+00:00"
|
||||
"time": "2026-09-29T12:35:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache-contracts",
|
||||
@@ -5278,16 +5278,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "3a1973458b153f566a8d7499bc06ece323d60063"
|
||||
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063",
|
||||
"reference": "3a1973458b153f566a8d7499bc06ece323d60063",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
|
||||
"reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5352,7 +5352,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5372,20 +5372,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-13T10:38:41+00:00"
|
||||
"time": "2026-09-18T15:55:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dependency-injection",
|
||||
"version": "v7.4.17",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/dependency-injection.git",
|
||||
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0"
|
||||
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
|
||||
"reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0",
|
||||
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a7faee783a87b55f27460fc571f8c639bf9d93a2",
|
||||
"reference": "a7faee783a87b55f27460fc571f8c639bf9d93a2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5436,7 +5436,7 @@
|
||||
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.17"
|
||||
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5456,7 +5456,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-21T17:40:08+00:00"
|
||||
"time": "2026-09-29T12:35:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
@@ -5531,16 +5531,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/doctrine-bridge",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/doctrine-bridge.git",
|
||||
"reference": "d89437a59232a7e9b294652faea2469f2af66b63"
|
||||
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/d89437a59232a7e9b294652faea2469f2af66b63",
|
||||
"reference": "d89437a59232a7e9b294652faea2469f2af66b63",
|
||||
"url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/03d285cfe0f633a856e7acb68cecf5964f8494a7",
|
||||
"reference": "03d285cfe0f633a856e7acb68cecf5964f8494a7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5620,7 +5620,7 @@
|
||||
"description": "Provides integration for Doctrine with various Symfony components",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5640,20 +5640,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-11T14:46:58+00:00"
|
||||
"time": "2026-09-28T19:09:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/doctrine-messenger",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/doctrine-messenger.git",
|
||||
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1"
|
||||
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/edee21e6307105f0aca6b82a1dc201e6789c5ed1",
|
||||
"reference": "edee21e6307105f0aca6b82a1dc201e6789c5ed1",
|
||||
"url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/a34e55dce43e1229bb47555b57ba3c95910988c7",
|
||||
"reference": "a34e55dce43e1229bb47555b57ba3c95910988c7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5666,7 +5666,7 @@
|
||||
"doctrine/persistence": "<1.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/persistence": "^1.3|^2|^3",
|
||||
"doctrine/persistence": "^1.3|^2|^3|^4",
|
||||
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||
"symfony/serializer": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
@@ -5696,7 +5696,7 @@
|
||||
"description": "Symfony Doctrine Messenger Bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5716,7 +5716,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-13T08:27:19+00:00"
|
||||
"time": "2026-09-24T08:15:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/dom-crawler",
|
||||
@@ -5870,16 +5870,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/error-handler",
|
||||
"version": "v7.4.17",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/error-handler.git",
|
||||
"reference": "8373921e231e190a88e2ad526951bbaa791576fa"
|
||||
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa",
|
||||
"reference": "8373921e231e190a88e2ad526951bbaa791576fa",
|
||||
"url": "https://api.github.com/repos/symfony/error-handler/zipball/82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
|
||||
"reference": "82239fe00f7cdc9f17c4166fff4ebdc22bd26ec0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5928,7 +5928,7 @@
|
||||
"description": "Provides tools to manage errors and ease debugging PHP code",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/error-handler/tree/v7.4.17"
|
||||
"source": "https://github.com/symfony/error-handler/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5948,7 +5948,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-21T17:40:08+00:00"
|
||||
"time": "2026-09-22T18:56:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/event-dispatcher",
|
||||
@@ -6255,16 +6255,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/finder.git",
|
||||
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe"
|
||||
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe",
|
||||
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe",
|
||||
"url": "https://api.github.com/repos/symfony/finder/zipball/2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
|
||||
"reference": "2dd31e5208db7a0fda7bfc998ad727b79c2e4d1e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6299,7 +6299,7 @@
|
||||
"description": "Finds files and directories via an intuitive fluent interface",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/finder/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/finder/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6319,7 +6319,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-10T19:14:10+00:00"
|
||||
"time": "2026-09-26T06:31:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/flex",
|
||||
@@ -6396,16 +6396,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/form",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/form.git",
|
||||
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3"
|
||||
"reference": "a36c175f54bbe0320e096a857d67780053a28f44"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/form/zipball/ededc326d2e5f271019b37d54c978e67cbc53ae3",
|
||||
"reference": "ededc326d2e5f271019b37d54c978e67cbc53ae3",
|
||||
"url": "https://api.github.com/repos/symfony/form/zipball/a36c175f54bbe0320e096a857d67780053a28f44",
|
||||
"reference": "a36c175f54bbe0320e096a857d67780053a28f44",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6475,7 +6475,7 @@
|
||||
"description": "Allows to easily create, process and reuse HTML forms",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/form/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/form/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6495,20 +6495,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-15T06:01:15+00:00"
|
||||
"time": "2026-09-29T12:35:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/framework-bundle",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/framework-bundle.git",
|
||||
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732"
|
||||
"reference": "99786f9338c24770ce4de6a95d049675aa75b129"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/666aece282b9c6f4664c5d81149b17ac5a0f4732",
|
||||
"reference": "666aece282b9c6f4664c5d81149b17ac5a0f4732",
|
||||
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/99786f9338c24770ce4de6a95d049675aa75b129",
|
||||
"reference": "99786f9338c24770ce4de6a95d049675aa75b129",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6562,7 +6562,6 @@
|
||||
"symfony/workflow": "<7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/persistence": "^1.3|^2|^3",
|
||||
"dragonmantank/cron-expression": "^3.1",
|
||||
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||
"seld/jsonlint": "^1.10",
|
||||
@@ -6633,7 +6632,7 @@
|
||||
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6653,20 +6652,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-14T09:18:42+00:00"
|
||||
"time": "2026-09-28T19:09:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/html-sanitizer",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/html-sanitizer.git",
|
||||
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7"
|
||||
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/d49c351e24d38604466e5a51ab20cbc99f9493c7",
|
||||
"reference": "d49c351e24d38604466e5a51ab20cbc99f9493c7",
|
||||
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
|
||||
"reference": "0b7605debddc90c7c9d4e9c1b6eb520a248f26dc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6707,7 +6706,7 @@
|
||||
"sanitizer"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/html-sanitizer/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6727,20 +6726,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-04T10:11:42+00:00"
|
||||
"time": "2026-09-28T05:21:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-client.git",
|
||||
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7"
|
||||
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
|
||||
"reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
|
||||
"url": "https://api.github.com/repos/symfony/http-client/zipball/750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
|
||||
"reference": "750a420b8dd0af3d6e1c6c1412802abf2cdd2b6c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6808,7 +6807,7 @@
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/http-client/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6828,7 +6827,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-08T13:28:43+00:00"
|
||||
"time": "2026-09-24T11:02:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-client-contracts",
|
||||
@@ -6914,16 +6913,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-foundation",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-foundation.git",
|
||||
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936"
|
||||
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936",
|
||||
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936",
|
||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9d0761c0da6bd5b801b401666834901a9a1ffc33",
|
||||
"reference": "9d0761c0da6bd5b801b401666834901a9a1ffc33",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6972,7 +6971,7 @@
|
||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6992,20 +6991,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-14T17:41:17+00:00"
|
||||
"time": "2026-09-24T07:12:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/http-kernel",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/http-kernel.git",
|
||||
"reference": "683507424cc84ca85ac1551adb609b6b98216667"
|
||||
"reference": "392784b5d7b959b8d872542a2530c50c540a788f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667",
|
||||
"reference": "683507424cc84ca85ac1551adb609b6b98216667",
|
||||
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/392784b5d7b959b8d872542a2530c50c540a788f",
|
||||
"reference": "392784b5d7b959b8d872542a2530c50c540a788f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7091,7 +7090,7 @@
|
||||
"description": "Provides a structured process for converting a Request into a Response",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/http-kernel/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7111,7 +7110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-15T07:09:35+00:00"
|
||||
"time": "2026-09-29T14:05:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/intl",
|
||||
@@ -7205,16 +7204,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/lock",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/lock.git",
|
||||
"reference": "4073e583b1cb048799706d7ba3245328858cfea6"
|
||||
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/lock/zipball/4073e583b1cb048799706d7ba3245328858cfea6",
|
||||
"reference": "4073e583b1cb048799706d7ba3245328858cfea6",
|
||||
"url": "https://api.github.com/repos/symfony/lock/zipball/80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
|
||||
"reference": "80a1a0f5ca166ddec7c68bc5f1b39cea19f333f0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7264,7 +7263,7 @@
|
||||
"semaphore"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/lock/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/lock/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7284,7 +7283,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-30T20:10:52+00:00"
|
||||
"time": "2026-09-18T15:55:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mailer",
|
||||
@@ -7372,16 +7371,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/messenger",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/messenger.git",
|
||||
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51"
|
||||
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/4070504211a16c4237247f7b2a91143c3a1f4e51",
|
||||
"reference": "4070504211a16c4237247f7b2a91143c3a1f4e51",
|
||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/4cf94f12c9e899a87e6b6bf800dffcb709585410",
|
||||
"reference": "4cf94f12c9e899a87e6b6bf800dffcb709585410",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -7442,7 +7441,7 @@
|
||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/messenger/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/messenger/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -7462,7 +7461,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-14T15:23:30+00:00"
|
||||
"time": "2026-09-29T06:38:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mime",
|
||||
@@ -8926,16 +8925,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/property-info",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/property-info.git",
|
||||
"reference": "547d2acbe865d511809b96ab653304fd632abbaa"
|
||||
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/property-info/zipball/547d2acbe865d511809b96ab653304fd632abbaa",
|
||||
"reference": "547d2acbe865d511809b96ab653304fd632abbaa",
|
||||
"url": "https://api.github.com/repos/symfony/property-info/zipball/d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
|
||||
"reference": "d24c4c810b1285c5d8d77c6fd7b25e714479bd48",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -8992,7 +8991,7 @@
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/property-info/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/property-info/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9012,7 +9011,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-04T10:11:42+00:00"
|
||||
"time": "2026-09-28T09:47:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
@@ -9104,16 +9103,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/routing.git",
|
||||
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3"
|
||||
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
|
||||
"reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3",
|
||||
"url": "https://api.github.com/repos/symfony/routing/zipball/2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
|
||||
"reference": "2bacb72be5eadeaf9874d5accb75bfb33d21c74b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9165,7 +9164,7 @@
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/routing/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9185,7 +9184,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-17T13:12:36+00:00"
|
||||
"time": "2026-09-28T19:09:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/runtime",
|
||||
@@ -9274,16 +9273,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-bundle",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-bundle.git",
|
||||
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506"
|
||||
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/caaa8b92f2657a6b99057f4da36ee89f3baab506",
|
||||
"reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506",
|
||||
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
|
||||
"reference": "a2a8dc7ab9d5d90d5937963a4ef1b9c8e3e7258c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9324,6 +9323,7 @@
|
||||
"symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/ldap": "^6.4|^7.0|^8.0",
|
||||
"symfony/lock": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/rate-limiter": "^6.4|^7.0|^8.0",
|
||||
"symfony/runtime": "^6.4.13|^7.1.6|^8.0",
|
||||
@@ -9362,7 +9362,7 @@
|
||||
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-bundle/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/security-bundle/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9382,20 +9382,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-30T17:23:54+00:00"
|
||||
"time": "2026-09-24T11:02:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-core",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-core.git",
|
||||
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2"
|
||||
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-core/zipball/4fac226354298b8d66f680275b0daef1d3f580d2",
|
||||
"reference": "4fac226354298b8d66f680275b0daef1d3f580d2",
|
||||
"url": "https://api.github.com/repos/symfony/security-core/zipball/4e5c01656aa79e6d463fde45117041ebccbaf829",
|
||||
"reference": "4e5c01656aa79e6d463fde45117041ebccbaf829",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9454,7 +9454,7 @@
|
||||
"description": "Symfony Security Component - Core Library",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-core/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/security-core/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9474,7 +9474,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-30T00:47:26+00:00"
|
||||
"time": "2026-09-28T16:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-csrf",
|
||||
@@ -9552,16 +9552,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/security-http",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/security-http.git",
|
||||
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb"
|
||||
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/security-http/zipball/c13182ec07b8a0c5316505a4c9c51948f87d87bb",
|
||||
"reference": "c13182ec07b8a0c5316505a4c9c51948f87d87bb",
|
||||
"url": "https://api.github.com/repos/symfony/security-http/zipball/5fa566b8bfbb398cded6988f059c3d97506ed692",
|
||||
"reference": "5fa566b8bfbb398cded6988f059c3d97506ed692",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9620,7 +9620,7 @@
|
||||
"description": "Symfony Security Component - HTTP Integration",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/security-http/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/security-http/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9640,20 +9640,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-15T06:01:15+00:00"
|
||||
"time": "2026-09-28T18:33:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/serializer",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/serializer.git",
|
||||
"reference": "d631b82e0db971444651c4e6a076040d20878caa"
|
||||
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/serializer/zipball/d631b82e0db971444651c4e6a076040d20878caa",
|
||||
"reference": "d631b82e0db971444651c4e6a076040d20878caa",
|
||||
"url": "https://api.github.com/repos/symfony/serializer/zipball/67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
|
||||
"reference": "67c5b0b2ef39863835f43b9c34dcb3af80b892cf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9724,7 +9724,7 @@
|
||||
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/serializer/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/serializer/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -9744,7 +9744,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-08T13:28:43+00:00"
|
||||
"time": "2026-09-28T09:39:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/service-contracts",
|
||||
@@ -10452,16 +10452,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/type-info",
|
||||
"version": "v7.4.17",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/type-info.git",
|
||||
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31"
|
||||
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
|
||||
"reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31",
|
||||
"url": "https://api.github.com/repos/symfony/type-info/zipball/bbaaf3366400f5c85f66800b955c77b46e8abad2",
|
||||
"reference": "bbaaf3366400f5c85f66800b955c77b46e8abad2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10511,7 +10511,7 @@
|
||||
"type"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/type-info/tree/v7.4.17"
|
||||
"source": "https://github.com/symfony/type-info/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10531,20 +10531,20 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-21T15:52:48+00:00"
|
||||
"time": "2026-09-28T12:19:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/uid",
|
||||
"version": "v7.4.17",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/uid.git",
|
||||
"reference": "69d732355a139c6f8881337d28515aa01f12b8be"
|
||||
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be",
|
||||
"reference": "69d732355a139c6f8881337d28515aa01f12b8be",
|
||||
"url": "https://api.github.com/repos/symfony/uid/zipball/a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
|
||||
"reference": "a96bbdb02a04ff3b5592cee6741b03edc78b56aa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10589,7 +10589,7 @@
|
||||
"uuid"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/uid/tree/v7.4.17"
|
||||
"source": "https://github.com/symfony/uid/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10609,7 +10609,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T07:38:58+00:00"
|
||||
"time": "2026-09-23T16:18:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/ux-twig-component",
|
||||
@@ -10702,16 +10702,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/validator",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/validator.git",
|
||||
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66"
|
||||
"reference": "75a30e116016554477f662e106fd8e337f0c4dac"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/validator/zipball/a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
|
||||
"reference": "a6d275d96d107fb2000dae04ab23b2e0c29ceb66",
|
||||
"url": "https://api.github.com/repos/symfony/validator/zipball/75a30e116016554477f662e106fd8e337f0c4dac",
|
||||
"reference": "75a30e116016554477f662e106fd8e337f0c4dac",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10782,7 +10782,7 @@
|
||||
"description": "Provides tools to validate values",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/validator/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/validator/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10802,7 +10802,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-09T19:50:33+00:00"
|
||||
"time": "2026-09-29T12:35:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-dumper",
|
||||
@@ -11137,16 +11137,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1"
|
||||
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
|
||||
"reference": "0222bf4dfaa1e3ceb8fff579635d655f2950d5a7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11189,7 +11189,7 @@
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11209,7 +11209,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-30T00:47:26+00:00"
|
||||
"time": "2026-09-26T06:31:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/extra-bundle",
|
||||
@@ -13055,16 +13055,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.5.36",
|
||||
"version": "12.5.37",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65"
|
||||
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ddb775588a0ea447d1c9b190c0f29c174ce81d65",
|
||||
"reference": "ddb775588a0ea447d1c9b190c0f29c174ce81d65",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/684340c1590e5f3f3b859f5e80619725d88b63ba",
|
||||
"reference": "684340c1590e5f3f3b859f5e80619725d88b63ba",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13133,7 +13133,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.36"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.37"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13141,7 +13141,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-25T08:47:40+00:00"
|
||||
"time": "2026-09-29T12:08:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
@@ -15103,16 +15103,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/web-profiler-bundle",
|
||||
"version": "v7.4.19",
|
||||
"version": "v7.4.20",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/web-profiler-bundle.git",
|
||||
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67"
|
||||
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f9c5fed39f307b23c47e8a11c431d848390f8a67",
|
||||
"reference": "f9c5fed39f307b23c47e8a11c431d848390f8a67",
|
||||
"url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/916c2a4edf51270a5173a57d8d3936c02a4543a1",
|
||||
"reference": "916c2a4edf51270a5173a57d8d3936c02a4543a1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -15169,7 +15169,7 @@
|
||||
"dev"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.19"
|
||||
"source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.20"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -15189,7 +15189,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-04T10:11:42+00:00"
|
||||
"time": "2026-09-28T19:09:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
|
||||
@@ -4,7 +4,7 @@ league_oauth2_server:
|
||||
private_key_passphrase: null
|
||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||
require_code_challenge_for_public_clients: true
|
||||
enable_client_credentials_grant: false
|
||||
enable_client_credentials_grant: true
|
||||
enable_auth_code_grant: true
|
||||
enable_refresh_token_grant: true
|
||||
enable_password_grant: false
|
||||
|
||||
@@ -207,6 +207,10 @@ services:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\BusProNet\Utility\CountryCodeMapper:
|
||||
arguments:
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\BusProNet\XmlLoader\HotelLoader:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
@@ -219,6 +223,11 @@ services:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
|
||||
App\Service\TravelExportBuilder:
|
||||
arguments:
|
||||
$cache: '@bpn.cache'
|
||||
$logger: '@monolog.logger.bpn'
|
||||
|
||||
App\Service\VoucherValidator:
|
||||
arguments:
|
||||
$cache: '@cache.app'
|
||||
@@ -386,8 +395,3 @@ services:
|
||||
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||
|
||||
league.oauth2_server.grant.client_credentials:
|
||||
class: League\OAuth2\Server\Grant\ClientCredentialsGrant
|
||||
tags:
|
||||
- { name: league.oauth2_server.authorization_server.grant, accessTokenTTL: 'PT1H' }
|
||||
+122
-5
@@ -10,8 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
|
||||
|
||||
- Token endpoint: `POST /token` (unauthenticated).
|
||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||
- Access token TTL depends on the grant: **1 hour** for client_credentials, **10 minutes** for authorization_code.
|
||||
Do not hardcode either value — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||
- Access token TTL is **10 minutes** for every grant.
|
||||
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||
A token can therefore expire part-way through a long page walk of `GET /api/exports/travels`; re-request one on
|
||||
401 and resume at the page you were on. At the default `perPage=500` the whole export is a single page today.
|
||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||
- Send the token as `Authorization: Bearer <access_token>`.
|
||||
@@ -48,7 +50,9 @@ There is no single error envelope; three shapes occur:
|
||||
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
|
||||
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
|
||||
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
|
||||
|
||||
`GET /api/exports/travels` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
|
||||
|
||||
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
|
||||
|
||||
@@ -60,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
|
||||
|
||||
#### `GET /api/hotels` — scope `api`
|
||||
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`.
|
||||
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
|
||||
`costUnit`, `countryCode`.
|
||||
|
||||
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
|
||||
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
|
||||
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
|
||||
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
|
||||
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
|
||||
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
|
||||
the mapping table is extended.
|
||||
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
|
||||
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
|
||||
|
||||
#### `GET /api/hotels/{id}` — scope `api`
|
||||
|
||||
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown.
|
||||
Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
|
||||
`404 {"message":"Not found"}` if unknown.
|
||||
|
||||
#### `GET /api/countries` — scope `api`
|
||||
|
||||
@@ -123,6 +139,107 @@ This is a full overwrite, not a merge.
|
||||
|
||||
---
|
||||
|
||||
### Bulk exports
|
||||
|
||||
#### `GET /api/exports/travels` — scope `api`
|
||||
|
||||
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
|
||||
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
|
||||
|
||||
This is the only paginated endpoint and the only one with a `meta` envelope.
|
||||
|
||||
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
|
||||
arbitrary string, so an export hung off that collection would share a namespace with every date code.
|
||||
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Parameter | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
|
||||
| `perPage` | int 1–1000 | `500` | |
|
||||
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
|
||||
|
||||
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
|
||||
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
|
||||
an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"busProId": 12794,
|
||||
"code": "PCJSER261226",
|
||||
"product": "Semesterende - Portes du Soleil - Joliment",
|
||||
"dateFrom": "2026-12-26",
|
||||
"dateTo": "2026-12-30",
|
||||
"hotel": {
|
||||
"busProId": 225444,
|
||||
"code": "PCJSER1",
|
||||
"name": "NEU SER Jolimont 1",
|
||||
"costUnit": "863",
|
||||
"country": "CH",
|
||||
"countryCode": "ch"
|
||||
},
|
||||
"pickups": [
|
||||
{
|
||||
"busProId": 18,
|
||||
"code": "ESS-Route",
|
||||
"city": "Essen",
|
||||
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||
"time": "20:15",
|
||||
"datetime": "2026-12-25T20:15:00+01:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"perPage": 500,
|
||||
"total": 415,
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
|
||||
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||
empty, never null.
|
||||
|
||||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
|
||||
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||
|
||||
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
|
||||
lost rows you previously had.
|
||||
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
|
||||
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
|
||||
with no name is more useful to a disposition view than one that silently vanished.
|
||||
|
||||
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
|
||||
|
||||
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
|
||||
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
|
||||
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
|
||||
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
|
||||
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
|
||||
bare-time sources too, and carries that resolution.
|
||||
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||
null together.
|
||||
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
||||
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
|
||||
day earlier than the running order implies. This is upstream data, not a transformation applied here.
|
||||
|
||||
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
|
||||
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
#### `GET /api/travels` — scope `api`
|
||||
|
||||
@@ -35,4 +35,15 @@ class Hotel
|
||||
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $country = null;
|
||||
|
||||
/**
|
||||
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
|
||||
* space-separated list such as "84 85", so it is an opaque label, not a number.
|
||||
*/
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $costUnit = null;
|
||||
|
||||
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
|
||||
#[Groups(['api:list', 'api:single', 'snapshot'])]
|
||||
public ?string $countryCode = null;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Utility;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Maps BusPro's `hotel/land` token onto a lowercase ISO 3166-1 alpha-2 code.
|
||||
*
|
||||
* BusPro has no country vocabulary: the field is free text that has accumulated three
|
||||
* conventions side by side — ISO-2 (`CH`, `NL`), the European vehicle registration codes
|
||||
* (`D`, `A`, `F`, `S`, `FL`) and ISO-3 (`AUT`, `PRT`, `HRV`), with the same country often
|
||||
* under several tokens. The table below is therefore part of the API contract rather than
|
||||
* a derivation: consumers render a flag from it, and a wrong entry mislabels a destination
|
||||
* silently, which is worse than no flag at all. An unknown token maps to null — never to a
|
||||
* guess, never to an empty string.
|
||||
*/
|
||||
class CountryCodeMapper
|
||||
{
|
||||
/**
|
||||
* Keys are upper-cased tokens, values lowercase ISO-2.
|
||||
*
|
||||
* Entries worth their explanation:
|
||||
* - `IR` is Ireland, not Iran. Every row carrying it is Dublin or Carlow.
|
||||
* - `SLO` is Slovakia here, not Slovenia: its only row is a Slovak company, and
|
||||
* Slovenia already arrives under its own `SI`.
|
||||
* - `S`, `N`, `FL` are vehicle codes (Sweden, Norway, Liechtenstein), consistent with
|
||||
* the `D`/`A`/`F`/`I`/`E`/`B` in the same column.
|
||||
* - `UK` is the colloquial form of the ISO `GB`.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const MAP = [
|
||||
'A' => 'at',
|
||||
'AT' => 'at',
|
||||
'AUT' => 'at',
|
||||
'B' => 'be',
|
||||
'CA' => 'ca',
|
||||
'CH' => 'ch',
|
||||
'CZ' => 'cz',
|
||||
'D' => 'de',
|
||||
'DEU' => 'de',
|
||||
'DK' => 'dk',
|
||||
'E' => 'es',
|
||||
'ES' => 'es',
|
||||
'F' => 'fr',
|
||||
'FI' => 'fi',
|
||||
'FL' => 'li',
|
||||
'FR' => 'fr',
|
||||
'GR' => 'gr',
|
||||
'GRE' => 'gr',
|
||||
'HR' => 'hr',
|
||||
'HRV' => 'hr',
|
||||
'HU' => 'hu',
|
||||
'I' => 'it',
|
||||
'IL' => 'il',
|
||||
'IR' => 'ie',
|
||||
'IS' => 'is',
|
||||
'IT' => 'it',
|
||||
'LT' => 'lt',
|
||||
'LV' => 'lv',
|
||||
'MT' => 'mt',
|
||||
'N' => 'no',
|
||||
'NL' => 'nl',
|
||||
'PL' => 'pl',
|
||||
'PRT' => 'pt',
|
||||
'PT' => 'pt',
|
||||
'RUS' => 'ru',
|
||||
'S' => 'se',
|
||||
'SI' => 'si',
|
||||
'SLO' => 'sk',
|
||||
'UK' => 'gb',
|
||||
'USA' => 'us',
|
||||
'ZA' => 'za',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tokens already reported in this process, so a master data file full of the same
|
||||
* unknown token produces one log line rather than one per hotel.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $reported = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $token the raw `hotel/land` value
|
||||
*
|
||||
* @return string|null lowercase ISO-2, or null when absent, empty or unmappable
|
||||
*/
|
||||
public function map(?string $token): ?string
|
||||
{
|
||||
if (null === $token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtoupper(trim($token));
|
||||
|
||||
if ('' === $normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset(self::MAP[$normalized])) {
|
||||
return self::MAP[$normalized];
|
||||
}
|
||||
|
||||
if (false === isset($this->reported[$normalized])) {
|
||||
$this->reported[$normalized] = true;
|
||||
$this->logger->warning('Unmapped BusPro country token', ['token' => $token]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,25 @@ namespace App\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\CountryCodeMapper;
|
||||
use App\BusProNet\XmlCrawlerFactory;
|
||||
use App\Exception\HotelNotFoundException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class HotelLoader extends AbstractLoader
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CountryCodeMapper $countryCodeMapper,
|
||||
CacheInterface $cache,
|
||||
FilesystemOperator $xmlExport,
|
||||
) {
|
||||
parent::__construct($cache, $xmlExport);
|
||||
}
|
||||
|
||||
/** @return array<int, Hotel> */
|
||||
public function loadAll(?string $filename = 'hotel.xml'): array
|
||||
{
|
||||
@@ -87,6 +98,10 @@ class HotelLoader extends AbstractLoader
|
||||
$hotel->country = $this->getStringOrNullValue($node->filterXPath('//land'));
|
||||
$hotel->street = $this->getStringOrNullValue($node->filterXPath('//strasse'));
|
||||
$hotel->phone = $this->getStringOrNullValue($node->filterXPath('//telefon'));
|
||||
// String, never int: see Hotel::$costUnit.
|
||||
$hotel->costUnit = $this->getStringOrNullValue($node->filterXPath('//kostenstelle'));
|
||||
// `country` itself stays the raw token — it is the join key into /api/countries[].token.
|
||||
$hotel->countryCode = $this->countryCodeMapper->map($hotel->country);
|
||||
|
||||
return $hotel;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ class TravelLoader extends AbstractLoader
|
||||
->each(function (Crawler $hotelNode) use (&$mapping, $hotels, $travelId) {
|
||||
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||
|
||||
// A travel may reference a hotel that hotel.xml does not carry.
|
||||
// Reading ->name off the miss would fatal the whole mapping, not
|
||||
// just this entry, and take /api/travels down with it.
|
||||
if (false === isset($hotels[$hotelId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mapping[$travelId]['hotels'][$hotelId] = [
|
||||
'id' => $hotelId,
|
||||
'code' => $hotelNode->attr('code'),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Removes local accounts that carry nothing worth keeping.
|
||||
*
|
||||
* Nothing here destroys customer data: BusPro owns the person, the bookings and the roles, and
|
||||
* BpnAuthenticator rebuilds the local row on the next login (`findOneBy(email) ?? new User()`),
|
||||
* re-deriving the roles, the hotel codes and the profile completeness from the CRM as it goes.
|
||||
* An account is a cache of what BusPro says, so dropping one costs its owner nothing.
|
||||
*
|
||||
* One thing is not rebuilt, and it is the reason every removal gets a log line of its own: the
|
||||
* uuid, minted in the constructor and exported as the OAuth2 `sub` claim (see UserinfoController).
|
||||
* A returning account gets a fresh one, so the old subject cannot be tied to the new row by
|
||||
* anything except that log entry. The SSO clients that gate on a role — E&P Team, E&P Hub — never
|
||||
* see these accounts anyway, since the roles they require are exactly the ones excluded below.
|
||||
*
|
||||
* An account is removed when all of the following hold:
|
||||
*
|
||||
* - it holds no effective role beyond ROLE_CUSTOMER, that role being the fallback for an account
|
||||
* with no privileges at all (see Role::withCustomerFallback()). An account left with no roles
|
||||
* at all is the same state, spelled the way it was before the fallback landed;
|
||||
* - it carries no pending nomination, which would otherwise disappear from /admin/user before
|
||||
* anybody had ruled on it;
|
||||
* - it holds no booking edit draft, and nothing in the Groups subsystem names it.
|
||||
*
|
||||
* --inactive-for adds a "has not signed in for N months" condition on top. It is off by default:
|
||||
* a returning customer is recreated on sight, so age says nothing about whether a row may go.
|
||||
*
|
||||
* Deleting requires --force. The removal cannot be undone and the first run clears thousands of
|
||||
* rows, so a bare invocation reports what it would do and stops. The guard is a flag rather than
|
||||
* an $io->confirm() because the scheduler hands the command a StringInput, which reports itself
|
||||
* as interactive — a confirmation would sit waiting on a STDIN nobody is attached to. A flag
|
||||
* behaves the same way whether a person or cron is running it, and it makes the schedule entry
|
||||
* say out loud that the task destroys data.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:cleanup:users',
|
||||
description: 'Removes accounts that hold no role, no draft and no group booking reference',
|
||||
)]
|
||||
class CleanupUsersCommand extends Command
|
||||
{
|
||||
/**
|
||||
* How many accounts are removed between two flushes. The unit of work holds every scheduled
|
||||
* removal until then, and the first run has thousands of rows to get through, so it is a
|
||||
* memory bound rather than a policy one.
|
||||
*/
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption(
|
||||
'inactive-for',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Only consider accounts without a login for this many months (default: no such limit)',
|
||||
)
|
||||
->addOption(
|
||||
'limit',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Maximum number of accounts to delete in one run (default: no limit)',
|
||||
)
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without actually deleting')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Actually delete; without it the command only reports what it would do')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$months = null !== $input->getOption('inactive-for') ? (int) $input->getOption('inactive-for') : null;
|
||||
$limit = null !== $input->getOption('limit') ? (int) $input->getOption('limit') : null;
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
$force = (bool) $input->getOption('force');
|
||||
|
||||
if ((null !== $months && $months < 1) || (null !== $limit && $limit < 1)) {
|
||||
$io->error('--inactive-for and --limit have to be at least 1 when given.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$inactiveSince = null !== $months ? new \DateTimeImmutable(sprintf('-%d months', $months)) : null;
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$this->userRepository->findCleanupCandidates($inactiveSince),
|
||||
static fn (User $user): bool => self::isDisposable($user),
|
||||
));
|
||||
|
||||
// After the role filter, never before it: --limit caps the accounts actually deleted, and
|
||||
// the candidate query cannot tell which ones those are.
|
||||
if (null !== $limit) {
|
||||
$candidates = \array_slice($candidates, 0, $limit);
|
||||
}
|
||||
|
||||
if ([] === $candidates) {
|
||||
$io->success('No accounts to remove.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (true === $dryRun || false === $force) {
|
||||
$io->table(
|
||||
['E-Mail', 'Letzter Login', 'Rollen'],
|
||||
array_map(static fn (User $user): array => [
|
||||
$user->getEmail(),
|
||||
$user->getLastLoginAt()?->format('Y-m-d') ?? '-',
|
||||
implode(', ', Role::assignedOnly($user->getRoles())) ?: '-',
|
||||
], $candidates),
|
||||
);
|
||||
|
||||
$io->note(sprintf('Would delete %d account(s).', \count($candidates)));
|
||||
|
||||
if (true === $dryRun) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Not a dry run, just an invocation that forgot to say so. Reported as a failure so a
|
||||
// schedule entry missing --force turns into a mail rather than a task that quietly
|
||||
// does nothing every week.
|
||||
$io->error('Refusing to delete without --force. Pass --dry-run to preview, or --force to delete.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
foreach ($candidates as $index => $user) {
|
||||
// One line per account, before the removal rather than after: once the row is gone
|
||||
// this is the only place left that records which account the uuid belonged to. Worth
|
||||
// a log_entry row per deletion, including the several thousand the first run writes —
|
||||
// they age out with the six-month log retention.
|
||||
$this->logger->info('Deleting account without roles', [
|
||||
'uuid' => $user->getUuid(),
|
||||
'email' => $user->getEmail(),
|
||||
'lastLoginAt' => $user->getLastLoginAt()?->format(\DateTimeInterface::ATOM),
|
||||
'roles' => Role::assignedOnly($user->getRoles()),
|
||||
]);
|
||||
|
||||
$this->entityManager->remove($user);
|
||||
|
||||
if (0 === ($index + 1) % self::BATCH_SIZE) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$count = \count($candidates);
|
||||
|
||||
$this->logger->info('Deleted accounts without roles', [
|
||||
'count' => $count,
|
||||
'inactiveSince' => $inactiveSince?->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$io->success(sprintf('Deleted %d account(s).', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account's role set says it may go. ROLE_CUSTOMER is the fallback rather than a
|
||||
* baseline, so "customer only" and "nothing at all" are the same state — the second being the
|
||||
* spelling accounts kept that have not signed in since the fallback landed.
|
||||
*
|
||||
* Pending markers grant nothing, but they are a nomination waiting for an administrator, so an
|
||||
* account carrying one stays until somebody has ruled on it.
|
||||
*/
|
||||
private static function isDisposable(User $user): bool
|
||||
{
|
||||
$roles = $user->getRoles();
|
||||
|
||||
return [] === Role::pendingOnly($roles)
|
||||
&& [] === array_diff(Role::effectiveOnly($roles), [Role::CUSTOMER]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Model\TravelExportQuery;
|
||||
use App\Service\TravelExportBuilder;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Bulk exports, for consumers that mirror our data rather than query it per row.
|
||||
*
|
||||
* Deliberately its own resource rather than a verb hung off the collection it exports.
|
||||
* `/api/travels/{dateCode}` accepts an arbitrary string, so an `/api/travels/export` would
|
||||
* share a namespace with every date code and could only be kept apart from one by a
|
||||
* negative lookahead on the sibling route -- a constraint nothing in the URL announces and
|
||||
* which silently mis-routes when it is wrong. Under `/api/exports` the collision does not
|
||||
* exist: this prefix never takes a parameterised segment, exactly like `/api/contingents`.
|
||||
*/
|
||||
#[Route('/api/exports')]
|
||||
#[IsGranted('ROLE_OAUTH2_API')]
|
||||
class ExportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TravelExportBuilder $travelExportBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every travel date x hotel pair with its pickups resolved, paginated.
|
||||
*
|
||||
* The counters in meta describe the whole export, not the page, so they repeat
|
||||
* unchanged on every page and a consumer can abort after the first one.
|
||||
*/
|
||||
#[Route(path: '/travels', name: 'api_exports_travels', methods: ['GET'])]
|
||||
public function travels(
|
||||
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
|
||||
TravelExportQuery $query = new TravelExportQuery(),
|
||||
): JsonResponse {
|
||||
// Deliberately not wrapped in a catch that answers 200 with an error body, the way
|
||||
// TravelController::hotelAvailability() does: the consumer's retry logic keys on
|
||||
// status codes, so a failure here has to surface as a 5xx.
|
||||
$export = $this->travelExportBuilder->build();
|
||||
|
||||
$total = count($export->rows);
|
||||
|
||||
return $this->json([
|
||||
'data' => array_slice($export->rows, ($query->page - 1) * $query->perPage, $query->perPage),
|
||||
'meta' => [
|
||||
'page' => $query->page,
|
||||
'perPage' => $query->perPage,
|
||||
'total' => $total,
|
||||
'totalPages' => (int) ceil($total / $query->perPage),
|
||||
'skipped' => $export->skipped,
|
||||
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
|
||||
'generatedAt' => $export->generatedAt->format('c'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* The whole travel-date x hotel export plus the counters that describe how it was built.
|
||||
*
|
||||
* Held in the bpn.cache pool as one unit and paginated in the controller, which is what
|
||||
* makes the counters identical on every page: they describe the export, not the page, so a
|
||||
* consumer can read them once and abort the page walk on the first response.
|
||||
*
|
||||
* @see \App\Service\TravelExportBuilder
|
||||
*/
|
||||
final readonly class TravelExport
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||
* @param int $skipped (travel, hotel) pairs dropped as unusable
|
||||
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public int $skipped,
|
||||
public int $pickupsWithoutMasterData,
|
||||
public \DateTimeImmutable $generatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
final readonly class TravelExportQuery
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\Positive]
|
||||
public int $page = 1,
|
||||
|
||||
#[Assert\Range(min: 1, max: 1000)]
|
||||
public int $perPage = 500,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Entity\Groups\AdditionalService;
|
||||
use App\Entity\Groups\BoardService;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\Filter\UserFilterDto;
|
||||
use App\Repository\Filter\AppliesListFiltersTrait;
|
||||
@@ -122,4 +127,87 @@ class UserRepository extends ServiceEntityRepository
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entities that point at a User and would silently lose that pointer if the account were
|
||||
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
|
||||
* stand in the way — it just erases who created, last touched or looks after a group booking.
|
||||
* The cleanup therefore has to ask, rather than let the constraint decide.
|
||||
*
|
||||
* @var array<class-string, string[]>
|
||||
*/
|
||||
private const USER_REFERENCES = [
|
||||
Accommodation::class => ['createdBy', 'updatedBy'],
|
||||
AccommodationPrice::class => ['createdBy', 'updatedBy'],
|
||||
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
|
||||
AdditionalService::class => ['createdBy', 'updatedBy'],
|
||||
BoardService::class => ['createdBy', 'updatedBy'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
|
||||
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
|
||||
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
|
||||
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
|
||||
*
|
||||
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
|
||||
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
|
||||
* asking for ten deletions would get however many of the first ten candidates survived the
|
||||
* role filter. Ordered oldest login first so a caller that does cap the result takes the
|
||||
* least recently seen accounts.
|
||||
*
|
||||
* The roles are deliberately not part of this query. They live in a JSON array column with no
|
||||
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
|
||||
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
|
||||
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
|
||||
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
|
||||
* a role set actually means.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('u');
|
||||
|
||||
if (null !== $inactiveSince) {
|
||||
$qb
|
||||
->andWhere('u.lastLoginAt IS NOT NULL')
|
||||
->andWhere('u.lastLoginAt < :inactiveSince')
|
||||
->setParameter('inactiveSince', $inactiveSince)
|
||||
;
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
||||
$this->getEntityManager()->createQueryBuilder()
|
||||
->select('draft.id')
|
||||
->from(BookingEditDraft::class, 'draft')
|
||||
->andWhere('draft.user = u')
|
||||
->getDQL()
|
||||
)));
|
||||
|
||||
$index = 0;
|
||||
|
||||
foreach (self::USER_REFERENCES as $class => $fields) {
|
||||
foreach ($fields as $field) {
|
||||
$alias = sprintf('ref%d%s', $index++, $field);
|
||||
|
||||
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
||||
$this->getEntityManager()->createQueryBuilder()
|
||||
->select($alias.'.id')
|
||||
->from($class, $alias)
|
||||
->andWhere(sprintf('%s.%s = u', $alias, $field))
|
||||
->getDQL()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/** @var User[] $users */
|
||||
$users = $qb
|
||||
->orderBy('u.lastLoginAt', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
return $users;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Traits\TypeConversionTrait;
|
||||
use App\BusProNet\XmlCrawlerFactory;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use App\Model\TravelExport;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* Flattens the BusPro XML export into one row per travel date x hotel, pickups resolved.
|
||||
*
|
||||
* Built from the XML file map rather than the travel_snapshot table. The table sits at
|
||||
* exactly this grain and is tempting, but it is refreshed on its own schedule, independent
|
||||
* of the master-data caches. Only the loaders share the bpn.cache pool under the xml-sync
|
||||
* tag, and that is what makes the consistency guarantee hold for free: /api/hotels,
|
||||
* /api/pickups and this export all move together when app:bpn:xml-sync invalidates the tag.
|
||||
*
|
||||
* Each Ziel_*.xml is read and walked exactly once. Looping TravelLoader::loadById() per row
|
||||
* would re-parse a whole file -- rooms, services, prices -- once per hotel on the date.
|
||||
*/
|
||||
class TravelExportBuilder
|
||||
{
|
||||
use TypeConversionTrait;
|
||||
|
||||
private const CACHE_KEY = 'bpn_travels_export';
|
||||
|
||||
public function __construct(
|
||||
private readonly HotelLoader $hotelLoader,
|
||||
private readonly PickupLoader $pickupLoader,
|
||||
private readonly TravelParser $travelParser,
|
||||
private readonly FilesystemOperator $xmlExport,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the whole export, cached as one unit alongside the sibling loaders.
|
||||
*/
|
||||
public function build(): TravelExport
|
||||
{
|
||||
try {
|
||||
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
|
||||
$item->expiresAfter(3 * 60 * 60);
|
||||
$item->tag(['xml-sync']);
|
||||
|
||||
return $this->assemble();
|
||||
});
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new \RuntimeException('Cache key error in TravelExportBuilder', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function assemble(): TravelExport
|
||||
{
|
||||
$hotels = $this->hotelLoader->loadAll();
|
||||
$pickupMasterData = $this->pickupLoader->loadAll();
|
||||
|
||||
/** @var array<string, array<string, mixed>> $rows keyed by travelId-hotelId */
|
||||
$rows = [];
|
||||
$skipped = 0;
|
||||
/** @var array<int, true> $pickupsWithoutMasterData */
|
||||
$pickupsWithoutMasterData = [];
|
||||
|
||||
$files = $this
|
||||
->xmlExport
|
||||
->listContents('.')
|
||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'));
|
||||
|
||||
foreach ($files as $file) {
|
||||
$crawler = XmlCrawlerFactory::create($this->xmlExport->read($file->path()));
|
||||
|
||||
$crawler->filterXPath('//reisen/reise/termin')->each(
|
||||
function (Crawler $node) use (&$rows, &$skipped, &$pickupsWithoutMasterData, $hotels, $pickupMasterData) {
|
||||
$this->collectTravel($node, $rows, $skipped, $pickupsWithoutMasterData, $hotels, $pickupMasterData);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new TravelExport(
|
||||
array_values($rows),
|
||||
$skipped,
|
||||
count($pickupsWithoutMasterData),
|
||||
new \DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @param array<int, true> $pickupsWithoutMasterData
|
||||
* @param array<int, Hotel> $hotels
|
||||
* @param array<int|string, Pickup> $pickupMasterData
|
||||
*/
|
||||
private function collectTravel(
|
||||
Crawler $node,
|
||||
array &$rows,
|
||||
int &$skipped,
|
||||
array &$pickupsWithoutMasterData,
|
||||
array $hotels,
|
||||
array $pickupMasterData,
|
||||
): void {
|
||||
$hotelNodes = $node->filterXPath('.//hotel');
|
||||
|
||||
// Dates first: getPickups() needs a non-null default date for the time-only zeit
|
||||
// shape, and parsing them up front is also what turns an unusable date into a
|
||||
// dropped row rather than a row with a null date the consumer cannot key on.
|
||||
$dateFrom = $this->stringToDate($node->attr('termin'));
|
||||
$dateTo = $this->stringToDate($node->attr('bis'));
|
||||
|
||||
if (null === $dateFrom || null === $dateTo) {
|
||||
$this->logger->warning('Travel export: unparseable travel date', [
|
||||
'travel_id' => $node->attr('idbuspro'),
|
||||
'termin' => $node->attr('termin'),
|
||||
'bis' => $node->attr('bis'),
|
||||
]);
|
||||
$skipped += $hotelNodes->count();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$travelId = (int) $node->attr('idbuspro');
|
||||
|
||||
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
||||
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
|
||||
// calendar day. Resolved once per travel date, then shared by its hotel rows.
|
||||
$pickups = $this->buildPickups(
|
||||
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
|
||||
$pickupMasterData,
|
||||
$pickupsWithoutMasterData,
|
||||
);
|
||||
|
||||
$travel = [
|
||||
'busProId' => $travelId,
|
||||
'code' => $node->attr('code'),
|
||||
'product' => $this->trimOrNull($this->getStringOrNullValue($node->filterXPath('.//text'))),
|
||||
'dateFrom' => $dateFrom->format('Y-m-d'),
|
||||
'dateTo' => $dateTo->format('Y-m-d'),
|
||||
];
|
||||
|
||||
$hotelNodes->each(function (Crawler $hotelNode) use (&$rows, &$skipped, $hotels, $travel, $travelId, $pickups) {
|
||||
$hotelId = (int) $hotelNode->attr('idbuspro');
|
||||
|
||||
// Defensive: no travel in the current snapshot references a hotel that
|
||||
// hotel.xml does not carry. A row without master data would reach the consumer
|
||||
// with no name and no country, so it is dropped and counted instead.
|
||||
if (false === isset($hotels[$hotelId])) {
|
||||
$this->logger->warning('Travel export: hotel missing from master data', [
|
||||
'travel_id' => $travelId,
|
||||
'hotel_id' => $hotelId,
|
||||
]);
|
||||
++$skipped;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hotel = $hotels[$hotelId];
|
||||
|
||||
// Keyed by the pair the consumer upserts on, so a date repeated across files
|
||||
// collapses last-wins instead of reaching it as a duplicate.
|
||||
$rows[$travelId.'-'.$hotelId] = $travel + [
|
||||
'hotel' => [
|
||||
'busProId' => $hotelId,
|
||||
'code' => $hotel->code,
|
||||
'name' => $hotel->name,
|
||||
'costUnit' => $hotel->costUnit,
|
||||
'country' => $hotel->country,
|
||||
'countryCode' => $hotel->countryCode,
|
||||
],
|
||||
'pickups' => $pickups,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the travel XML's pickups against zustiege.xml.
|
||||
*
|
||||
* Deliberately not PickupLoader::patchPickupsDetails(), which sorts by time -- the
|
||||
* contract is source order, which is the order the disposition lists them in. Nor
|
||||
* enrichPickupDetails(), which is private and passes over a miss silently.
|
||||
*
|
||||
* @param array<int, Pickup> $pickups pickups indexed by id, in source order
|
||||
* @param array<int|string, Pickup> $masterData
|
||||
* @param array<int, true> $pickupsWithoutMasterData
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildPickups(array $pickups, array $masterData, array &$pickupsWithoutMasterData): array
|
||||
{
|
||||
$resolved = [];
|
||||
|
||||
foreach ($pickups as $pickup) {
|
||||
$pickupId = $pickup->id;
|
||||
|
||||
// Defensive: none exist today. An entry with no id is not addressable by the
|
||||
// consumer, so drop the entry -- never the row it belongs to.
|
||||
if (null === $pickupId || 0 === $pickupId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$details = $masterData[$pickupId] ?? null;
|
||||
|
||||
// Around 21 ids referenced by travel XML have no entry in zustiege.xml. The
|
||||
// entry is emitted anyway: dropping it would hide a real departure point from
|
||||
// the disposition UI, where a nameless stop is still better than a missing one.
|
||||
if (null === $details) {
|
||||
$pickupsWithoutMasterData[$pickupId] = true;
|
||||
}
|
||||
|
||||
$resolved[] = [
|
||||
'busProId' => $pickupId,
|
||||
'code' => $details?->code,
|
||||
'city' => $details?->city,
|
||||
'street' => $details?->street,
|
||||
'time' => $pickup->time?->format('H:i'),
|
||||
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
||||
// worked out the real day, and a null here would throw that away.
|
||||
'datetime' => $pickup->time?->format('c'),
|
||||
];
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function getStringOrNullValue(Crawler $node): ?string
|
||||
{
|
||||
return 0 < $node->count() ? $node->text() : null;
|
||||
}
|
||||
|
||||
private function trimOrNull(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Tests\BusProNet\DataLoader;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Utility\CountryCodeMapper;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
|
||||
@@ -34,7 +36,7 @@ class HotelDataLoaderTest extends TestCase
|
||||
->willReturn($xmlContent);
|
||||
|
||||
$cache = new TagAwareAdapter(new ArrayAdapter());
|
||||
$loader = new HotelLoader($cache, $filesystem);
|
||||
$loader = new HotelLoader(new CountryCodeMapper(new NullLogger()), $cache, $filesystem);
|
||||
|
||||
$hotels = $loader->loadAll();
|
||||
|
||||
@@ -50,5 +52,7 @@ class HotelDataLoaderTest extends TestCase
|
||||
$this->assertEquals('F', $hotel->country);
|
||||
$this->assertEquals('Rue des Lacs', $hotel->street);
|
||||
$this->assertEquals('', $hotel->phone);
|
||||
$this->assertEquals('fr', $hotel->countryCode);
|
||||
$this->assertNull($hotel->costUnit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\Utility;
|
||||
|
||||
use App\BusProNet\Utility\CountryCodeMapper;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
class CountryCodeMapperTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function tokenProvider(): array
|
||||
{
|
||||
return [
|
||||
'iso-2 passes through' => ['CH', 'ch'],
|
||||
'vehicle code D' => ['D', 'de'],
|
||||
'iso-3 DEU' => ['DEU', 'de'],
|
||||
'vehicle code A' => ['A', 'at'],
|
||||
'iso-2 AT' => ['AT', 'at'],
|
||||
'iso-3 AUT' => ['AUT', 'at'],
|
||||
'vehicle code F' => ['F', 'fr'],
|
||||
'vehicle code I' => ['I', 'it'],
|
||||
'iso-2 IT' => ['IT', 'it'],
|
||||
'vehicle code E' => ['E', 'es'],
|
||||
'vehicle code B' => ['B', 'be'],
|
||||
'vehicle code S is Sweden' => ['S', 'se'],
|
||||
'vehicle code N is Norway' => ['N', 'no'],
|
||||
'vehicle code FL is Liechtenstein' => ['FL', 'li'],
|
||||
'IR is Ireland, not Iran' => ['IR', 'ie'],
|
||||
'UK maps to the iso GB' => ['UK', 'gb'],
|
||||
'iso-3 PRT' => ['PRT', 'pt'],
|
||||
'iso-3 GRE' => ['GRE', 'gr'],
|
||||
'iso-3 HRV' => ['HRV', 'hr'],
|
||||
'iso-3 RUS' => ['RUS', 'ru'],
|
||||
'iso-3 USA' => ['USA', 'us'],
|
||||
'SI is Slovenia' => ['SI', 'si'],
|
||||
'SLO is Slovakia' => ['SLO', 'sk'],
|
||||
'ZA is South Africa' => ['ZA', 'za'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('tokenProvider')]
|
||||
public function testMapsKnownToken(string $token, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, (new CountryCodeMapper(new NullLogger()))->map($token));
|
||||
}
|
||||
|
||||
public function testEveryMappedCodeIsLowercaseIsoTwo(): void
|
||||
{
|
||||
$mapper = new CountryCodeMapper(new NullLogger());
|
||||
|
||||
foreach (array_keys(self::tokenProvider()) as $name) {
|
||||
[$token] = self::tokenProvider()[$name];
|
||||
$code = $mapper->map($token);
|
||||
|
||||
$this->assertNotNull($code);
|
||||
$this->assertMatchesRegularExpression('/^[a-z]{2}$/', $code, $token.' must map to lowercase ISO-2');
|
||||
}
|
||||
}
|
||||
|
||||
public function testIsCaseAndWhitespaceInsensitive(): void
|
||||
{
|
||||
$mapper = new CountryCodeMapper(new NullLogger());
|
||||
|
||||
$this->assertSame('ch', $mapper->map('ch'));
|
||||
$this->assertSame('at', $mapper->map(' aut '));
|
||||
}
|
||||
|
||||
public function testUnknownTokenMapsToNullAndWarnsOnce(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Unmapped BusPro country token', ['token' => 'XX']);
|
||||
|
||||
$mapper = new CountryCodeMapper($logger);
|
||||
|
||||
$this->assertNull($mapper->map('XX'));
|
||||
$this->assertNull($mapper->map('XX'));
|
||||
}
|
||||
|
||||
public function testAbsentTokenMapsToNullWithoutWarning(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger
|
||||
->expects($this->never())
|
||||
->method('warning');
|
||||
|
||||
$mapper = new CountryCodeMapper($logger);
|
||||
|
||||
$this->assertNull($mapper->map(null));
|
||||
$this->assertNull($mapper->map(''));
|
||||
$this->assertNull($mapper->map(' '));
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Tests\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\Utility\CountryCodeMapper;
|
||||
use App\BusProNet\XmlCrawlerFactory;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class HotelLoaderTest extends TestCase
|
||||
{
|
||||
@@ -19,7 +23,7 @@ class HotelLoaderTest extends TestCase
|
||||
->expects($this->never())
|
||||
->method('read');
|
||||
|
||||
$loader = new HotelLoader(new ArrayAdapter(), $filesystem);
|
||||
$loader = $this->createLoader($filesystem);
|
||||
$travel = new Travel();
|
||||
$travel->hotelId = null;
|
||||
$travel->hotel = null;
|
||||
@@ -28,4 +32,64 @@ class HotelLoaderTest extends TestCase
|
||||
|
||||
$this->assertNull($travel->hotel);
|
||||
}
|
||||
|
||||
public function testParseXmlMapsCostUnitAndCountryCode(): void
|
||||
{
|
||||
$hotel = $this->createLoader()->parseXml($this->hotelNode(
|
||||
'<kostenstelle>83</kostenstelle><land>CH</land>'
|
||||
));
|
||||
|
||||
$this->assertSame('83', $hotel->costUnit);
|
||||
$this->assertSame('CH', $hotel->country, 'the raw token is the join key and must not be rewritten');
|
||||
$this->assertSame('ch', $hotel->countryCode);
|
||||
}
|
||||
|
||||
public function testParseXmlKeepsAListOfCostUnitsAsAString(): void
|
||||
{
|
||||
$hotel = $this->createLoader()->parseXml($this->hotelNode(
|
||||
'<kostenstelle>84 85</kostenstelle><land>D</land>'
|
||||
));
|
||||
|
||||
$this->assertSame('84 85', $hotel->costUnit);
|
||||
}
|
||||
|
||||
public function testParseXmlLeavesCostUnitNullWhenAbsent(): void
|
||||
{
|
||||
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>D</land>'));
|
||||
|
||||
$this->assertNull($hotel->costUnit);
|
||||
$this->assertSame('de', $hotel->countryCode);
|
||||
}
|
||||
|
||||
public function testParseXmlLeavesBothCountryFieldsNullWhenLandIsAbsent(): void
|
||||
{
|
||||
$hotel = $this->createLoader()->parseXml($this->hotelNode('<name>Ohne Land</name>'));
|
||||
|
||||
$this->assertNull($hotel->country);
|
||||
$this->assertNull($hotel->countryCode);
|
||||
}
|
||||
|
||||
public function testParseXmlLeavesCountryCodeNullForAnUnmappableToken(): void
|
||||
{
|
||||
$hotel = $this->createLoader()->parseXml($this->hotelNode('<land>XX</land>'));
|
||||
|
||||
$this->assertSame('XX', $hotel->country);
|
||||
$this->assertNull($hotel->countryCode, 'an unknown token must never be guessed into a code');
|
||||
}
|
||||
|
||||
private function hotelNode(string $children): Crawler
|
||||
{
|
||||
$xml = sprintf('<hotels><hotel idbuspro="1" code="TEST">%s</hotel></hotels>', $children);
|
||||
|
||||
return XmlCrawlerFactory::create($xml)->filterXPath('//hotels/hotel');
|
||||
}
|
||||
|
||||
private function createLoader(?FilesystemOperator $filesystem = null): HotelLoader
|
||||
{
|
||||
return new HotelLoader(
|
||||
new CountryCodeMapper(new NullLogger()),
|
||||
new ArrayAdapter(),
|
||||
$filesystem ?? $this->createStub(FilesystemOperator::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlLoader;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\TravelLoader;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use League\Flysystem\DirectoryListing;
|
||||
use League\Flysystem\FileAttributes;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
|
||||
class TravelLoaderFilesMapTest extends TestCase
|
||||
{
|
||||
private const XML = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise code="SBW">
|
||||
<termin idbuspro="900" code="SBW250101" termin="01.01.2025" bis="08.01.2025">
|
||||
<text>Skiwoche</text>
|
||||
<hotel idbuspro="1" code="KNOWN"/>
|
||||
<hotel idbuspro="999" code="GHOST"/>
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>';
|
||||
|
||||
/**
|
||||
* A travel may reference a hotel id that hotel.xml does not carry. Before the guard the
|
||||
* miss was dereferenced, which fataled the whole cached mapping rather than one entry --
|
||||
* so /api/travels went down entirely, not just the affected travel.
|
||||
*/
|
||||
public function testGenerateFilesMapSkipsHotelsMissingFromMasterData(): void
|
||||
{
|
||||
$known = new Hotel();
|
||||
$known->id = 1;
|
||||
$known->name = 'Hotel Bekannt';
|
||||
|
||||
$hotelLoader = $this->createStub(HotelLoader::class);
|
||||
$hotelLoader->method('loadAll')->willReturn([1 => $known]);
|
||||
|
||||
$filesystem = $this->createStub(FilesystemOperator::class);
|
||||
$filesystem->method('listContents')->willReturn(
|
||||
new DirectoryListing([new FileAttributes('Ziel_1.xml')])
|
||||
);
|
||||
$filesystem->method('read')->willReturn(self::XML);
|
||||
|
||||
$loader = new TravelLoader(
|
||||
$hotelLoader,
|
||||
$this->createStub(TravelParser::class),
|
||||
'https://example.test',
|
||||
new TagAwareAdapter(new ArrayAdapter()),
|
||||
$filesystem,
|
||||
);
|
||||
|
||||
$mapping = $loader->generateFilesMap();
|
||||
|
||||
$this->assertArrayHasKey(900, $mapping);
|
||||
$this->assertSame([1], array_keys($mapping[900]['hotels']));
|
||||
$this->assertSame('Hotel Bekannt', $mapping[900]['hotels'][1]['label']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\CleanupUsersCommand;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CleanupUsersCommandTest extends TestCase
|
||||
{
|
||||
public function testDeletesAccountsWithoutAnEffectiveRole(): void
|
||||
{
|
||||
$customer = $this->user('[email protected]', [Role::CUSTOMER]);
|
||||
$roleless = $this->user('[email protected]', []);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$removed = [];
|
||||
$entityManager->expects(self::exactly(2))
|
||||
->method('remove')
|
||||
->willReturnCallback(static function (User $user) use (&$removed): void {
|
||||
$removed[] = $user->getEmail();
|
||||
});
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$customer, $roleless], $entityManager));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertSame(['[email protected]', '[email protected]'], $removed);
|
||||
self::assertStringContainsString('Deleted 2 account(s).', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate query cannot read the roles, so everything the role policy protects has to
|
||||
* survive this filter — including an account whose only effective role sits beside the
|
||||
* ROLE_CUSTOMER fallback that older rows still carry.
|
||||
*
|
||||
* @param string[] $roles
|
||||
*/
|
||||
#[DataProvider('protectedRoleSets')]
|
||||
public function testKeepsAccountsHoldingAnythingBeyondTheCustomerFallback(array $roles): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', $roles)], $entityManager));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('No accounts to remove.', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string[]}>
|
||||
*/
|
||||
public static function protectedRoleSets(): iterable
|
||||
{
|
||||
yield 'teamer' => [[Role::TEAMER]];
|
||||
yield 'employee' => [[Role::EMPLOYEE]];
|
||||
yield 'approved administrative role' => [[Role::TEAMER, Role::MANAGER]];
|
||||
yield 'house manager' => [[Role::HOUSE_MANAGER]];
|
||||
yield 'pending nomination awaiting approval' => [[Role::CUSTOMER, Role::pending(Role::ADMIN)]];
|
||||
yield 'stale fallback beside a real role' => [[Role::CUSTOMER, Role::TEAMER]];
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole point of the guard: a bare invocation is a preview, not a deletion. Symfony's
|
||||
* VALUE_REQUIRED only says an option needs a value when it is given, so nothing in the
|
||||
* definition ever forces the caller to state an intent — this does.
|
||||
*/
|
||||
public function testRefusesToDeleteWithoutForce(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||
self::assertStringContainsString('Refusing to delete without --force', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testDryRunDeletesNothing(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('remove');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$tester = new CommandTester($this->command([$this->user('[email protected]', [Role::CUSTOMER])], $entityManager));
|
||||
$tester->execute(['--dry-run' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Would delete 1 account(s).', $tester->getDisplay());
|
||||
self::assertStringNotContainsString('Refusing to delete', $tester->getDisplay());
|
||||
self::assertStringContainsString('[email protected]', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* --limit caps the deletions, not the candidates examined, so a run that finds one deletable
|
||||
* account among protected ones still deletes it rather than spending the budget on rows it
|
||||
* then discards.
|
||||
*/
|
||||
public function testLimitCountsDeletionsRatherThanCandidates(): void
|
||||
{
|
||||
$users = [
|
||||
$this->user('[email protected]', [Role::TEAMER]),
|
||||
$this->user('[email protected]', [Role::CUSTOMER]),
|
||||
];
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())
|
||||
->method('remove')
|
||||
->with(self::callback(static fn (User $user): bool => '[email protected]' === $user->getEmail()));
|
||||
|
||||
$tester = new CommandTester($this->command($users, $entityManager));
|
||||
$tester->execute(['--limit' => 1, '--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
self::assertStringContainsString('Deleted 1 account(s).', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testInactivityWindowIsOffUnlessAskedFor(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::once())
|
||||
->method('findCleanupCandidates')
|
||||
->with(null)
|
||||
->willReturn([]);
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testInactivityWindowIsPassedOnAsADate(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::once())
|
||||
->method('findCleanupCandidates')
|
||||
->with(self::callback(static function (?\DateTimeImmutable $since): bool {
|
||||
$expected = new \DateTimeImmutable('-6 months');
|
||||
|
||||
return null !== $since && $since->format('Y-m-d') === $expected->format('Y-m-d');
|
||||
}))
|
||||
->willReturn([]);
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--inactive-for' => 6, '--force' => true]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRejectsANonPositiveWindow(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository->expects(self::never())->method('findCleanupCandidates');
|
||||
|
||||
$tester = new CommandTester($this->commandWith($repository, $this->createStub(EntityManagerInterface::class)));
|
||||
$tester->execute(['--inactive-for' => 0]);
|
||||
|
||||
self::assertSame(Command::INVALID, $tester->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(string $email, array $roles): User
|
||||
{
|
||||
return (new User($email))
|
||||
->setRoles($roles)
|
||||
->setLastLoginAt(new \DateTimeImmutable('-2 years'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User[] $candidates
|
||||
*/
|
||||
private function command(array $candidates, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||
{
|
||||
$repository = $this->createStub(UserRepository::class);
|
||||
$repository->method('findCleanupCandidates')->willReturn($candidates);
|
||||
|
||||
return $this->commandWith($repository, $entityManager);
|
||||
}
|
||||
|
||||
private function commandWith(UserRepository $repository, EntityManagerInterface $entityManager): CleanupUsersCommand
|
||||
{
|
||||
return new CleanupUsersCommand($repository, $entityManager, $this->createStub(LoggerInterface::class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\Controller\Api\ExportController;
|
||||
use App\Model\TravelExport;
|
||||
use App\Model\TravelExportQuery;
|
||||
use App\Service\TravelExportBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\Container;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ExportControllerTest extends TestCase
|
||||
{
|
||||
public function testPaginatesAndReportsWholeExportFigures(): void
|
||||
{
|
||||
$meta = $this->call(new TravelExportQuery(1, 5))['meta'];
|
||||
|
||||
self::assertSame(1, $meta['page']);
|
||||
self::assertSame(5, $meta['perPage']);
|
||||
self::assertSame(12, $meta['total'], 'total is the whole export, not this page');
|
||||
self::assertSame(3, $meta['totalPages']);
|
||||
}
|
||||
|
||||
public function testCountersAreIdenticalOnEveryPage(): void
|
||||
{
|
||||
// Whole-export figures repeated per page, so a consumer can abort after page 1.
|
||||
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
|
||||
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
|
||||
|
||||
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
|
||||
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
|
||||
}
|
||||
}
|
||||
|
||||
public function testConsecutivePagesDoNotOverlap(): void
|
||||
{
|
||||
$first = array_column($this->call(new TravelExportQuery(1, 5))['data'], 'busProId');
|
||||
$second = array_column($this->call(new TravelExportQuery(2, 5))['data'], 'busProId');
|
||||
|
||||
self::assertSame([1, 2, 3, 4, 5], $first);
|
||||
self::assertSame([6, 7, 8, 9, 10], $second);
|
||||
self::assertSame([], array_intersect($first, $second));
|
||||
}
|
||||
|
||||
public function testTheLastPageIsShort(): void
|
||||
{
|
||||
$data = $this->call(new TravelExportQuery(3, 5))['data'];
|
||||
|
||||
self::assertCount(2, $data);
|
||||
self::assertSame([11, 12], array_column($data, 'busProId'));
|
||||
}
|
||||
|
||||
public function testAPageBeyondTheEndIsEmptyRatherThanAnError(): void
|
||||
{
|
||||
$body = $this->call(new TravelExportQuery(99, 5));
|
||||
|
||||
self::assertSame([], $body['data']);
|
||||
self::assertSame(12, $body['meta']['total']);
|
||||
}
|
||||
|
||||
public function testDefaultsToTheFirstPageOfFiveHundred(): void
|
||||
{
|
||||
$body = $this->call();
|
||||
|
||||
self::assertSame(1, $body['meta']['page']);
|
||||
self::assertSame(500, $body['meta']['perPage']);
|
||||
self::assertSame(1, $body['meta']['totalPages']);
|
||||
self::assertCount(12, $body['data']);
|
||||
}
|
||||
|
||||
public function testCarriesTheBuilderCounters(): void
|
||||
{
|
||||
$meta = $this->call(null, 7, 21)['meta'];
|
||||
|
||||
self::assertSame(7, $meta['skipped']);
|
||||
self::assertSame(21, $meta['pickupsWithoutMasterData']);
|
||||
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A builder failure must reach the consumer as a 5xx, never as the 200-with-an-error-body
|
||||
* shape TravelController::hotelAvailability() uses: the retry logic keys on the status code.
|
||||
*/
|
||||
public function testABuilderFailureIsNotDowngradedToATwoHundred(): void
|
||||
{
|
||||
$builder = $this->createStub(TravelExportBuilder::class);
|
||||
$builder->method('build')->willThrowException(new \RuntimeException('cache exploded'));
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
|
||||
$this->controller($builder)->travels(new TravelExportQuery());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function call(?TravelExportQuery $query = null, int $skipped = 0, int $withoutMasterData = 0): array
|
||||
{
|
||||
$rows = [];
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$rows[] = ['busProId' => $i, 'hotel' => ['busProId' => 1], 'pickups' => []];
|
||||
}
|
||||
|
||||
$builder = $this->createStub(TravelExportBuilder::class);
|
||||
$builder->method('build')->willReturn(new TravelExport(
|
||||
$rows,
|
||||
$skipped,
|
||||
$withoutMasterData,
|
||||
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
|
||||
));
|
||||
|
||||
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
|
||||
return json_decode((string) $response->getContent(), true);
|
||||
}
|
||||
|
||||
private function controller(TravelExportBuilder $builder): ExportController
|
||||
{
|
||||
$controller = new ExportController($builder);
|
||||
|
||||
// No 'serializer' service registered, so AbstractController::json() falls back to
|
||||
// JsonResponse -- which is what this endpoint produces in production anyway.
|
||||
$controller->setContainer(new Container());
|
||||
|
||||
return $controller;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Api;
|
||||
|
||||
use App\Controller\Api\ExportController;
|
||||
use App\Controller\Api\TravelController;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Routing\Attribute\Route as RouteAttribute;
|
||||
use Symfony\Component\Routing\Matcher\UrlMatcher;
|
||||
use Symfony\Component\Routing\RequestContext;
|
||||
use Symfony\Component\Routing\Route;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
|
||||
/**
|
||||
* Guards the /api/travels namespace against a sibling route being swallowed by byCode().
|
||||
*
|
||||
* `/api/travels/{dateCode}` accepts an arbitrary string, so anything else placed under
|
||||
* `/api/travels/` competes with every date code, and a path falling through to the wrong
|
||||
* route answers 404 with a body that reads like an endpoint holding no data. The bulk
|
||||
* export lives at `/api/exports/travels` for exactly this reason. The collection is built
|
||||
* by reflecting over the real attributes, so this breaks if the declarations change.
|
||||
*/
|
||||
class TravelControllerRoutingTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function pathProvider(): array
|
||||
{
|
||||
return [
|
||||
'the export lives outside the travels namespace' => ['/api/exports/travels', 'api_exports_travels'],
|
||||
// Regression: a dateCode requirement of `.+` instead of the default `[^/]+` widens
|
||||
// the segment across slashes, and this four-segment path is then swallowed by byCode().
|
||||
'availability is not shadowed by the code lookup' => [
|
||||
'/api/travels/12794/225444/2026-12-30/availability',
|
||||
'api_travel_hotel_availability',
|
||||
],
|
||||
'numeric ids still reach byId' => ['/api/travels/12794/225444', 'api_travel_single_id'],
|
||||
'a date code alone reaches byCode' => ['/api/travels/SBW261226', 'api_travel_single_code'],
|
||||
'a date and hotel code reach byCode' => ['/api/travels/SBW261226/H1', 'api_travel_single_code'],
|
||||
'the mapping index is untouched' => ['/api/travels', 'api_travel_mapping'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('pathProvider')]
|
||||
public function testPathResolvesToTheIntendedRoute(string $path, string $expected): void
|
||||
{
|
||||
$matcher = new UrlMatcher($this->collection(), new RequestContext('', 'GET'));
|
||||
|
||||
self::assertSame($expected, $matcher->match($path)['_route']);
|
||||
}
|
||||
|
||||
public function testDateCodeNeverSpansSlashes(): void
|
||||
{
|
||||
$route = $this->collection()->get('api_travel_single_code');
|
||||
|
||||
self::assertNotNull($route);
|
||||
self::assertStringNotContainsString(
|
||||
'.+',
|
||||
(string) $route->getRequirement('dateCode'),
|
||||
'dateCode must keep the single-segment class, or it shadows the routes below it',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The export must stay out of `/api/travels/`, where it would be one date code away from
|
||||
* ambiguity and would need a negative lookahead on byCode() to be reachable at all.
|
||||
*/
|
||||
public function testNothingButTravelLookupsLivesUnderTheTravelsNamespace(): void
|
||||
{
|
||||
foreach ($this->collection()->all() as $name => $route) {
|
||||
self::assertStringNotContainsString(
|
||||
'/api/travels/export',
|
||||
$route->getPath(),
|
||||
$name.' must not sit in the date code namespace',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects the controller's own #[Route] attributes, in declaration order.
|
||||
*/
|
||||
private function collection(): RouteCollection
|
||||
{
|
||||
$collection = new RouteCollection();
|
||||
|
||||
foreach ([TravelController::class, ExportController::class] as $controller) {
|
||||
$this->addRoutes($collection, $controller);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string $controller
|
||||
*/
|
||||
private function addRoutes(RouteCollection $collection, string $controller): void
|
||||
{
|
||||
$class = new \ReflectionClass($controller);
|
||||
|
||||
$prefix = '';
|
||||
foreach ($class->getAttributes(RouteAttribute::class) as $attribute) {
|
||||
$prefix = (string) $attribute->newInstance()->path;
|
||||
}
|
||||
|
||||
foreach ($class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
foreach ($method->getAttributes(RouteAttribute::class) as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
|
||||
$collection->add((string) $route->name, new Route(
|
||||
$prefix.$route->path,
|
||||
$route->defaults,
|
||||
$route->requirements,
|
||||
[],
|
||||
'',
|
||||
[],
|
||||
$route->methods,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise code="SBW">
|
||||
<!-- Two hotels on one date: the cross product must produce one row per pair. -->
|
||||
<termin id="1" idbuspro="900" idprodukt="1" termin="26.12.2026" bis="30.12.2026" code="SBW261226" reiseart="F">
|
||||
<text> Semesterende - Portes du Soleil </text>
|
||||
<hotel idbuspro="1" code="H1"/>
|
||||
<hotel idbuspro="2" code="H2"/>
|
||||
<zustiege>
|
||||
<zustieg id="1" idbuspro="10" zeit="26.12.2026 23:59:00"/>
|
||||
<!-- Time only, and earlier than the 23:59 above: rolls onto the next calendar day. -->
|
||||
<zustieg id="2" idbuspro="11" zeit="02:15"/>
|
||||
<!-- No zeit at all: the entry survives with both time fields null. -->
|
||||
<zustieg id="3" idbuspro="12"/>
|
||||
<!-- Referenced but absent from zustiege.xml. -->
|
||||
<zustieg id="4" idbuspro="99" zeit="26.12.2026 21:00:00"/>
|
||||
</zustiege>
|
||||
</termin>
|
||||
<!-- Summer date, to pin the +02:00 offset. -->
|
||||
<termin id="2" idbuspro="901" termin="15.07.2027" bis="22.07.2027" code="SBW270715">
|
||||
<text>Sommer</text>
|
||||
<hotel idbuspro="1" code="H1"/>
|
||||
<zustiege>
|
||||
<zustieg id="1" idbuspro="10" zeit="15.07.2027 08:00:00"/>
|
||||
<!-- Time only and after the full datetime above: stays on the departure date. -->
|
||||
<zustieg id="2" idbuspro="11" zeit="09:30"/>
|
||||
</zustiege>
|
||||
</termin>
|
||||
<!-- Hotel absent from hotel.xml: the row is dropped and counted. -->
|
||||
<termin id="3" idbuspro="902" termin="01.02.2027" bis="08.02.2027" code="SBW270201">
|
||||
<text>Ohne Hotel</text>
|
||||
<hotel idbuspro="404" code="GHOST"/>
|
||||
</termin>
|
||||
<!-- Unparseable date: both its hotel rows are dropped and counted. -->
|
||||
<termin id="4" idbuspro="903" termin="kaputt" bis="08.02.2027" code="SBW270301">
|
||||
<text>Kaputtes Datum</text>
|
||||
<hotel idbuspro="1" code="H1"/>
|
||||
<hotel idbuspro="2" code="H2"/>
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<reisen>
|
||||
<reise code="SBW">
|
||||
<termin id="1" idbuspro="900" termin="26.12.2026" bis="30.12.2026" code="SBW261226">
|
||||
<text>Zweiter Treffer</text>
|
||||
<hotel idbuspro="1" code="H1"/>
|
||||
</termin>
|
||||
</reise>
|
||||
</reisen>
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
use App\BusProNet\XmlLoader\PickupLoader;
|
||||
use App\BusProNet\XmlParser\AgeConstraintParserRegistry;
|
||||
use App\BusProNet\XmlParser\TravelParser;
|
||||
use App\Model\TravelExport;
|
||||
use App\Service\TravelExportBuilder;
|
||||
use League\Flysystem\DirectoryListing;
|
||||
use League\Flysystem\FileAttributes;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
|
||||
class TravelExportBuilderTest extends TestCase
|
||||
{
|
||||
public function testEmitsOneRowPerTravelDateAndHotelPair(): void
|
||||
{
|
||||
$rows = $this->build()->rows;
|
||||
|
||||
$keys = array_map(
|
||||
static fn (array $row): string => $row['busProId'].'-'.$row['hotel']['busProId'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
self::assertSame(['900-1', '900-2', '901-1'], $keys);
|
||||
self::assertSame($keys, array_unique($keys), 'the pair the consumer upserts on must be unique export-wide');
|
||||
}
|
||||
|
||||
public function testCarriesTravelAndHotelMasterData(): void
|
||||
{
|
||||
$row = $this->rowFor('900-1');
|
||||
|
||||
self::assertSame(900, $row['busProId']);
|
||||
self::assertIsInt($row['busProId']);
|
||||
self::assertSame('SBW261226', $row['code']);
|
||||
self::assertSame('Semesterende - Portes du Soleil', $row['product'], 'the product label is trimmed');
|
||||
self::assertSame('2026-12-26', $row['dateFrom']);
|
||||
self::assertSame('2026-12-30', $row['dateTo']);
|
||||
|
||||
self::assertSame([
|
||||
'busProId' => 1,
|
||||
'code' => 'H1',
|
||||
'name' => 'Hotel Eins',
|
||||
'costUnit' => '84 85',
|
||||
'country' => 'CH',
|
||||
'countryCode' => 'ch',
|
||||
], $row['hotel']);
|
||||
}
|
||||
|
||||
public function testResolvesBothZeitShapesAndRollsAnOvernightPickupOntoTheNextDay(): void
|
||||
{
|
||||
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
||||
|
||||
self::assertSame('23:59', $pickups[10]['time']);
|
||||
self::assertSame('2026-12-26T23:59:00+01:00', $pickups[10]['datetime']);
|
||||
|
||||
// Time only, so it defaults to the departure date -- which would put it before the
|
||||
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
|
||||
self::assertSame('02:15', $pickups[11]['time']);
|
||||
self::assertSame('2026-12-27T02:15:00+01:00', $pickups[11]['datetime']);
|
||||
}
|
||||
|
||||
public function testEmitsTheSummerOffset(): void
|
||||
{
|
||||
$pickups = $this->rowFor('901-1')['pickups'];
|
||||
|
||||
self::assertSame('2027-07-15T08:00:00+02:00', $pickups[0]['datetime']);
|
||||
}
|
||||
|
||||
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
|
||||
{
|
||||
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
|
||||
|
||||
self::assertSame('2027-07-15T09:30:00+02:00', $pickups[11]['datetime'], 'no roll when the order already holds');
|
||||
}
|
||||
|
||||
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
|
||||
{
|
||||
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
||||
|
||||
self::assertArrayHasKey(12, $pickups);
|
||||
self::assertNull($pickups[12]['time']);
|
||||
self::assertNull($pickups[12]['datetime']);
|
||||
self::assertSame('Z12', $pickups[12]['code'], 'master data still applies to an entry with no time');
|
||||
}
|
||||
|
||||
public function testKeepsAPickupMissingFromMasterDataAndCountsIt(): void
|
||||
{
|
||||
$export = $this->build();
|
||||
$pickups = array_column($export->rows[0]['pickups'], null, 'busProId');
|
||||
|
||||
// Dropping it would hide a real departure point from the disposition UI.
|
||||
self::assertArrayHasKey(99, $pickups);
|
||||
self::assertNull($pickups[99]['code']);
|
||||
self::assertNull($pickups[99]['city']);
|
||||
self::assertNull($pickups[99]['street']);
|
||||
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
|
||||
self::assertSame('2026-12-26T21:00:00+01:00', $pickups[99]['datetime']);
|
||||
|
||||
// Counted once, not once per row that references it.
|
||||
self::assertSame(1, $export->pickupsWithoutMasterData);
|
||||
}
|
||||
|
||||
public function testPickupsKeepSourceOrder(): void
|
||||
{
|
||||
$order = array_column($this->rowFor('900-1')['pickups'], 'busProId');
|
||||
|
||||
self::assertSame([10, 11, 12, 99], $order, 'source order, never sorted by time');
|
||||
}
|
||||
|
||||
public function testPickupsAreAnEmptyArrayRatherThanNullWhenThereAreNone(): void
|
||||
{
|
||||
$rows = $this->build('<?xml version="1.0"?><reisen><reise><termin idbuspro="1" termin="01.01.2027" bis="02.01.2027"><hotel idbuspro="1"/></termin></reise></reisen>')->rows;
|
||||
|
||||
self::assertSame([], $rows[0]['pickups']);
|
||||
}
|
||||
|
||||
public function testDropsAndCountsRowsWhoseHotelIsMissingFromMasterData(): void
|
||||
{
|
||||
$export = $this->build();
|
||||
|
||||
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 902 === $r['busProId']));
|
||||
// One hotel on travel 902, two on the travel with the unparseable date.
|
||||
self::assertSame(3, $export->skipped);
|
||||
}
|
||||
|
||||
public function testDropsEveryRowOfATravelWhoseDateCannotBeParsed(): void
|
||||
{
|
||||
$export = $this->build();
|
||||
|
||||
self::assertSame([], array_filter($export->rows, static fn (array $r): bool => 903 === $r['busProId']));
|
||||
}
|
||||
|
||||
public function testCollapsesADuplicatePairLastWins(): void
|
||||
{
|
||||
$export = $this->build(null, ['Ziel_1.xml', 'Ziel_2.xml']);
|
||||
|
||||
$matching = array_values(array_filter(
|
||||
$export->rows,
|
||||
static fn (array $r): bool => 900 === $r['busProId'] && 1 === $r['hotel']['busProId'],
|
||||
));
|
||||
|
||||
self::assertCount(1, $matching);
|
||||
self::assertSame('Zweiter Treffer', $matching[0]['product']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowFor(string $key): array
|
||||
{
|
||||
foreach ($this->build()->rows as $row) {
|
||||
if ($row['busProId'].'-'.$row['hotel']['busProId'] === $key) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
self::fail('No row for '.$key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport
|
||||
{
|
||||
$contents = [
|
||||
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
|
||||
'Ziel_2.xml' => (string) file_get_contents(__DIR__.'/../Resources/travel_export_data_duplicate.xml'),
|
||||
];
|
||||
|
||||
$filesystem = $this->createStub(FilesystemOperator::class);
|
||||
$filesystem->method('listContents')->willReturn(new DirectoryListing(array_map(
|
||||
static fn (string $path): FileAttributes => new FileAttributes($path),
|
||||
$files,
|
||||
)));
|
||||
$filesystem->method('read')->willReturnCallback(
|
||||
static fn (string $path): string => $contents[$path] ?? ''
|
||||
);
|
||||
|
||||
$hotelLoader = $this->createStub(HotelLoader::class);
|
||||
$hotelLoader->method('loadAll')->willReturn([
|
||||
1 => $this->hotel(1, 'H1', 'Hotel Eins', '84 85', 'CH', 'ch'),
|
||||
2 => $this->hotel(2, 'H2', 'Hotel Zwei', null, 'D', 'de'),
|
||||
]);
|
||||
|
||||
$pickupLoader = $this->createStub(PickupLoader::class);
|
||||
$pickupLoader->method('loadAll')->willReturn([
|
||||
10 => $this->pickup(10),
|
||||
11 => $this->pickup(11),
|
||||
12 => $this->pickup(12),
|
||||
]);
|
||||
|
||||
return (new TravelExportBuilder(
|
||||
$hotelLoader,
|
||||
$pickupLoader,
|
||||
new TravelParser(new AgeConstraintParserRegistry([]), false),
|
||||
$filesystem,
|
||||
new TagAwareAdapter(new ArrayAdapter()),
|
||||
new NullLogger(),
|
||||
))->build();
|
||||
}
|
||||
|
||||
private function hotel(int $id, string $code, string $name, ?string $costUnit, string $country, string $countryCode): Hotel
|
||||
{
|
||||
$hotel = new Hotel();
|
||||
$hotel->id = $id;
|
||||
$hotel->code = $code;
|
||||
$hotel->name = $name;
|
||||
$hotel->costUnit = $costUnit;
|
||||
$hotel->country = $country;
|
||||
$hotel->countryCode = $countryCode;
|
||||
|
||||
return $hotel;
|
||||
}
|
||||
|
||||
private function pickup(int $id): Pickup
|
||||
{
|
||||
$pickup = new Pickup();
|
||||
$pickup->id = $id;
|
||||
$pickup->code = 'Z'.$id;
|
||||
$pickup->city = 'Stadt '.$id;
|
||||
$pickup->street = 'Strasse '.$id;
|
||||
|
||||
return $pickup;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user