diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02c45fe6d..a27550789 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -698,7 +698,7 @@ jobs: working-directory: ansible/tests run: >- python3 -B -m unittest -v - test_topology test_deployment_maintenance + test_topology test_deployment_maintenance test_ssh_host_trust - name: Validate production and development Compose files run: | diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 63ebd7189..ffd41bd27 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -289,6 +289,8 @@ jobs: VPS_USER: ${{ secrets.VPS_USER }} VPS_APP_DIR: ${{ vars.VPS_APP_DIR }} VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_SSH_KNOWN_HOSTS: ${{ secrets.VPS_SSH_KNOWN_HOSTS }} + ANSIBLE_HOST_KEY_CHECKING: "true" VPS_HEALTHCHECK_URL: ${{ vars.VPS_HEALTHCHECK_URL }} VPS_HEALTHCHECK_VALIDATE_CERTS: ${{ vars.VPS_HEALTHCHECK_VALIDATE_CERTS || 'true' }} GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.actor }} @@ -375,7 +377,7 @@ jobs: exit 1 fi - for value in VPS_HOST VPS_USER VPS_SSH_KEY IMAGE_REF VPS_APP_DIR COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL; do + for value in VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS IMAGE_REF VPS_APP_DIR COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL; do test -n "${!value}" || { echo "$value is required."; exit 1; } done @@ -389,13 +391,39 @@ jobs: test -n "$FIXTURE_IMAGE_REF" || { echo "FIXTURE_IMAGE_REF is required to reset development fixtures."; exit 1; } fi + - name: Configure reviewed SSH host trust + shell: bash + run: | + set -eu + known_hosts="$RUNNER_TEMP/fireguard-api-known_hosts" + matching_keys="$known_hosts.match" + trap 'rm -f "$matching_keys"' EXIT + case "$VPS_PORT" in + ''|*[!0-9]*) echo "VPS_PORT must be a valid port."; exit 1 ;; + esac + if [ "${#VPS_PORT}" -gt 5 ]; then + echo "VPS_PORT must be a valid port."; exit 1 + fi + port=$((10#$VPS_PORT)) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "VPS_PORT must be a valid port."; exit 1 + fi + test -n "$VPS_SSH_KNOWN_HOSTS" || { echo "VPS_SSH_KNOWN_HOSTS is required."; exit 1; } + printf '%s\n' "$VPS_SSH_KNOWN_HOSTS" > "$known_hosts" + chmod 600 "$known_hosts" + host_lookup="$VPS_HOST" + if [ "$port" -ne 22 ]; then host_lookup="[$VPS_HOST]:$port"; fi + ssh-keygen -F "$host_lookup" -f "$known_hosts" > "$matching_keys" || + { echo "No approved SSH host key matches the deployment target."; exit 1; } + ssh-keygen -l -f "$matching_keys" > /dev/null 2>&1 || + { echo "The approved SSH host key material is invalid."; exit 1; } + printf 'VPS_PORT=%s\n' "$port" >> "$GITHUB_ENV" + - name: Configure SSH run: | install -m 700 -d ~/.ssh printf '%s\n' "$VPS_SSH_KEY" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - name: Install Ansible run: | @@ -411,4 +439,6 @@ jobs: } > .deploy/inventory.ini - name: Run Ansible deployment - run: ansible-playbook -i .deploy/inventory.ini ansible/deploy.yml + run: | + export ANSIBLE_SSH_COMMON_ARGS="-o UserKnownHostsFile=\"$RUNNER_TEMP/fireguard-api-known_hosts\" -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes" + ansible-playbook -i .deploy/inventory.ini ansible/deploy.yml diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6c9499097..2bda2fc85 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -96,8 +96,18 @@ have the same value. Deployment secrets: - `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` +- `VPS_SSH_KNOWN_HOSTS`: complete approved OpenSSH known_hosts entries for this + environment, verified through an authenticated operator/provider channel. - `GHCR_TOKEN` if the workflow token is insufficient +SSH deployment fails before Ansible when the reviewed host-key secret is absent, +invalid or does not match the configured host and port. Port 22 uses the bare host; +other ports use `[host]:port`, including IPv6. Hashed entries and multiple approved +keys are supported. Strict checking uses only this reviewed file; the workflow +never obtains trust from a network scan. For a host-key rotation, verify the new +key independently and update the environment secret before redeploying. Existing +image rollbacks use the same current workflow trust gate. + Application secrets specific to each environment: - `APP_SECRET` diff --git a/ansible/tests/test_ssh_host_trust.py b/ansible/tests/test_ssh_host_trust.py new file mode 100644 index 000000000..92aa20541 --- /dev/null +++ b/ansible/tests/test_ssh_host_trust.py @@ -0,0 +1,159 @@ +"""Offline tests of the actual deployment host-trust shell step.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/deploy-vps.yml" +DEPLOYMENT = "api" + + +def shell_path(path): + value = Path(path).as_posix() + if os.name == "nt" and len(value) > 1 and value[1] == ":": + return "/" + value[0].lower() + value[2:] + return value + + +def executable(name): + if os.name == "nt": + candidate = Path("C:/Program Files/Git/usr/bin") / (name + ".exe") + if candidate.is_file(): + return str(candidate) + result = shutil.which(name) + if not result: + raise RuntimeError(name + " is required for host-trust regressions") + return result + + +class ReviewedSshHostTrustTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="fireguard ssh trust ") + cls.directory = Path(cls.temporary.name) + cls.shell = executable("bash") + cls.keygen = executable("ssh-keygen") + cls.key = cls.directory / "fixture-key" + subprocess.run( + [cls.keygen, "-q", "-t", "ed25519", "-N", "", "-f", shell_path(cls.key)], + check=True, capture_output=True, + ) + cls.public_key = (cls.directory / "fixture-key.pub").read_text().strip() + workflow = WORKFLOW.read_text() + step = workflow.split(" - name: Configure reviewed SSH host trust\n", 1)[1] + step = step.split(" - name: ", 1)[0] + cls.script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def run_step(self, material, host="vps.example.invalid", port="22"): + with tempfile.TemporaryDirectory(dir=self.directory) as directory: + temporary = Path(directory) + environment = os.environ.copy() + environment.update({ + "VPS_HOST": host, + "VPS_PORT": port, + "VPS_SSH_KNOWN_HOSTS": material, + "RUNNER_TEMP": shell_path(temporary), + "GITHUB_ENV": shell_path(temporary / "github-env"), + }) + environment["PATH"] = str(Path(self.keygen).parent) + os.pathsep + environment["PATH"] + result = subprocess.run( + [self.shell, "-c", self.script], env=environment, + capture_output=True, text=True, timeout=10, + ) + installed = temporary / ("fireguard-" + DEPLOYMENT + "-known_hosts") + saved = installed.read_text() if installed.exists() else None + canonical = (temporary / "github-env").read_text() if (temporary / "github-env").exists() else None + return result, saved, canonical + + def test_approved_hosts_and_ports(self): + cases = [ + ("vps.example.invalid", "22", "vps.example.invalid"), + ("192.0.2.10", "22", "192.0.2.10"), + ("2001:db8::1", "22", "2001:db8::1"), + ("vps.example.invalid", "2222", "[vps.example.invalid]:2222"), + ("2001:db8::1", "2222", "[2001:db8::1]:2222"), + ("vps.example.invalid", "00022", "vps.example.invalid"), + ] + for host, port, lookup in cases: + with self.subTest(host=host, port=port): + material = lookup + " " + self.public_key + result, installed, canonical = self.run_step(material, host, port) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(material + "\n", installed) + self.assertEqual("VPS_PORT=" + str(int(port)) + "\n", canonical) + + def test_missing_blank_unrelated_and_wrong_port_are_rejected(self): + for material in ["", " \n\t", "other.example.invalid " + self.public_key, + "[vps.example.invalid]:2222 " + self.public_key]: + with self.subTest(material=material[:24]): + result, _, canonical = self.run_step(material) + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_malformed_matching_key_is_rejected(self): + result, _, canonical = self.run_step("vps.example.invalid ssh-ed25519 not-a-key") + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_invalid_ports_are_rejected(self): + for port in ["", "0", "65536", "100000", "-1", "22x"]: + with self.subTest(port=port): + result, _, canonical = self.run_step("vps.example.invalid " + self.public_key, port=port) + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_hashed_host_and_approved_aliases_are_preserved(self): + entry = self.directory / "hashed-hosts" + entry.write_text("vps.example.invalid " + self.public_key + "\n") + subprocess.run([self.keygen, "-H", "-f", shell_path(entry)], check=True, capture_output=True) + material = entry.read_text().strip() + result, installed, _ = self.run_step(material) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(material + "\n", installed) + aliases = "vps.example.invalid,192.0.2.10 " + self.public_key + result, installed, _ = self.run_step(aliases) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(aliases + "\n", installed) + + def test_workflow_requires_independent_trust_and_strict_handshake(self): + workflow = WORKFLOW.read_text() + self.assertNotIn("ssh-keyscan", workflow) + self.assertIn("secrets.VPS_SSH_KNOWN_HOSTS", workflow) + self.assertIn("StrictHostKeyChecking=yes", workflow) + self.assertIn("GlobalKnownHostsFile=/dev/null", workflow) + self.assertRegex(workflow, r"ANSIBLE_HOST_KEY_CHECKING[^\n]*(?:true|True)") + self.assertLess(workflow.index("Configure reviewed SSH host trust"), workflow.index("ansible-playbook")) + if DEPLOYMENT == "api": + self.assertIn('UserKnownHostsFile=\\\"$RUNNER_TEMP/fireguard-api-known_hosts\\\"', workflow) + else: + self.assertIn("/fireguard-web-known_hosts", workflow) + + def test_missing_router_identity_still_stops_before_deployment(self): + step = WORKFLOW.read_text().split(" - name: Validate required deployment configuration\n", 1)[1] + script = textwrap.dedent(step.split(" run: |\n", 1)[1].split(" - name: ", 1)[0]) + required = ( + "VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS IMAGE_REF VPS_APP_DIR " + "COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST " + "TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL" + ).split() + environment = os.environ.copy() + environment.update({name: "fixture" for name in required}) + environment.update({"SOURCE_BRANCH": "main", "DEPLOY_ENVIRONMENT": "production", "RESET_DEVELOPMENT_FIXTURES": "false"}) + environment.pop("TRAEFIK_API_ROUTER_NAME", None) + result = subprocess.run([self.shell, "-c", script], env=environment, capture_output=True, text=True, timeout=10) + self.assertNotEqual(0, result.returncode) + self.assertIn("TRAEFIK_API_ROUTER_NAME is required", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/config/modules/intervention.yaml b/config/modules/intervention.yaml index 42aa166aa..b0847e8b3 100644 --- a/config/modules/intervention.yaml +++ b/config/modules/intervention.yaml @@ -104,6 +104,12 @@ services: Intervention\Application\UseCase\Query\Time\ListTimeEntries\ListTimeEntriesHandler: tags: ['messenger.message_handler'] + Intervention\Application\UseCase\Query\Time\ListTimeEntryVersions\ListTimeEntryVersionsHandler: + tags: ['messenger.message_handler'] + + Intervention\Application\UseCase\Query\Time\GetTimeEntry\GetTimeEntryHandler: + tags: ['messenger.message_handler'] + # Bind the rich-text comment sanitizer (Quill / PrimeNG editor output). Intervention\Presentation\Api\Processor\InterventionActivityProcessor: arguments: diff --git a/config/modules/inventory.yaml b/config/modules/inventory.yaml index d60458e5b..1c5431680 100644 --- a/config/modules/inventory.yaml +++ b/config/modules/inventory.yaml @@ -42,3 +42,10 @@ services: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter: ~ Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort: alias: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter + + Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionHistoryAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Inventory\Application\Port\Inbound\InventoryInterventionHistoryPort: + alias: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionHistoryAdapter diff --git a/config/modules/maintenance.yaml b/config/modules/maintenance.yaml index 5e667eb15..cd99fe529 100644 --- a/config/modules/maintenance.yaml +++ b/config/modules/maintenance.yaml @@ -80,6 +80,13 @@ services: Maintenance\Application\Port\Inbound\MaintenanceOperationResultsPort: alias: Maintenance\Application\Service\MaintenancePlanAuthorityService + Maintenance\Infrastructure\Adapter\Intervention\MaintenanceInterventionHistoryAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + Maintenance\Application\Port\Inbound\MaintenanceInterventionHistoryPort: + alias: Maintenance\Infrastructure\Adapter\Intervention\MaintenanceInterventionHistoryAdapter + Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort: alias: Maintenance\Infrastructure\Adapter\Equipment\MaintenanceOperationsDueAdapter diff --git a/config/modules/maintenance_cost.yaml b/config/modules/maintenance_cost.yaml index 2a9d490c0..4a1c6545b 100644 --- a/config/modules/maintenance_cost.yaml +++ b/config/modules/maintenance_cost.yaml @@ -31,6 +31,13 @@ services: MaintenanceCost\Application\Port\Inbound\MaintenanceCostReadPort: alias: MaintenanceCost\Infrastructure\Adapter\Reporting\MaintenanceCostReadAdapter + MaintenanceCost\Infrastructure\Adapter\Intervention\MaintenanceCostInterventionHistoryAdapter: + arguments: + $connection: '@doctrine.dbal.main_connection' + + MaintenanceCost\Application\Port\Inbound\MaintenanceCostInterventionHistoryPort: + alias: MaintenanceCost\Infrastructure\Adapter\Intervention\MaintenanceCostInterventionHistoryAdapter + maintenance_cost.main_transaction_manager: class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter arguments: diff --git a/config/modules/shared.yaml b/config/modules/shared.yaml index 31570ca9a..55881874c 100644 --- a/config/modules/shared.yaml +++ b/config/modules/shared.yaml @@ -91,6 +91,8 @@ services: Shared\Application\Port\Outbound\EventBusPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\MessengerEventBusAdapter' Shared\Application\Port\Outbound\FileStoragePort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\FlysystemFileStorageAdapter' + Shared\Application\Port\Outbound\SpreadsheetSafeTextPort: '@Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter' + Shared\Application\Port\Outbound\ImageInputValidationPort: '@Shared\Infrastructure\Image\ImageInputValidationAdapter' Shared\Application\Port\Outbound\LoggerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\LoggerAdapter' Shared\Application\Port\Outbound\MailerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\MailerAdapter' Shared\Application\Port\Outbound\TransactionManagerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter' diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index b0f43eab8..752c1707f 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -63,6 +63,7 @@ api_platform: # The prescribed fix for a handler that wants a status is a DOMAIN exception, # not an entry here. That work is cleanup, not a defect: nothing is broken. exception_to_status: + Shared\Application\Contract\Image\InvalidImageInputException: 422 Audit\Domain\Exception\AuditEventNotFoundException: 404 Workload\Domain\Exception\WorkloadNotFoundException: 404 Workload\Domain\Exception\WorkloadAccessDeniedException: 403 diff --git a/migrations/main/Version20261008121000.php b/migrations/main/Version20261008121000.php new file mode 100644 index 000000000..08000e4f6 --- /dev/null +++ b/migrations/main/Version20261008121000.php @@ -0,0 +1,67 @@ +addSql('ALTER TABLE procurement_suppliers ALTER code TYPE VARCHAR(80)'); + } + + /** + * Method down + * + * Restores the former bound only when every retained value still fits it. + * + * @access public + * + * @param Schema $schema the main schema + * + * @return void + */ + public function down(Schema $schema): void + { + // PostgreSQL refuses rollback while any retained reference exceeds the former limit. + $this->addSql('ALTER TABLE procurement_suppliers ALTER code TYPE VARCHAR(64)'); + } + // #endregion +} diff --git a/openapi.json b/openapi.json index b73feab8f..0c7828811 100644 --- a/openapi.json +++ b/openapi.json @@ -27092,6 +27092,48 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "InterventionTime page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "InterventionTime itemsPerPage", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "ownOnly", + "in": "query", + "description": "Limit entries and their total to the current member, including for time managers.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true } ] }, @@ -27206,6 +27248,94 @@ } }, "/api/intervention-work-items/{taskId}/time-entries/{entryId}": { + "get": { + "operationId": "intervention_time_get", + "tags": [ + "InterventionTime" + ], + "responses": { + "200": { + "description": "InterventionTime resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InterventionTime.TimeEntryOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a InterventionTime resource.", + "description": "Retrieves a InterventionTime resource.", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "entryId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, "delete": { "operationId": "intervention_time_cancel", "tags": [ @@ -27430,6 +27560,124 @@ } } }, + "/api/intervention-work-items/{taskId}/time-entries/{entryId}/versions": { + "get": { + "operationId": "intervention_time_versions", + "tags": [ + "InterventionTime" + ], + "responses": { + "200": { + "description": "InterventionTime resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InterventionTime.TimeEntryHistoryOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a InterventionTime resource.", + "description": "Retrieves a InterventionTime resource.", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "entryId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "beforeRevision", + "in": "query", + "description": "Exclusive revision cursor; omitted for the newest retained revisions.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "InterventionTime itemsPerPage", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + "style": "form", + "explode": true + } + ] + } + }, "/api/intervention-work-items": { "get": { "operationId": "intervention_work_item_list", @@ -47512,7 +47760,7 @@ "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Procurement.ChangePurchaseOrderInput-procurement.write" + "$ref": "#/components/schemas/Procurement.CreatePurchaseOrderInput-procurement.write" } } }, @@ -49113,7 +49361,7 @@ "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Procurement.ChangeSupplierInput-procurement.write" + "$ref": "#/components/schemas/Procurement.CreateSupplierInput-procurement.write" } } }, @@ -67074,6 +67322,45 @@ } } }, + "InterventionTime.TimeEntryHistoryOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Method __construct", + "type": "string" + }, + "versions": { + "description": "Method __construct", + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryVersionView.jsonld" + } + }, + "totalItems": { + "description": "Method __construct", + "type": "integer" + }, + "itemsPerPage": { + "description": "Method __construct", + "type": "integer" + }, + "nextBeforeRevision": { + "description": "Method __construct", + "type": [ + "integer", + "null" + ] + } + } + } + ], + "description": "InterventionTimeResource." + }, "InterventionTime.TimeEntryOutput.jsonld": { "allOf": [ { @@ -67112,6 +67399,25 @@ "items": { "$ref": "#/components/schemas/TimeEntryView.jsonld" } + }, + "totalItems": { + "description": "complete scoped entry count", + "type": "integer" + }, + "page": { + "description": "one-based requested journal page", + "type": "integer" + }, + "itemsPerPage": { + "description": "maximum entries in this response", + "type": "integer" + }, + "nextPage": { + "description": "next journal page, null when complete", + "type": [ + "integer", + "null" + ] } } } @@ -78136,7 +78442,7 @@ } } }, - "Procurement.ChangePurchaseOrderInput-procurement.write": { + "Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch": { "type": "object", "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { @@ -78173,7 +78479,7 @@ } } }, - "Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch": { + "Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch": { "type": "object", "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { @@ -78183,17 +78489,25 @@ "null" ] }, - "supplierId": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "code": { "type": [ "string", "null" ] }, - "lines": { + "email": { + "type": [ + "string", + "null" + ] + }, + "phone": { + "type": [ + "string", + "null" + ] + }, + "contacts": { "type": [ "array", "null" @@ -78210,35 +78524,41 @@ } } }, - "Procurement.ChangeSupplierInput-procurement.write": { + "Procurement.CreatePurchaseOrderInput-procurement.write": { "type": "object", "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { "name": { + "description": "Property name", "type": [ "string", "null" ] }, - "code": { - "type": [ - "string", - "null" - ] - }, - "email": { + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "phone": { + "supplierId": { + "format": "uuid", + "description": "Property supplierId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "contacts": { + "lines": { + "description": "Property lines", "type": [ "array", "null" @@ -78255,35 +78575,51 @@ } } }, - "Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch": { + "Procurement.CreateSupplierInput-procurement.write": { "type": "object", "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "properties": { "name": { + "description": "Property name", + "type": [ + "string", + "null" + ] + }, + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, "code": { + "description": "Property code", "type": [ "string", "null" ] }, "email": { + "description": "Property email", "type": [ "string", "null" ] }, "phone": { + "description": "Property phone", "type": [ "string", "null" ] }, "contacts": { + "description": "Property contacts", "type": [ "array", "null" @@ -80450,10 +80786,22 @@ "type": "string" }, "versions": { + "description": "bounded latest revision; older durable history is paged separately", "type": "array", "items": { "$ref": "#/components/schemas/TimeEntryVersionView.jsonld" } + }, + "totalVersions": { + "description": "complete durable revision count", + "type": "integer" + }, + "nextBeforeRevision": { + "description": "exclusive cursor for older retained revisions", + "type": [ + "integer", + "null" + ] } } }, diff --git a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php index 118545877..1f16622e2 100644 --- a/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php +++ b/src/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandler.php @@ -191,6 +191,10 @@ private function findSetupReplay(CreateEquipmentCommand $command): ?Equipment 'type' => $command->type, 'subType' => $command->subType, 'brand' => $command->brand, 'model' => $command->model, 'serialNumber' => $command->serialNumber, 'locationLabel' => $command->locationLabel, 'facility' => null !== $command->facilityId ? '/api/facilities/' . $command->facilityId : null, + 'name' => $command->name, + 'assetCode' => $command->assetCode, + 'criticality' => $command->criticality, + 'technicalProperties' => $command->technicalProperties, ]); if (null === $operation->resourceId) { return null; diff --git a/src/Equipment/MODULE.md b/src/Equipment/MODULE.md index d70b10956..908478bde 100644 --- a/src/Equipment/MODULE.md +++ b/src/Equipment/MODULE.md @@ -23,6 +23,11 @@ and creation event. Receipt/onboarding replays preserve their original result. Published resources can target published facilities only; a draft can also target another draft belonging to the same intervention. +Onboarding receipts compare the complete prepared equipment identity, including +`name`, `assetCode`, `criticality` and `technicalProperties`. An exact retry returns +the original equipment; changing any prepared identity field returns a conflict +before another quota debit or save. + `GET /api/organizations/{organizationId}/facilities/{facilityId}/equipment-summary` is Equipment-owned and requires Equipment read access. `includeDescendants` defaults to true. It returns `scope` (`subtree` or `direct`), `totalItems`, four @@ -317,6 +322,12 @@ seventh (`id`, `status`, `facilityId`, `facilityName`, `installedAt`, importer ignores. The frozen slice is asserted by `tests/Unit/Equipment/Presentation/Api/Service/EquipmentCsvWriterTest.php`. +Text cells protect formula prefixes, leading control whitespace and literal +apostrophes. The appended `_fireguard_text_encoding=apostrophe-v1` metadata lets +Import remove that protection before provisioning, retaining original formulas +and apostrophes as stored text while the downloaded CSV remains safe to open. +The first seven columns and their reimport meanings remain unchanged. + **QR label sheet (added 2026-08-28).** `GET .../equipment/labels` (`EXPORT_EQUIPMENT_LABELS`, on a dedicated `EquipmentLabelSheetResource` for the same route-collision reason as the CSV export: `labels` is a literal diff --git a/src/Equipment/Presentation/Api/Service/EquipmentCsvWriter.php b/src/Equipment/Presentation/Api/Service/EquipmentCsvWriter.php index 36a6e9efd..88e2d52f3 100644 --- a/src/Equipment/Presentation/Api/Service/EquipmentCsvWriter.php +++ b/src/Equipment/Presentation/Api/Service/EquipmentCsvWriter.php @@ -5,7 +5,9 @@ namespace Equipment\Presentation\Api\Service; use Equipment\Application\Contract\Export\EquipmentExportRow; +use Shared\Application\Port\Outbound\SpreadsheetSafeTextPort; +use function array_map; use function fputcsv; /** @@ -34,7 +36,7 @@ * * @author Valentin FORTIN */ -final class EquipmentCsvWriter +final readonly class EquipmentCsvWriter { // #region Constants /** @@ -66,9 +68,21 @@ final class EquipmentCsvWriter 'commissionedAt', 'createdAt', 'updatedAt', + SpreadsheetSafeTextPort::ENCODING_COLUMN, ]; // #endregion + // #region Constructor + /** + * Constructor + * + * @param SpreadsheetSafeTextPort $spreadsheetText reversible text-cell encoder + */ + public function __construct(private SpreadsheetSafeTextPort $spreadsheetText) + { + } + // #endregion + // #region Methods /** * Method write. @@ -85,10 +99,12 @@ final class EquipmentCsvWriter */ public function write(array $rows, $handle): void { - fputcsv($handle, self::HEADER, escape: '\\'); + fputcsv($handle, self::HEADER, escape: ''); foreach ($rows as $row) { - fputcsv($handle, $this->toRow($row), escape: '\\'); + $cells = array_map($this->spreadsheetText->encode(...), $this->toRow($row)); + $cells[] = SpreadsheetSafeTextPort::ENCODING_VERSION; + fputcsv($handle, $cells, escape: ''); } } diff --git a/src/Facility/MODULE.md b/src/Facility/MODULE.md index bbc9a8dc6..18d736a46 100644 --- a/src/Facility/MODULE.md +++ b/src/Facility/MODULE.md @@ -189,6 +189,12 @@ first seven would silently break the bulk import round trip. `tests/Unit/Facility/Presentation/Api/Service/FacilityCsvWriterTest.php` freezes the first-seven-columns ordering. +Text cells protect formula prefixes, leading control whitespace and literal +apostrophes. An appended `_fireguard_text_encoding=apostrophe-v1` column lets +Import restore their original values before provisioning. Coordinates and +`levelIndex` keep their plain numeric representation, including negative values. +The first seven columns and their reimport meanings remain unchanged. + A `FacilitiesExportedEvent` is dispatched after a successful export, carrying only the applied filter **names** (`filterKeys`), never their raw values. The Audit module wires it centrally to the `facility.list_exported` action — this diff --git a/src/Facility/Presentation/Api/Service/FacilityCsvWriter.php b/src/Facility/Presentation/Api/Service/FacilityCsvWriter.php index 37aedadfc..594fcba42 100644 --- a/src/Facility/Presentation/Api/Service/FacilityCsvWriter.php +++ b/src/Facility/Presentation/Api/Service/FacilityCsvWriter.php @@ -5,7 +5,9 @@ namespace Facility\Presentation\Api\Service; use Facility\Application\Contract\Export\FacilityExportRow; +use Shared\Application\Port\Outbound\SpreadsheetSafeTextPort; +use function array_map; use function fputcsv; /** @@ -31,7 +33,7 @@ * * @author Valentin FORTIN */ -final class FacilityCsvWriter +final readonly class FacilityCsvWriter { // #region Constants /** @@ -58,9 +60,21 @@ final class FacilityCsvWriter 'createdAt', 'updatedAt', 'levelIndex', + SpreadsheetSafeTextPort::ENCODING_COLUMN, ]; // #endregion + // #region Constructor + /** + * Constructor + * + * @param SpreadsheetSafeTextPort $spreadsheetText reversible text-cell encoder + */ + public function __construct(private SpreadsheetSafeTextPort $spreadsheetText) + { + } + // #endregion + // #region Methods /** * Method write. @@ -74,10 +88,17 @@ final class FacilityCsvWriter */ public function write(array $rows, $handle): void { - fputcsv($handle, self::HEADER, escape: '\\'); + fputcsv($handle, self::HEADER, escape: ''); foreach ($rows as $row) { - fputcsv($handle, $this->toRow($row), escape: '\\'); + $original = $this->toRow($row); + $cells = array_map($this->spreadsheetText->encode(...), $original); + // Coordinates and floor levels are typed numeric values, including negatives. + foreach ([4, 5, 11] as $numericColumn) { + $cells[$numericColumn] = $original[$numericColumn]; + } + $cells[] = SpreadsheetSafeTextPort::ENCODING_VERSION; + fputcsv($handle, $cells, escape: ''); } } diff --git a/src/Import/Infrastructure/Csv/CsvRowStreamer.php b/src/Import/Infrastructure/Csv/CsvRowStreamer.php index 2f6eb4d66..eacdd1f66 100644 --- a/src/Import/Infrastructure/Csv/CsvRowStreamer.php +++ b/src/Import/Infrastructure/Csv/CsvRowStreamer.php @@ -8,8 +8,10 @@ use Import\Application\Port\Outbound\CsvRowStreamerPort; use Import\Infrastructure\Exception\CsvStreamOpenException; use InvalidArgumentException; +use Shared\Application\Port\Outbound\SpreadsheetSafeTextPort; use function array_map; +use function array_search; use function fclose; use function fgetcsv; use function fopen; @@ -75,8 +77,10 @@ * @since 1.0.0 * * @param int $maxRows the maximum number of data rows accepted + * @param SpreadsheetSafeTextPort $spreadsheetText decoder for explicitly marked exports */ public function __construct( + private SpreadsheetSafeTextPort $spreadsheetText, private int $maxRows = self::DEFAULT_MAX_ROWS, ) { } @@ -128,9 +132,11 @@ public function rows(string $contents): Generator /** @var list $header */ $header = array_map(static fn (?string $column): string => trim($column ?? ''), $header); + $encodingColumn = array_search(SpreadsheetSafeTextPort::ENCODING_COLUMN, $header, true); + $escape = false === $encodingColumn ? '\\' : ''; $rowNumber = 0; - while (false !== ($row = fgetcsv($stream, 0, $delimiter, escape: '\\'))) { + while (false !== ($row = fgetcsv($stream, 0, $delimiter, escape: $escape))) { if ([null] === $row) { // A fully blank line: fgetcsv() reports it as [null]. continue; @@ -222,13 +228,17 @@ private function detectDelimiter($stream): string */ private function combine(array $header, array $row): array { + $encodingColumn = array_search(SpreadsheetSafeTextPort::ENCODING_COLUMN, $header, true); + $encoded = false !== $encodingColumn + && SpreadsheetSafeTextPort::ENCODING_VERSION === ($row[$encodingColumn] ?? ''); $values = []; foreach ($header as $index => $column) { - if ('' === $column) { + if ('' === $column || SpreadsheetSafeTextPort::ENCODING_COLUMN === $column) { continue; } - $values[$column] = trim($row[$index] ?? ''); + $cell = $row[$index] ?? ''; + $values[$column] = $encoded ? $this->spreadsheetText->decode($cell) : trim($cell); } return $values; diff --git a/src/Import/MODULE.md b/src/Import/MODULE.md index 352374add..162c0b853 100644 --- a/src/Import/MODULE.md +++ b/src/Import/MODULE.md @@ -73,6 +73,13 @@ filters rows before pagination and the total count. An explicit unauthorized kin Comma/semicolon delimiters and UTF-8 BOM are supported. Unknown columns are ignored. The 5000-data-row limit is counted before provisioning. +Ordinary equipment/facility exports append `_fireguard_text_encoding=apostrophe-v1` +to identify spreadsheet-safe text. Only rows with that exact marker are decoded, +once, before their provisioning factories run; literal apostrophes and formula-like +business text survive reimport. Marked rows use RFC4180 escaping and retain original +cell whitespace until existing factory normalization. Unmarked imports retain their +historical trimming and literal apostrophes, without spreadsheet decoding. + | Kind | Columns | | --------- | -------------------------------------------------------------------------------------------- | | equipment | type (required), subType, brand, model, serialNumber, locationLabel, facilityCode | diff --git a/src/Intervention/Application/Contract/Time/TimeEntryView.php b/src/Intervention/Application/Contract/Time/TimeEntryView.php index cac5cbcfe..f374c008d 100644 --- a/src/Intervention/Application/Contract/Time/TimeEntryView.php +++ b/src/Intervention/Application/Contract/Time/TimeEntryView.php @@ -29,7 +29,9 @@ * @param string $updatedBy member who authored the latest version * @param string $createdAt timestamp when the resource was created * @param string $updatedAt timestamp of the latest persisted version - * @param list $versions + * @param list $versions bounded latest revision; older durable history is paged separately + * @param int $totalVersions complete durable revision count + * @param ?int $nextBeforeRevision exclusive cursor for older retained revisions */ public function __construct( public string $id, @@ -44,7 +46,9 @@ public function __construct( public string $updatedBy, public string $createdAt, public string $updatedAt, - public array $versions, + public array $versions = [], + public int $totalVersions = 0, + public ?int $nextBeforeRevision = null, ) { } } diff --git a/src/Intervention/Application/Port/Outbound/InterventionTimeEntryRepositoryPort.php b/src/Intervention/Application/Port/Outbound/InterventionTimeEntryRepositoryPort.php index ded65cee4..76015389e 100644 --- a/src/Intervention/Application/Port/Outbound/InterventionTimeEntryRepositoryPort.php +++ b/src/Intervention/Application/Port/Outbound/InterventionTimeEntryRepositoryPort.php @@ -27,13 +27,13 @@ interface InterventionTimeEntryRepositoryPort public function context(string $taskId, bool $lock = false): ?\Intervention\Application\Contract\Time\TimeEntryTaskContext; /** - * Finds a time entry together with its retained versions. + * Finds a current time entry without loading its complete retained history. * * @since 1.0.0 * * @param string $id stable resource identifier, retained across idempotent retries * - * @return ?\Intervention\Application\Contract\Time\TimeEntryView Persisted entry and retained revision history. Returns null when the entry does not exist. + * @return ?\Intervention\Application\Contract\Time\TimeEntryView current entry with one latest version and an older-history cursor, null when absent */ public function find(string $id): ?\Intervention\Application\Contract\Time\TimeEntryView; @@ -44,10 +44,67 @@ public function find(string $id): ?\Intervention\Application\Contract\Time\TimeE * * @param string $taskId intervention work-item identifier * @param ?string $memberId beneficiary filter; null includes all authorized contributions on the task + * @param int $page one-based journal page + * @param int $itemsPerPage maximum returned entries, from 1 to 100 * * @return list<\Intervention\Application\Contract\Time\TimeEntryView> */ - public function list(string $taskId, ?string $memberId): array; + public function list(string $taskId, ?string $memberId, int $page = 1, int $itemsPerPage = 30): array; + + /** + * Method count + * + * Counts the complete authorized journal without hydrating its entries. + * + * @access public + * + * @param string $taskId owning task identifier + * @param ?string $memberId beneficiary filter, null for the management scope + * + * @return int exact number of scoped entries, including cancellations + */ + public function count(string $taskId, ?string $memberId): int; + + /** + * Method versions + * + * Reads retained revisions newest first with a stable exclusive revision cursor. + * + * @access public + * + * @param string $entryId authorized entry identifier + * @param ?int $beforeRevision exclusive upper revision bound + * @param int $limit bounded window including at most one continuation probe + * + * @return list<\Intervention\Application\Contract\Time\TimeEntryVersionView> retained revision window + */ + public function versions(string $entryId, ?int $beforeRevision, int $limit): array; + + /** + * Method countVersions + * + * Counts retained history without loading its contents. + * + * @access public + * + * @param string $entryId authorized entry identifier + * + * @return int exact retained revision count + */ + public function countVersions(string $entryId): int; + + /** + * Method originalVersion + * + * Reads only the original revision needed for stable create-request replay. + * + * @access public + * + * @param string $entryId entry identifier + * + * @return ?\Intervention\Application\Contract\Time\TimeEntryVersionView original revision, null if unavailable + */ + public function originalVersion(string $entryId): ?\Intervention\Application\Contract\Time\TimeEntryVersionView; /** * Persists the independent entry and its audited version without changing the intervention revision. diff --git a/src/Intervention/Application/UseCase/Command/Time/WriteTimeEntry/WriteTimeEntryHandler.php b/src/Intervention/Application/UseCase/Command/Time/WriteTimeEntry/WriteTimeEntryHandler.php index 3e18ac01e..2f8fe2b0d 100644 --- a/src/Intervention/Application/UseCase/Command/Time/WriteTimeEntry/WriteTimeEntryHandler.php +++ b/src/Intervention/Application/UseCase/Command/Time/WriteTimeEntry/WriteTimeEntryHandler.php @@ -192,7 +192,7 @@ private function isCreateReplay(WriteTimeEntryCommand $command, TimeEntryView $e if ($existing->createdBy !== $actor || $existing->memberId !== ($command->memberId ?? $actor)) { return false; } - $original = $existing->versions[0] ?? null; + $original = $this->entries->originalVersion($existing->id); return null !== $original && $original->workedOn === $command->workedOn && $original->minutes === $command->minutes && $original->note === $command->note; } diff --git a/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandler.php b/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandler.php new file mode 100644 index 000000000..b14fced1f --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandler.php @@ -0,0 +1,66 @@ +entries->context($query->taskId); + if (null === $task) { + throw InterventionNotFoundException::withId($query->taskId); + } + $actor = $this->access->actor($task, $query->userId); + $entry = $this->entries->find($query->entryId); + if (null === $entry || $entry->workItemId !== $task->taskId || ($entry->memberId !== $actor && !$this->access->canManage($task, $query->userId))) { + throw InterventionNotFoundException::withId($query->entryId); + } + + return new GetTimeEntryResult($entry); + } + // #endregion +} diff --git a/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryQuery.php b/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryQuery.php new file mode 100644 index 000000000..81b0dd399 --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryQuery.php @@ -0,0 +1,36 @@ +access->actor($task, $query->userId); - return new ListTimeEntriesResult($this->entries->list($query->taskId, $this->access->canManage($task, $query->userId) ? null : $actor)); + $memberId = $query->ownOnly || !$this->access->canManage($task, $query->userId) ? $actor : null; + + return new ListTimeEntriesResult( + $this->entries->list($query->taskId, $memberId, $query->page, $query->itemsPerPage), + $this->entries->count($query->taskId, $memberId), + $query->page, + $query->itemsPerPage, + ); } } diff --git a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesQuery.php b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesQuery.php index 923fe954b..414a0393d 100644 --- a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesQuery.php +++ b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesQuery.php @@ -4,6 +4,12 @@ namespace Intervention\Application\UseCase\Query\Time\ListTimeEntries; +use InvalidArgumentException; + +use function intdiv; + +use const PHP_INT_MAX; + /** * ListTimeEntriesQuery. * @@ -19,8 +25,14 @@ * * @param string $userId authenticated account identifier used for authorization * @param string $taskId intervention work-item identifier + * @param int $page one-based journal page + * @param int $itemsPerPage maximum entries returned, from 1 to 100 + * @param bool $ownOnly limits the read to the caller's contributions even with management permission */ - public function __construct(public string $userId, public string $taskId) + public function __construct(public string $userId, public string $taskId, public int $page = 1, public int $itemsPerPage = 30, public bool $ownOnly = false) { + if ($page < 1 || $itemsPerPage < 1 || $itemsPerPage > 100 || $page > intdiv(PHP_INT_MAX, $itemsPerPage)) { + throw new InvalidArgumentException('Invalid time journal pagination.'); + } } } diff --git a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesResult.php b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesResult.php index de1fab81f..f0cdcd722 100644 --- a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesResult.php +++ b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesResult.php @@ -18,8 +18,11 @@ * @since 1.0.0 * * @param list<\Intervention\Application\Contract\Time\TimeEntryView> $entries + * @param int $totalItems exact scoped journal count + * @param int $page one-based requested page + * @param int $itemsPerPage maximum returned entries */ - public function __construct(public array $entries) + public function __construct(public array $entries, public int $totalItems, public int $page, public int $itemsPerPage) { } } diff --git a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandler.php b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandler.php new file mode 100644 index 000000000..0d5e327c9 --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandler.php @@ -0,0 +1,75 @@ +entries->context($query->taskId); + if (null === $task) { + throw InterventionNotFoundException::withId($query->taskId); + } + $actor = $this->access->actor($task, $query->userId); + $entry = $this->entries->find($query->entryId); + if (null === $entry || $entry->workItemId !== $task->taskId || ($entry->memberId !== $actor && !$this->access->canManage($task, $query->userId))) { + throw InterventionNotFoundException::withId($query->entryId); + } + $versions = $this->entries->versions($entry->id, $query->beforeRevision, $query->itemsPerPage + 1); + $hasMore = count($versions) > $query->itemsPerPage; + if ($hasMore) { + array_pop($versions); + } + $next = $hasMore ? $versions[count($versions) - 1]->revision : null; + + return new ListTimeEntryVersionsResult($versions, $this->entries->countVersions($entry->id), $query->itemsPerPage, $next); + } + // #endregion +} diff --git a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsQuery.php b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsQuery.php new file mode 100644 index 000000000..6ea4e06b0 --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsQuery.php @@ -0,0 +1,42 @@ + 100 || (null !== $beforeRevision && $beforeRevision < 1)) { + throw new InvalidArgumentException('Invalid time history pagination.'); + } + } + // #endregion +} diff --git a/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsResult.php b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsResult.php new file mode 100644 index 000000000..c385e0d5f --- /dev/null +++ b/src/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsResult.php @@ -0,0 +1,38 @@ + $versions newest-first retained revisions + * @param int $totalItems complete retained count + * @param int $itemsPerPage requested page size + * @param ?int $nextBeforeRevision exclusive continuation cursor, null at the end + * + * @return void + */ + public function __construct(public array $versions, public int $totalItems, public int $itemsPerPage, public ?int $nextBeforeRevision) + { + } + // #endregion +} diff --git a/src/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepository.php b/src/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepository.php index 1261a3d8a..001c43bf7 100644 --- a/src/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepository.php +++ b/src/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepository.php @@ -11,9 +11,13 @@ use Intervention\Application\Port\Outbound\InterventionTimeEntryRepositoryPort; use Intervention\Domain\Model\TimeEntry\TimeEntry; use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionTimeEntryRecord, InterventionTimeEntryVersionRecord, InterventionWorkItemAssignmentRecord, InterventionWorkItemRecord}; +use InvalidArgumentException; use LogicException; use function array_map; +use function intdiv; + +use const PHP_INT_MAX; /** * InterventionTimeEntryRepository. @@ -73,13 +77,13 @@ public function context(string $taskId, bool $lock = false): ?TimeEntryTaskConte } /** - * Finds a time entry together with its retained versions. + * Finds a current entry with one latest version and a cursor to its older history. * * @since 1.0.0 * * @param string $id stable resource identifier, retained across idempotent retries * - * @return ?TimeEntryView Persisted entry and retained revision history. Returns null when the entry does not exist. + * @return ?TimeEntryView current entry without a retained-history query, null when absent */ public function find(string $id): ?TimeEntryView { @@ -99,17 +103,112 @@ public function find(string $id): ?TimeEntryView * * @param string $taskId intervention work-item identifier * @param ?string $memberId beneficiary filter; null includes all authorized contributions on the task + * @param int $page one-based journal page + * @param int $itemsPerPage maximum hydrated entries, from 1 to 100 * * @return list */ - public function list(string $taskId, ?string $memberId): array + public function list(string $taskId, ?string $memberId, int $page = 1, int $itemsPerPage = 30): array { + if ($page < 1 || $itemsPerPage < 1 || $itemsPerPage > 100 || $page > intdiv(PHP_INT_MAX, $itemsPerPage)) { + throw new InvalidArgumentException('Invalid time journal pagination.'); + } $criteria = ['workItem' => $taskId]; if (null !== $memberId) { $criteria['memberId'] = $memberId; } - return array_map($this->view(...), $this->entityManager->getRepository(InterventionTimeEntryRecord::class)->findBy($criteria, ['workedOn' => 'DESC', 'id' => 'ASC'])); + return array_map($this->view(...), $this->entityManager->getRepository(InterventionTimeEntryRecord::class)->findBy($criteria, ['workedOn' => 'DESC', 'id' => 'ASC'], $itemsPerPage, ($page - 1) * $itemsPerPage)); + } + + /** + * Method count + * + * Counts all entries in the selected beneficiary scope without hydration. + * + * @access public + * + * @param string $taskId owning task + * @param ?string $memberId beneficiary filter, null for all authorized members + * + * @return int complete scoped entry count + */ + public function count(string $taskId, ?string $memberId): int + { + $criteria = ['workItem' => $taskId]; + if (null !== $memberId) { + $criteria['memberId'] = $memberId; + } + + return $this->entityManager->getRepository(InterventionTimeEntryRecord::class)->count($criteria); + } + + /** + * Method versions + * + * Uses the entry/revision primary key for a bounded descending history window. + * + * @access public + * + * @param string $entryId authorized entry + * @param ?int $beforeRevision exclusive revision cursor + * @param int $limit at most 101 rows including a continuation probe + * + * @return list retained history window + */ + public function versions(string $entryId, ?int $beforeRevision, int $limit): array + { + if ($limit < 1 || $limit > 101 || (null !== $beforeRevision && $beforeRevision < 1)) { + throw new InvalidArgumentException('Invalid time history pagination.'); + } + $query = $this->entityManager->createQueryBuilder() + ->select('version') + ->from(InterventionTimeEntryVersionRecord::class, 'version') + ->where('IDENTITY(version.entry) = :entry') + ->setParameter('entry', $entryId) + ->orderBy('version.revision', 'DESC') + ->setMaxResults($limit); + if (null !== $beforeRevision) { + $query->andWhere('version.revision < :before')->setParameter('before', $beforeRevision); + } + /** @var list $versions */ + $versions = $query->getQuery()->getResult(); + + return array_map($this->versionView(...), $versions); + } + + /** + * Method countVersions + * + * Returns a scalar count of all retained revisions. + * + * @access public + * + * @param string $entryId authorized entry + * + * @return int exact durable version count + */ + public function countVersions(string $entryId): int + { + return $this->entityManager->getRepository(InterventionTimeEntryVersionRecord::class)->count(['entry' => $entryId]); + } + + /** + * Method originalVersion + * + * Fetches one primary-key row for create idempotency regardless of journal age. + * + * @access public + * + * @param string $entryId stable entry identifier + * + * @return ?TimeEntryVersionView original persisted request + */ + public function originalVersion(string $entryId): ?TimeEntryVersionView + { + $version = $this->entityManager->find(InterventionTimeEntryVersionRecord::class, ['entry' => $entryId, 'revision' => 1]); + + return $version instanceof InterventionTimeEntryVersionRecord ? $this->versionView($version) : null; } /** @@ -160,7 +259,7 @@ public function save(TimeEntry $entry, string $organizationId, string $actorId): } /** - * Maps a persisted time entry and its versions into the published journal contract. + * Maps current scalar state without loading its durable history. * * @since 1.0.0 * @@ -170,7 +269,6 @@ public function save(TimeEntry $entry, string $organizationId, string $actorId): */ private function view(InterventionTimeEntryRecord $record): TimeEntryView { - $versions = $this->entityManager->getRepository(InterventionTimeEntryVersionRecord::class)->findBy(['entry' => $record], ['revision' => 'ASC']); $task = $record->workItem ?? throw new LogicException('Time entry task is missing.'); return new TimeEntryView( @@ -186,7 +284,25 @@ private function view(InterventionTimeEntryRecord $record): TimeEntryView $record->updatedBy, $record->createdAt->format('c'), $record->updatedAt->format('c'), - array_map(static fn (InterventionTimeEntryVersionRecord $v): TimeEntryVersionView => new TimeEntryVersionView($v->revision, $v->workedOn, $v->minutes, $v->note, $v->cancelled, $v->actorId, $v->recordedAt->format('c')), $versions), + [new TimeEntryVersionView($record->revision, $record->workedOn, $record->minutes, $record->note, $record->cancelled, $record->updatedBy, $record->updatedAt->format('c'))], + $record->revision, + $record->revision > 1 ? $record->revision : null, ); } + + /** + * Method versionView + * + * Maps one persisted immutable revision without loading its owning entry. + * + * @access private + * + * @param InterventionTimeEntryVersionRecord $version retained revision row + * + * @return TimeEntryVersionView transport-independent history view + */ + private function versionView(InterventionTimeEntryVersionRecord $version): TimeEntryVersionView + { + return new TimeEntryVersionView($version->revision, $version->workedOn, $version->minutes, $version->note, $version->cancelled, $version->actorId, $version->recordedAt->format('c')); + } } diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowInterventionWriter.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowInterventionWriter.php index 7e5610eb5..297de7506 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowInterventionWriter.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowInterventionWriter.php @@ -98,7 +98,7 @@ public function mutateIntervention(InterventionWorkflowMutation $mutation, array if (!in_array($intervention->status, ['draft', 'abandoned'], true)) { throw new InterventionConflictException('Only draft or abandoned interventions can be deleted.'); } - $this->runtime->support->assertNoTimeHistory($intervention); + $this->runtime->support->assertNoRetainedHistory($intervention); // Purge any still-draft resource records this intervention created before // removing it, so no orphaned drafts (and their unique client ids) survive. $this->draftPublisher->discard($intervention->id, false); diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php index d94ae67b6..bd910c8fa 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowMutationSupport.php @@ -26,6 +26,9 @@ }; use Intervention\Infrastructure\Persistence\Doctrine\Record\InterventionTimeEntryRecord; use InvalidArgumentException; +use Inventory\Application\Port\Inbound\InventoryInterventionHistoryPort; +use Maintenance\Application\Port\Inbound\MaintenanceInterventionHistoryPort; +use MaintenanceCost\Application\Port\Inbound\MaintenanceCostInterventionHistoryPort; use Organization\Application\Port\Inbound\OrganizationWorkforceDirectoryPort; use Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord; @@ -51,6 +54,9 @@ * @param InterventionMemberPolicy $memberPolicy policy for organization membership and intervention roles * @param InterventionResourceGatewayPort $resources port used to resolve linked resource ownership * @param OrganizationWorkforceDirectoryPort $workforce directory used to read organization and member context + * @param InventoryInterventionHistoryPort $inventory physical history and shared declaration fence + * @param MaintenanceCostInterventionHistoryPort $costs existence of retained financial references + * @param MaintenanceInterventionHistoryPort $maintenance retained occurrence references * * @return void */ @@ -59,6 +65,9 @@ public function __construct( private InterventionMemberPolicy $memberPolicy, private InterventionResourceGatewayPort $resources, private OrganizationWorkforceDirectoryPort $workforce, + private InventoryInterventionHistoryPort $inventory, + private MaintenanceCostInterventionHistoryPort $costs, + private MaintenanceInterventionHistoryPort $maintenance, private ?\Facility\Application\Port\Inbound\FacilityLifecycleReferencePort $facilities = null, private ?\Facility\Application\Port\Inbound\FacilityHierarchyPort $hierarchy = null, ) { @@ -407,6 +416,56 @@ public function assertNoTimeHistory(InterventionRecord $intervention, ?Intervent } } + /** + * Method lockRetentionFence + * + * Acquires the physical declaration fence before the parent lock, matching Inventory writers. + * + * @access public + * + * @param string $organizationId owning organization + * @param string $interventionId parent of the resource being deleted + * + * @return void + */ + public function lockRetentionFence(string $organizationId, string $interventionId): void + { + $this->inventory->lock($organizationId, $interventionId); + } + + /** + * Method assertNoRetainedHistory + * + * Checks owner-published references in the same main transaction under the parent lock. + * Task occurrence links remain retained when a retry moves the occurrence to newer work. + * + * @access public + * + * @param InterventionRecord $intervention locked operational parent + * @param ?InterventionWorkItemRecord $item optional locked task scope + * + * @return void + * + * @throws InterventionConflictException when deleting would orphan retained facts + */ + public function assertNoRetainedHistory(InterventionRecord $intervention, ?InterventionWorkItemRecord $item = null): void + { + $this->assertNoTimeHistory($intervention, $item); + $organizationId = $this->organizationId($intervention); + if ($this->costs->hasHistory($organizationId, $intervention->id, $item?->id) + || $this->inventory->hasHistory($organizationId, $intervention->id, $item?->id)) { + throw new InterventionConflictException('Financial and inventory history must be retained; this resource cannot be deleted.'); + } + $hasOccurrence = null !== $item + ? null !== $item->occurrenceId + : (int) $this->entityManager->createQueryBuilder()->select('COUNT(w.id)')->from(InterventionWorkItemRecord::class, 'w') + ->where('w.intervention = :intervention AND w.occurrenceId IS NOT NULL')->setParameter('intervention', $intervention) + ->getQuery()->getSingleScalarResult() > 0; + if ($hasOccurrence || (null === $item && $this->maintenance->hasHistory($organizationId, $intervention->id))) { + throw new InterventionConflictException('Preventive occurrence history must be retained; this resource cannot be deleted.'); + } + } + /** * Resolves the organization timezone or rejects an unknown organization. * diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php index e6beeffe2..a611221f5 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkItemWriter.php @@ -160,7 +160,7 @@ private function assertWorkItemMutationAllowed(InterventionWorkItemRecord $recor /** * Method deleteWorkItem * - * Deletes a prepared work item without time history and updates its parent timestamp. + * Deletes a prepared work item without retained facts and updates its parent timestamp. * * @access private * @@ -176,7 +176,7 @@ private function deleteWorkItem(InterventionWorkItemRecord $record, Intervention if ('draft' !== $intervention->status) { throw new InterventionConflictException('Only prepared work items can be deleted.'); } - $this->runtime->support->assertNoTimeHistory($intervention, $record); + $this->runtime->support->assertNoRetainedHistory($intervention, $record); $this->runtime->entityManager->remove($record); $this->runtime->support->touch($intervention, new DateTimeImmutable()); $this->runtime->entityManager->flush(); diff --git a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkloadCoordinator.php b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkloadCoordinator.php index 6b33ce920..c767542c7 100644 --- a/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkloadCoordinator.php +++ b/src/Intervention/Infrastructure/Service/Workflow/InterventionWorkflowWorkloadCoordinator.php @@ -71,7 +71,10 @@ public function prepareWorkloadMutation(InterventionWorkflowMutation $mutation): if (null === $context) { throw InterventionNotFoundException::withId($mutation->id ?? 'unknown'); } - // Parent first, sorted members next, task rows last, shared with time writes. + if ('delete' === $mutation->action) { + $this->support->lockRetentionFence($context->organizationId, $context->interventionId); + } + // Deletion fences first, parent next, sorted members then tasks, shared with time writes. $parent = $this->support->intervention($context->interventionId); $this->entityManager->refresh($parent); $members = []; diff --git a/src/Intervention/MODULE.md b/src/Intervention/MODULE.md index 214585846..9f94464b5 100644 --- a/src/Intervention/MODULE.md +++ b/src/Intervention/MODULE.md @@ -108,6 +108,19 @@ deleted while retained children, assignments or intervention resources still use them. The 409 `intervention_draft_dependencies` response provides typed dependency counts, without disclosing inaccessible resource identifiers. +Hard DELETE of a draft or abandoned intervention, or a prepared task, refuses with +409 when retained time, expense, financial preparation, +inventory declaration/movement or preventive occurrence references exist. The +check runs through owner-published Application ports in the main mutation +transaction. Deletion takes the Inventory intervention fence before the parent +row lock, so concurrent physical declarations cannot be orphaned. Expense writes +share the parent lock. Task occurrence identities remain retained after a retry +moves the current occurrence to a newer intervention. Empty drafts remain deletable. + +Retention regressions use real financial, physical and preventive bridges. Two +independent PostgreSQL sessions prove that DELETE waits for an uncommitted expense +or consumption, then refuses after the fact commits for both parent and task scopes. + Publication scheduling commits the publication row and its `main_outbox` command together. Execution commits all resource mutations, the completed status and its durable event in one main transaction. A failure rolls these writes back before @@ -195,7 +208,9 @@ independent of the requested page and filters. | Method | Path | Description | | ------ | ---------------------------------------------------------- | ----------------------------------------------------------------- | -| GET | `/intervention-work-items/{taskId}/time-entries` | Authorized entries and their retained revisions | +| GET | `/intervention-work-items/{taskId}/time-entries` | Authorized current entries, paginated with exact total and next page | +| GET | `/intervention-work-items/{taskId}/time-entries/{entryId}/versions` | Retained revisions, newest first with an exclusive revision cursor | +| GET | `/intervention-work-items/{taskId}/time-entries/{entryId}` | One authorized current entry, including conflict-review server values | | POST | `/intervention-work-items/{taskId}/time-entries` | Record actual work with a stable client entry identifier | | PATCH | `/intervention-work-items/{taskId}/time-entries/{entryId}` | Correct an entry against its own `If-Match` revision | | DELETE | `/intervention-work-items/{taskId}/time-entries/{entryId}` | Cancel an entry against its own revision without deleting history | @@ -209,6 +224,23 @@ the beneficiary, the acting author and every correction version. Cancelled time no longer contributes to actual totals but still prevents physical deletion of its task and parent intervention. +Journal reads accept a positive `page` (default 1) and `itemsPerPage` from 1 to 100 +(default 30), and return `totalItems` and nullable `nextPage`. Optional `ownOnly` +(default false) limits both the entries and exact total to the current member +before pagination, even when the caller has time management rights. Omitting it +or passing false never expands a caller's authorized beneficiary scope. Malformed +scope selectors return 400. Current reads and +mutation responses expose only the latest inline `versions` row, `totalVersions` +and nullable `nextBeforeRevision`; they never hydrate older history. The history +endpoint accepts `beforeRevision` (exclusive, omitted for newest) and the same +bounded `itemsPerPage`, and returns the complete retained `totalItems` and nullable +`nextBeforeRevision`. Newly appended versions do not shift an older-history cursor. +All durable versions remain reachable; no retention or deletion accompanies paging. +History uses the journal's beneficiary scope: foreign tasks and other members' entries +remain hidden with 404 unless the caller has time management rights. Malformed, +zero, negative or oversized pagination returns 400. Create replay reads only the +original revision needed to compare the original payload. + `organization.interventions.time.write` permits authorized contributors to record their own time; `organization.interventions.time.manage` is required to act for another member. Active organization membership and intervention contribution @@ -962,6 +994,11 @@ since `OrganizationMemberRecord` itself carries no display name). ### Export (CSV) +User-controlled names, facility labels and assignee names are protected against +spreadsheet formula interpretation, including prefixes after control whitespace. +The trailing `_fireguard_text_encoding=apostrophe-v1` metadata identifies reversible +text protection; callers requiring original values can decode the marked cells. + | Method | Path | Description | | ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | GET | `/interventions/export` | Streams a bounded CSV export (filters: `organization` _(required)_, `name`, `type`, `status`, `priority` _(multi-value, 400 on an unknown value — same guard as the list endpoint)_, `site`, `responsible`, `dueAtAfter`, `dueAtBefore`, `due=overdue`) | diff --git a/src/Intervention/Presentation/Api/Dto/Output/TimeEntryHistoryOutput.php b/src/Intervention/Presentation/Api/Dto/Output/TimeEntryHistoryOutput.php new file mode 100644 index 000000000..1e8ba844e --- /dev/null +++ b/src/Intervention/Presentation/Api/Dto/Output/TimeEntryHistoryOutput.php @@ -0,0 +1,39 @@ + $versions newest-first retained versions + * @param int $totalItems exact complete revision count + * @param int $itemsPerPage requested page limit + * @param ?int $nextBeforeRevision exclusive continuation cursor, null when complete + * + * @return void + */ + public function __construct(#[ApiProperty(identifier: true)] public string $id, public array $versions, public int $totalItems, public int $itemsPerPage, public ?int $nextBeforeRevision) + { + } + // #endregion +} diff --git a/src/Intervention/Presentation/Api/Dto/Output/TimeJournalOutput.php b/src/Intervention/Presentation/Api/Dto/Output/TimeJournalOutput.php index 54eb76991..94de60b35 100644 --- a/src/Intervention/Presentation/Api/Dto/Output/TimeJournalOutput.php +++ b/src/Intervention/Presentation/Api/Dto/Output/TimeJournalOutput.php @@ -19,8 +19,12 @@ * * @param string $workItemId intervention task associated with this contribution * @param list<\Intervention\Application\Contract\Time\TimeEntryView> $entries + * @param int $totalItems complete scoped entry count + * @param int $page one-based requested journal page + * @param int $itemsPerPage maximum entries in this response + * @param ?int $nextPage next journal page, null when complete */ - public function __construct(#[\ApiPlatform\Metadata\ApiProperty(identifier: true)] public string $workItemId, public array $entries) + public function __construct(#[\ApiPlatform\Metadata\ApiProperty(identifier: true)] public string $workItemId, public array $entries, public int $totalItems, public int $page, public int $itemsPerPage, public ?int $nextPage) { } } diff --git a/src/Intervention/Presentation/Api/Operation/InterventionTimeOperations.php b/src/Intervention/Presentation/Api/Operation/InterventionTimeOperations.php index df41af685..33bf1de8a 100644 --- a/src/Intervention/Presentation/Api/Operation/InterventionTimeOperations.php +++ b/src/Intervention/Presentation/Api/Operation/InterventionTimeOperations.php @@ -19,6 +19,16 @@ final class InterventionTimeOperations */ public const string LIST = 'intervention_time_list'; + /** + * Constant VERSIONS + */ + public const string VERSIONS = 'intervention_time_versions'; + + /** + * Constant GET + */ + public const string GET = 'intervention_time_get'; + /** * Constant CREATE */ diff --git a/src/Intervention/Presentation/Api/Provider/InterventionTimeProvider.php b/src/Intervention/Presentation/Api/Provider/InterventionTimeProvider.php index a8460d781..48ceb1383 100644 --- a/src/Intervention/Presentation/Api/Provider/InterventionTimeProvider.php +++ b/src/Intervention/Presentation/Api/Provider/InterventionTimeProvider.php @@ -4,18 +4,30 @@ namespace Intervention\Presentation\Api\Provider; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Metadata\{Operation, QueryParameterInterface}; +use ApiPlatform\State\{ParameterNotFound, ProviderInterface}; use Auth\Infrastructure\Security\User\SecurityUser; +use Intervention\Application\UseCase\Query\Time\GetTimeEntry\{GetTimeEntryQuery, GetTimeEntryResult}; use Intervention\Application\UseCase\Query\Time\ListTimeEntries\{ListTimeEntriesQuery, ListTimeEntriesResult}; -use Intervention\Presentation\Api\Dto\Output\TimeJournalOutput; +use Intervention\Application\UseCase\Query\Time\ListTimeEntryVersions\{ListTimeEntryVersionsQuery, ListTimeEntryVersionsResult}; +use Intervention\Presentation\Api\Dto\Output\{TimeEntryHistoryOutput, TimeEntryOutput, TimeJournalOutput}; +use Intervention\Presentation\Api\Operation\InterventionTimeOperations; use Intervention\Presentation\Api\Trait\InterventionWorkflowExceptionMapperTrait; use Shared\Application\Port\Inbound\QueryBusPort; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException}; use Throwable; +use function filter_var; +use function is_array; +use function is_bool; use function is_string; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_BOOLEAN; +use const FILTER_VALIDATE_INT; +use const PHP_INT_MAX; /** * InterventionTimeProvider. @@ -25,7 +37,7 @@ * * @author Valentin FORTIN * - * @implements ProviderInterface + * @implements ProviderInterface */ final readonly class InterventionTimeProvider implements ProviderInterface { @@ -50,23 +62,122 @@ public function __construct(private QueryBusPort $queries, private Security $sec * @param array $uriVariables * @param array $context * - * @return TimeJournalOutput authorized task journal and its independent entry revisions + * @return TimeJournalOutput|TimeEntryHistoryOutput|TimeEntryOutput authorized current entry, journal or history page */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): TimeJournalOutput + public function provide(Operation $operation, array $uriVariables = [], array $context = []): TimeJournalOutput|TimeEntryHistoryOutput|TimeEntryOutput { $user = $this->security->getUser(); if (!$user instanceof SecurityUser) { throw new AccessDeniedHttpException('Authentication required.'); } $taskId = is_string($uriVariables['taskId'] ?? null) ? $uriVariables['taskId'] : ''; + $filters = $this->filters($operation, $context); + $itemsPerPage = $this->positiveInteger($filters['itemsPerPage'] ?? 30, 'itemsPerPage', 100); try { + if (InterventionTimeOperations::GET === $operation->getName()) { + $entryId = is_string($uriVariables['entryId'] ?? null) ? $uriVariables['entryId'] : ''; + /** @var GetTimeEntryResult $entry */ + $entry = $this->queries->ask(new GetTimeEntryQuery($user->getId(), $taskId, $entryId)); + + return new TimeEntryOutput($entryId, $entry->entry); + } + if (InterventionTimeOperations::VERSIONS === $operation->getName()) { + $entryId = is_string($uriVariables['entryId'] ?? null) ? $uriVariables['entryId'] : ''; + $before = isset($filters['beforeRevision']) ? $this->positiveInteger($filters['beforeRevision'], 'beforeRevision') : null; + /** @var ListTimeEntryVersionsResult $history */ + $history = $this->queries->ask(new ListTimeEntryVersionsQuery($user->getId(), $taskId, $entryId, $before, $itemsPerPage)); + + return new TimeEntryHistoryOutput($entryId, $history->versions, $history->totalItems, $history->itemsPerPage, $history->nextBeforeRevision); + } /** @var ListTimeEntriesResult $result */ - $result = $this->queries->ask(new ListTimeEntriesQuery($user->getId(), $taskId)); + $result = $this->queries->ask(new ListTimeEntriesQuery($user->getId(), $taskId, $this->positiveInteger($filters['page'] ?? 1, 'page'), $itemsPerPage, $this->boolean($filters['ownOnly'] ?? false, 'ownOnly'))); } catch (Throwable $error) { throw $this->mapWorkflowException($error); } - return new TimeJournalOutput($taskId, $result->entries); + $next = $result->page * $result->itemsPerPage < $result->totalItems ? $result->page + 1 : null; + + return new TimeJournalOutput($taskId, $result->entries, $result->totalItems, $result->page, $result->itemsPerPage, $next); + } + + /** + * Method positiveInteger + * + * Rejects malformed and unbounded pagination before dispatch. + * + * @access private + * + * @param mixed $value raw query value + * @param string $name parameter name + * @param int $maximum upper bound + * + * @return int validated positive integer + */ + private function positiveInteger(mixed $value, string $name, int $maximum = PHP_INT_MAX): int + { + $parsed = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => $maximum]]); + if (false === $parsed) { + throw new BadRequestHttpException($name . ' must be a positive integer within its allowed range.'); + } + + return $parsed; + } + + /** + * Method boolean + * + * Rejects malformed scope selectors before dispatching a journal read. + * + * @access private + * + * @param mixed $value raw query value + * @param string $name parameter name + * + * @return bool validated scope selector + */ + private function boolean(mixed $value, string $name): bool + { + if (is_bool($value)) { + return $value; + } + if (!is_string($value) || '' === trim($value)) { + throw new BadRequestHttpException($name . ' must be a boolean.'); + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if (null === $parsed) { + throw new BadRequestHttpException($name . ' must be a boolean.'); + } + + return $parsed; + } + + /** + * Method filters + * + * Reads the journal's flat scope and pagination parameters from API Platform while retaining + * context values for clients using the existing provider filter mechanism. + * + * @access private + * + * @param Operation $operation parsed API Platform parameter metadata + * @param array $context legacy provider filter values + * + * @return array raw scope and pagination values for owner-local validation + */ + private function filters(Operation $operation, array $context): array + { + $filters = is_array($context['filters'] ?? null) ? $context['filters'] : []; + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + if (!$parameter instanceof QueryParameterInterface) { + continue; + } + $value = $parameter->getValue(); + if (!$value instanceof ParameterNotFound) { + $filters[$key] = $value; + } + } + + return $filters; } } diff --git a/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php b/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php index 4ea19f222..c8e62e5be 100644 --- a/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php +++ b/src/Intervention/Presentation/Api/Resource/InterventionTimeResource.php @@ -4,9 +4,9 @@ namespace Intervention\Presentation\Api\Resource; -use ApiPlatform\Metadata\{ApiResource, Delete, Get, Patch, Post}; +use ApiPlatform\Metadata\{ApiResource, Delete, Get, Patch, Post, QueryParameter}; use Intervention\Presentation\Api\Dto\Input\WriteTimeEntryInput; -use Intervention\Presentation\Api\Dto\Output\{TimeEntryOutput, TimeJournalOutput}; +use Intervention\Presentation\Api\Dto\Output\{TimeEntryHistoryOutput, TimeEntryOutput, TimeJournalOutput}; use Intervention\Presentation\Api\Operation\InterventionTimeOperations; use Intervention\Presentation\Api\Processor\InterventionTimeProcessor; use Intervention\Presentation\Api\Provider\InterventionTimeProvider; @@ -19,8 +19,17 @@ * * @author Valentin FORTIN */ -#[ApiResource(shortName: 'InterventionTime', operations: [ - new Get(name: InterventionTimeOperations::LIST, uriTemplate: '/intervention-work-items/{taskId}/time-entries', uriVariables: ['taskId'], output: TimeJournalOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER), +#[ApiResource(shortName: 'InterventionTime', normalizationContext: ['skip_null_values' => false], operations: [ + new Get(name: InterventionTimeOperations::GET, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], output: TimeEntryOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER), + new Get(name: InterventionTimeOperations::LIST, uriTemplate: '/intervention-work-items/{taskId}/time-entries', uriVariables: ['taskId'], output: TimeJournalOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER, parameters: [ + 'page' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'default' => 1], castToArray: false, castToNativeType: false, constraints: []), + 'itemsPerPage' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30], castToArray: false, castToNativeType: false, constraints: []), + 'ownOnly' => new QueryParameter(schema: ['type' => 'boolean', 'default' => false], description: 'Limit entries and their total to the current member, including for time managers.', castToArray: false, castToNativeType: false, constraints: []), + ]), + new Get(name: InterventionTimeOperations::VERSIONS, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}/versions', uriVariables: ['taskId', 'entryId'], output: TimeEntryHistoryOutput::class, provider: InterventionTimeProvider::class, security: self::SECURITY_ROLE_USER, parameters: [ + 'beforeRevision' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1], description: 'Exclusive revision cursor; omitted for the newest retained revisions.', castToArray: false, castToNativeType: false, constraints: []), + 'itemsPerPage' => new QueryParameter(schema: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 30], castToArray: false, castToNativeType: false, constraints: []), + ]), new Post(name: InterventionTimeOperations::CREATE, uriTemplate: '/intervention-work-items/{taskId}/time-entries', uriVariables: ['taskId'], read: false, input: WriteTimeEntryInput::class, output: TimeEntryOutput::class, processor: InterventionTimeProcessor::class, status: 201, security: self::SECURITY_ROLE_USER), new Patch(name: InterventionTimeOperations::CORRECT, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], read: false, input: WriteTimeEntryInput::class, output: TimeEntryOutput::class, processor: InterventionTimeProcessor::class, security: self::SECURITY_ROLE_USER), new Delete(name: InterventionTimeOperations::CANCEL, uriTemplate: '/intervention-work-items/{taskId}/time-entries/{entryId}', uriVariables: ['taskId', 'entryId'], read: false, input: false, output: false, processor: InterventionTimeProcessor::class, status: 204, security: self::SECURITY_ROLE_USER), diff --git a/src/Intervention/Presentation/Api/Service/InterventionCsvWriter.php b/src/Intervention/Presentation/Api/Service/InterventionCsvWriter.php index a2179f5d2..8b781fd99 100644 --- a/src/Intervention/Presentation/Api/Service/InterventionCsvWriter.php +++ b/src/Intervention/Presentation/Api/Service/InterventionCsvWriter.php @@ -5,7 +5,9 @@ namespace Intervention\Presentation\Api\Service; use Intervention\Application\Contract\Export\InterventionExportRow; +use Shared\Application\Port\Outbound\SpreadsheetSafeTextPort; +use function array_map; use function fputcsv; /** @@ -22,7 +24,7 @@ * * @author Valentin FORTIN */ -final class InterventionCsvWriter +final readonly class InterventionCsvWriter { // #region Constants /** @@ -43,9 +45,21 @@ final class InterventionCsvWriter 'due_at', 'created_at', 'updated_at', + SpreadsheetSafeTextPort::ENCODING_COLUMN, ]; // #endregion + // #region Constructor + /** + * Constructor + * + * @param SpreadsheetSafeTextPort $spreadsheetText reversible text-cell encoder + */ + public function __construct(private SpreadsheetSafeTextPort $spreadsheetText) + { + } + // #endregion + // #region Methods /** * Method write. @@ -59,10 +73,12 @@ final class InterventionCsvWriter */ public function write(array $rows, $handle): void { - fputcsv($handle, self::HEADER, escape: '\\'); + fputcsv($handle, self::HEADER, escape: ''); foreach ($rows as $row) { - fputcsv($handle, $this->toRow($row), escape: '\\'); + $cells = array_map($this->spreadsheetText->encode(...), $this->toRow($row)); + $cells[] = SpreadsheetSafeTextPort::ENCODING_VERSION; + fputcsv($handle, $cells, escape: ''); } } diff --git a/src/Inventory/Application/Port/Inbound/InventoryInterventionHistoryPort.php b/src/Inventory/Application/Port/Inbound/InventoryInterventionHistoryPort.php new file mode 100644 index 000000000..6a61f2af6 --- /dev/null +++ b/src/Inventory/Application/Port/Inbound/InventoryInterventionHistoryPort.php @@ -0,0 +1,47 @@ + $command->kind, 'partId' => $command->partId, 'warehouseId' => $command->warehouseId, 'quantity' => $quantity, 'occurredAt' => $command->occurredAt?->format('c'), 'interventionId' => $command->interventionId, 'workItemId' => $command->workItemId, 'equipmentId' => $command->equipmentId, 'reason' => $command->reason, 'unitCost' => null === $command->unitCost ? null : $this->amount($command->unitCost), 'currency' => $command->currency, 'sourceReceiptId' => $command->sourceReceiptId, 'originalId' => $command->originalId], JSON_THROW_ON_ERROR)); return $this->transactions->transactional(function () use ($command, $quantity, $hash): ApplyInventoryStockResult { + // Work fences precede currency; currency precedes operation and stock locks, including Procurement calls. + $context = $this->validateWorkContext($command); + $currency = $this->currency->lock($command->organizationId); $receipt = $this->store->operationForUpdate($command->organizationId, $command->clientOperationId ?? throw new LogicException('Missing operation identity.')); if (null !== $receipt) { if ($receipt->payloadHash !== $hash) { throw new InventoryConflictException('clientOperationId was already used for another declaration.'); } - if ('consumption' === $command->kind) { - $this->interventions->validate($command->organizationId, $command->interventionId ?? throw new InvalidArgumentException('An intervention is required.'), $command->workItemId, $command->equipmentId, $command->actorId); - } elseif ('return' === $command->kind) { - $declaration = $this->store->declaration($command->organizationId, $command->originalId ?? throw new InvalidArgumentException('An original consumption is required.')) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); - $this->interventions->validate($command->organizationId, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); - } return $this->replay($command, $receipt); } $result = match($command->kind) { - 'receipt' => $this->receive($command, $quantity), - 'consumption' => $this->consume($command, $quantity), - 'return','receipt_return' => $this->returnStock($command, $quantity), - default => $this->correct($command, $quantity), + 'receipt' => $this->receive($command, $quantity, $currency), + 'consumption' => $this->consume($command, $quantity, $currency, $context ?? throw new LogicException('Missing work context.')), + 'return','receipt_return' => $this->returnStock($command, $quantity, $currency, $context), + default => $this->correct($command, $quantity, $currency), }; if (null !== $result->receipt?->blockedReason) { return $result; @@ -84,7 +82,32 @@ public function __invoke(ApplyInventoryStockCommand $command): ApplyInventorySto }); } - private function consume(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + /** + * Method validateWorkContext + * + * Acquires the publication fence and parent lock before any financial or stock lock. + * + * @access private + * + * @param ApplyInventoryStockCommand $command the canonical operation + * + * @return ?InventoryInterventionContext the authorized work context when linked to an intervention + */ + private function validateWorkContext(ApplyInventoryStockCommand $command): ?InventoryInterventionContext + { + if ('consumption' === $command->kind) { + return $this->interventions->validate($command->organizationId, $command->interventionId ?? throw new InvalidArgumentException('An intervention is required.'), $command->workItemId, $command->equipmentId, $command->actorId); + } + if ('return' === $command->kind) { + $declaration = $this->store->declaration($command->organizationId, $command->originalId ?? throw new InvalidArgumentException('An original consumption is required.')) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); + + return $this->interventions->validate($command->organizationId, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); + } + + return null; + } + + private function consume(ApplyInventoryStockCommand $command, string $quantity, string $currency, InventoryInterventionContext $context): ApplyInventoryStockResult { $org = $command->organizationId; $intervention = $command->interventionId ?? throw new InvalidArgumentException('An interventionId is required.'); @@ -93,14 +116,13 @@ private function consume(ApplyInventoryStockCommand $command, string $quantity): new Uuid($id); } } - $context = $this->interventions->validate($org, $intervention, $command->workItemId, $command->equipmentId, $command->actorId); [$part,$warehouse] = $this->references($org, $command->partId, $command->warehouseId); $occurred = $command->occurredAt ?? throw new InvalidArgumentException('An occurredAt date is required.'); $declaration = new ConsumptionDeclaration($this->ids->generate(), $org, $part->id, $warehouse->id, $quantity, $intervention, $command->workItemId, $command->equipmentId, $command->actorId, $occurred, 'received_pending', null, null, $context->published); // Persist the complete physical fact before its resolution. Insufficient stock returns normally. $this->store->saveDeclaration($declaration); - return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published)); + return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published, $currency)); } private function canonical(ApplyInventoryStockCommand $c): ApplyInventoryStockCommand @@ -108,9 +130,8 @@ private function canonical(ApplyInventoryStockCommand $c): ApplyInventoryStockCo return new ApplyInventoryStockCommand(strtolower($c->organizationId), strtolower($c->actorId), $c->kind, null === $c->clientOperationId ? null : strtolower($c->clientOperationId), null === $c->partId ? null : strtolower($c->partId), null === $c->warehouseId ? null : strtolower($c->warehouseId), $c->quantity, $c->occurredAt, null === $c->interventionId ? null : strtolower($c->interventionId), null === $c->workItemId ? null : strtolower($c->workItemId), null === $c->equipmentId ? null : strtolower($c->equipmentId), $c->reason, $c->unitCost, $c->currency, null === $c->sourceReceiptId ? null : strtolower($c->sourceReceiptId), null === $c->originalId ? null : strtolower($c->originalId)); } - private function resolve(ConsumptionDeclaration $declaration, InventoryReference $part, InventoryReference $warehouse, bool $late): ConsumptionDeclaration + private function resolve(ConsumptionDeclaration $declaration, InventoryReference $part, InventoryReference $warehouse, bool $late, string $currency): ConsumptionDeclaration { - $currency = $this->currency->lock($declaration->organizationId); $balance = $this->store->balanceForUpdate($declaration->organizationId, $warehouse->id, $part->id); $reason = match(true) { $part->archived || $warehouse->archived => 'archived_reference',null === $balance => 'missing_balance',DecimalAmount::fromString($balance->quantity)->compareTo(DecimalAmount::fromString($declaration->quantity)) < 0 => 'insufficient_stock',default => null @@ -147,22 +168,22 @@ private function reconcile(ApplyInventoryStockCommand $command): ApplyInventoryS throw new InventoryNotFoundException('Consumption declaration not found.'); } $context = $this->interventions->validate($command->organizationId, $candidate->interventionId, $candidate->workItemId, $candidate->equipmentId, $command->actorId); + $currency = $this->currency->lock($command->organizationId); $declaration = $this->store->declaration($command->organizationId, $id, true) ?? throw new InventoryNotFoundException('Consumption declaration not found.'); if ('confirmed' === $declaration->status) { return new ApplyInventoryStockResult(declaration:$declaration, replayed:true); } [$part,$warehouse] = $this->references($command->organizationId, $declaration->partId, $declaration->warehouseId); - return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published || $declaration->late)); + return new ApplyInventoryStockResult(declaration:$this->resolve($declaration, $part, $warehouse, $context->published || $declaration->late, $currency)); } - private function receive(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + private function receive(ApplyInventoryStockCommand $command, string $quantity, string $currency): ApplyInventoryStockResult { [$part,$warehouse] = $this->references($command->organizationId, $command->partId, $command->warehouseId); if ($part->archived || $warehouse->archived) { throw new InventoryConflictException('Archived inventory references cannot receive stock.'); } - $currency = $this->currency->lock($command->organizationId); if ($command->currency !== $currency) { throw new InventoryConflictException('Receipt currency must match the organization currency.'); } @@ -177,7 +198,7 @@ private function receive(ApplyInventoryStockCommand $command, string $quantity): return new ApplyInventoryStockResult(movement:$movement, receipt:new InventoryReceiptResult($movement->id, $quantity, $unit, $value)); } - private function returnStock(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + private function returnStock(ApplyInventoryStockCommand $command, string $quantity, string $currency, ?InventoryInterventionContext $context): ApplyInventoryStockResult { $reason = $this->reason($command->reason); $originalId = $command->originalId ?? throw new InvalidArgumentException('The original declaration or movement is required.'); @@ -189,8 +210,7 @@ private function returnStock(ApplyInventoryStockCommand $command, string $quanti if (null === $declaration) { throw new InventoryNotFoundException('Consumption declaration not found.'); } - $context = $this->interventions->validate($org, $declaration->interventionId, $declaration->workItemId, $declaration->equipmentId, $command->actorId); - $late = $context->published; + $late = ($context ?? throw new LogicException('Missing work context.'))->published; if (null === $declaration->movementId) { throw new InventoryConflictException('An unresolved declaration cannot be returned to stock.'); } @@ -201,7 +221,6 @@ private function returnStock(ApplyInventoryStockCommand $command, string $quanti throw new InventoryConflictException('The referenced movement is not returnable.'); } [$part,$warehouse] = $this->references($org, $original->partId, $original->warehouseId); - $currency = $this->currency->lock($org); $balance = $this->store->balanceForUpdate($org, $warehouse->id, $part->id) ?? throw new InventoryConflictException('Missing inventory balance.'); $returned = DecimalAmount::fromString($this->store->linkedQuantity($org, $originalId)); $originalQuantity = DecimalAmount::fromString($original->quantity); @@ -251,11 +270,10 @@ private function returnStock(ApplyInventoryStockCommand $command, string $quanti return new ApplyInventoryStockResult(movement:$movement, receipt:new InventoryReceiptResult($movement->id, $quantity, $unit, $value)); } - private function correct(ApplyInventoryStockCommand $command, string $quantity): ApplyInventoryStockResult + private function correct(ApplyInventoryStockCommand $command, string $quantity, string $currency): ApplyInventoryStockResult { $reason = $this->reason($command->reason); [$part,$warehouse] = $this->references($command->organizationId, $command->partId, $command->warehouseId); - $currency = $this->currency->lock($command->organizationId); $balance = $this->store->balanceForUpdate($command->organizationId, $warehouse->id, $part->id); $valuation = new StockValuation(null === $balance ? '0.000000' : $balance->quantity, null === $balance ? '0.000000' : $balance->totalValue); $delta = DecimalAmount::fromString($quantity); diff --git a/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionHistoryAdapter.php b/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionHistoryAdapter.php new file mode 100644 index 000000000..837727442 --- /dev/null +++ b/src/Inventory/Infrastructure/Adapter/Intervention/InventoryInterventionHistoryAdapter.php @@ -0,0 +1,77 @@ +connection->isTransactionActive()) { + throw new LogicException('Inventory retention requires the main transaction.'); + } + $this->connection->executeQuery('SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))', ['key' => 'inventory-intervention:' . $organizationId . ':' . $interventionId]); + } + + /** + * Method hasHistory + * + * @access public + * + * @param string $organizationId owning organization + * @param string $interventionId operational parent + * @param ?string $workItemId optional task scope + * + * @return bool whether retained declarations or movements exist + */ + public function hasHistory(string $organizationId, string $interventionId, ?string $workItemId = null): bool + { + $parameters = ['organization' => $organizationId, 'intervention' => $interventionId]; + $scope = 'organization_id = :organization AND intervention_id = :intervention'; + if (null !== $workItemId) { + $scope .= ' AND work_item_id = :item'; + $parameters['item'] = $workItemId; + } + + return false !== $this->connection->fetchOne('SELECT 1 FROM inventory_declarations WHERE ' . $scope . ' UNION ALL SELECT 1 FROM inventory_movements WHERE ' . $scope . ' LIMIT 1', $parameters); + } + // #endregion +} diff --git a/src/Inventory/MODULE.md b/src/Inventory/MODULE.md index 33d3c7f59..04c9f0e89 100644 --- a/src/Inventory/MODULE.md +++ b/src/Inventory/MODULE.md @@ -29,6 +29,12 @@ Balances and movements attach `valuation` only when the caller holds maintenance ## Flows +`InventoryInterventionHistoryPort` exposes the existence of every retained +declaration or movement without quantities or values. Intervention deletion uses +its shared work fence before the parent row lock and refuses hard DELETE while +these references exist, including received_pending declarations and fully +returned consumptions. Return and reconciliation keep their operational context. + A valid physical consumption is saved before resolution in the same main transaction. It debits the complete requested quantity and its CUMP value or remains received_pending without any stock movement. No partial issue or negative balance is possible. Archived references and absent balance books also retain the declaration. Reconciliation uses the declaration's original quantity and work context. Each operation has an organization-scoped stable identity and payload hash. A conflicting body returns 409. Replaying consumption returns its original response snapshot even if the declaration has since been reconciled; detail reads return its current status. Confirmed receipts, returns and corrections replay their immutable movement. @@ -41,7 +47,7 @@ Returned intervention parts cannot exceed their original consumption. Every corr Handlers inject the inventory store, main transaction manager and published Intervention/MaintenanceCost ports. The repository only queries Inventory tables. Organization authorization occurs at the HTTP boundary; Intervention validates work ownership, equipment scope and execution. Procurement and intervention costs call public Application ports/contracts. -All stock paths acquire the organization currency lock before the balance lock. Consumption and publication share the `inventory-intervention:{organizationId}:{interventionId}` advisory fence before the parent intervention lock. Publication refuses received unresolved declarations and captures immutable facts. Late declarations and returns remain current cost facts with linked adjustments; they never mutate an existing dossier. +Stock paths acquire locks in one order: intervention publication fence and parent when applicable, organization currency, stable operation identity, declaration/references, then balance. Procurement receipts and reversals enter the same currency-before-operation/reference order. Reconciliation reads its immutable parent scope before acquiring the work fence and currency, then locks the declaration. Consumption and publication share the `inventory-intervention:{organizationId}:{interventionId}` advisory fence before the parent intervention lock. Publication refuses received unresolved declarations and captures immutable facts. Late declarations and returns remain current cost facts with linked adjustments; they never mutate an existing dossier. The public intervention resource port also supplies finance-authorized direct-equipment candidate intervention identifiers from owned movements and pending declarations. Both the equipment input and distinct intervention output are bounded to 10000 identities, with an explicit refusal of oversized scopes. No quantities, valuations or sibling persistence cross this directory projection; MaintenanceCost verifies actual current/captured allocations before selecting a financial dossier. @@ -55,7 +61,11 @@ Migration `Version20261006111000` adds the scoped reference catalog, balances, i ## Testing -Domain tests cover weighted average allocation, fractional quantities, last-issue residuals and incomplete values. Handler tests cover shortages, missing books, late facts, original-snapshot replay and exact partial returns across tiny, fractional, mixed and unknown valuations. PostgreSQL functional tests cover reconciliation, returns with tiny rounding residuals and replay, money permissions, archived history, scoped 404s, unchanged published dossiers and search/pagination parity. A two-session PostgreSQL integration test disputes the final part and proves one confirmed issue plus one intact pending declaration. +Retention coverage checks abandoned parents and prepared tasks with both confirmed +and received_pending declarations. Rejected DELETE preserves balances, details, +returns and reconciliation; a fully returned issue still retains its history. + +Domain tests cover weighted average allocation, fractional quantities, last-issue residuals and incomplete values. Handler tests cover shortages, missing books, late facts, original-snapshot replay and exact partial returns across tiny, fractional, mixed and unknown valuations. PostgreSQL functional tests cover reconciliation, returns with tiny rounding residuals and replay, money permissions, archived history, scoped 404s, unchanged published dossiers and search/pagination parity. Two-session PostgreSQL integration tests use the real currency adapter to dispute the final part and to serialize Procurement reception against intervention consumption, retaining exact balances and replay receipts. Use `make test-db` once to build the isolated template, then run the focused Inventory suites. Source/test PHPStan, container lint, both Deptrac configurations, OpenAPI and main schema gates are required. diff --git a/src/Maintenance/Application/Port/Inbound/MaintenanceInterventionHistoryPort.php b/src/Maintenance/Application/Port/Inbound/MaintenanceInterventionHistoryPort.php new file mode 100644 index 000000000..f37003942 --- /dev/null +++ b/src/Maintenance/Application/Port/Inbound/MaintenanceInterventionHistoryPort.php @@ -0,0 +1,30 @@ +connection->fetchOne('SELECT 1 FROM maintenance_occurrences WHERE organization_id = :organization AND intervention_id = :intervention LIMIT 1', ['organization' => $organizationId, 'intervention' => $interventionId]); + } + // #endregion +} diff --git a/src/Maintenance/MODULE.md b/src/Maintenance/MODULE.md index e405ef963..252ec30f4 100644 --- a/src/Maintenance/MODULE.md +++ b/src/Maintenance/MODULE.md @@ -56,6 +56,12 @@ existing work is replayed; retry is explicit and permitted only after abandonmen a skipped published task, or unsuccessful servicing. Its original occurrence id and due date remain unchanged. Outputs expose the server's retryAllowed decision. +`MaintenanceInterventionHistoryPort` publishes occurrence references to the +operational deletion guard. Generated work remains accessible after abandonment: +hard DELETE returns 409, preserving normal replay and retry of the original +occurrence and due date. The old task's occurrence link also retains previous +attempts after a retry moves the current occurrence to a new intervention. + | Method | Path | Description | Permission | | ------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | GET | `/api/maintenance/schedules` | List schedules (filters: `organization` _(required)_, `facility`, `equipmentType`, `dueStatus`, `dueBefore`; 30/page, client page size) | `organization.maintenance.read` | @@ -418,6 +424,10 @@ automatically — no backfill migration is needed. ## Testing +Occurrence retention coverage generates work, refuses task DELETE, abandons the +parent, refuses parent DELETE, then verifies replay and retry keep the original +occurrence and due date while both previous and current work remain accessible. + - New operation coverage: Unit/Maintenance Domain, plan command/query handlers, plan processor/provider; Functional/Api/MaintenancePlanApiTest; and the PostgreSQL MaintenancePlanRepositoryTest with two real worker connections, the native Doctrine diff --git a/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostInterventionHistoryPort.php b/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostInterventionHistoryPort.php new file mode 100644 index 000000000..8b71288db --- /dev/null +++ b/src/MaintenanceCost/Application/Port/Inbound/MaintenanceCostInterventionHistoryPort.php @@ -0,0 +1,31 @@ + $organizationId, 'intervention' => $interventionId]; + $scope = 'organization_id = :organization AND intervention_id = :intervention'; + $expenseScope = $scope; + $planningScope = $scope; + $snapshotScope = $scope; + if (null !== $workItemId) { + $parameters['item'] = $workItemId; + $expenseScope .= ' AND work_item_id = :item'; + $planningScope .= " AND EXISTS (SELECT 1 FROM jsonb_array_elements(resources::jsonb) r WHERE r->>'workItemId' = :item)"; + $snapshotScope .= " AND (EXISTS (SELECT 1 FROM jsonb_array_elements(items::jsonb) i WHERE i->>'workItemId' = :item) OR EXISTS (SELECT 1 FROM jsonb_array_elements(planning_resources::jsonb) r WHERE r->>'workItemId' = :item))"; + } + + return false !== $this->connection->fetchOne('SELECT 1 FROM maintenance_cost_expenses WHERE ' . $expenseScope . ' UNION ALL SELECT 1 FROM maintenance_cost_planning WHERE ' . $planningScope . ' UNION ALL SELECT 1 FROM maintenance_cost_snapshots WHERE ' . $snapshotScope . ' LIMIT 1', $parameters); + } + // #endregion +} diff --git a/src/MaintenanceCost/MODULE.md b/src/MaintenanceCost/MODULE.md index 41bd51d1c..d19164694 100644 --- a/src/MaintenanceCost/MODULE.md +++ b/src/MaintenanceCost/MODULE.md @@ -25,6 +25,12 @@ Publication acquires the Inventory intervention fence before the operational wor ## Flows +`MaintenanceCostInterventionHistoryPort` exposes only the existence of owned +financial references. Intervention and task DELETE checks run under the same +main parent lock as expense and preparation writes. Any expense, financial +preparation or frozen contribution retains its referenced operational work; +rejected deletion preserves detail reads, directory discovery and report totals. + Reporting selects interventions by completed publication date, or planned start/creation for work that remains unpublished. These dates describe the work dossier, not the occurrence date of each time or material fact. The organization date window is bounded to 500 candidate interventions before applying financial target filters, because direct material references need not occur among operational tasks. An oversized candidate window requires narrower dates; target filters cannot lift that cap. At most 50000 combined financial contributions are calculated. No truncated total is returned. The operational finance directory independently remains paginated when a report window is too large. Each financial contribution is allocated once. Time and targeted expenses follow their work item. Material keeps its direct equipment identity even without a work item. Financial filters first retain matching source dossiers from current/frozen contribution identities and captured operational targets, then separate known different targets from the selected amounts. Missing target scope stays explicitly unallocated rather than being silently treated as a different site or client. Global expenses and budgets remain unallocated within the matching dossiers. All retained amounts reconcile against the selected source dossiers, with excluded targets shown separately; they are not claimed as attributable to a selected asset. @@ -53,6 +59,10 @@ Additive main migration 20261007150000 widens only frozen `total` and `knownTota ## Testing +Retention coverage records expenses on drafts without time entries and verifies +DELETE refusal, readable task contributions and unchanged exact report totals. +Financial preparation also retains the referenced task and parent. + Unit tests cover single allocation across multiple equipment targets, direct materials, missing historical identity, exact sums beyond eighteen digits, unknown prices, resource/budget separation, current versus frozen costs, compound target filters, full-scope pagination, reconciliation and strict leap/calendar bounds. Handler and provider tests cover finance authorization before source reads and refusal of partial or oversized source windows. Functional PostgreSQL tests exercise finance-only readers, ordinary operational denial, minimal server search, direct materials without tasks in directory names and target filters, exact filtered pages, captured targets after equipment moves, unknown historic publications, late expenses without snapshot replacement, organization 404, finance 403 and date/group/page 422. Procurement's owning reader separately proves partial receipts, returns, cancellation, unknown unit costs, exact sums, 500/501 limits and organization isolation. diff --git a/src/Notification/Application/Contract/Notification/SendNotificationRequest.php b/src/Notification/Application/Contract/Notification/SendNotificationRequest.php index 73b08589d..f08478d4a 100644 --- a/src/Notification/Application/Contract/Notification/SendNotificationRequest.php +++ b/src/Notification/Application/Contract/Notification/SendNotificationRequest.php @@ -22,10 +22,10 @@ * * @param string $type the notification type * @param string $subject the subject - * @param string $body the body content + * @param string $body the stored body content, rendered as plain text by the default email template * @param list $channels the delivery channels * @param array $payload the payload data - * @param array $deliveryPayload ephemeral delivery payload (not persisted) + * @param array $deliveryPayload ephemeral delivery payload (not persisted); email.bodyIsHtml must be true to render producer-trusted HTML, with every untrusted interpolation escaped * @param string|null $recipientUserId the recipient user identifier * @param string|null $recipientEmail the recipient email * @param string|null $organizationId the organization this notification belongs to, when any (nullable: account-level notifications and platform announcements legitimately have none) diff --git a/src/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapter.php b/src/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapter.php index aea38b299..f47d23be9 100644 --- a/src/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapter.php +++ b/src/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapter.php @@ -132,6 +132,7 @@ private function buildContext(Notification $notification, array $channelPayload) 'recipientEmail' => null !== $recipient ? (string) $recipient : null, ], $this->extractContext($channelPayload), + ['bodyIsHtml' => true === ($channelPayload['bodyIsHtml'] ?? false)], ); } diff --git a/src/Notification/Infrastructure/Console/SendNotificationConsoleCommand.php b/src/Notification/Infrastructure/Console/SendNotificationConsoleCommand.php index 8a6f7ad1f..d31476a49 100644 --- a/src/Notification/Infrastructure/Console/SendNotificationConsoleCommand.php +++ b/src/Notification/Infrastructure/Console/SendNotificationConsoleCommand.php @@ -84,7 +84,13 @@ protected function configure(): void ->addArgument( name: 'body', mode: InputArgument::REQUIRED, - description: 'Body of the notification (plain text or HTML)', + description: 'Body of the notification (plain text by default; use --body-is-html for trusted HTML)', + ) + ->addOption( + name: 'body-is-html', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Render the email body as trusted HTML; escape all untrusted values before interpolation', ) ->addOption( name: 'user-id', @@ -116,7 +122,7 @@ protected function configure(): void The %command.name% command sends a notification to a user. Send via e-mail only: - php %command.full_name% organization.invitation "You're invited" "

Join us!

" --email=user@example.com
+ php %command.full_name% organization.invitation "You're invited" "

Join us!

" --email=user@example.com --body-is-html
Send via both channels: php %command.full_name% system.announcement "Maintenance" "Scheduled maintenance tonight." --user-id= --email=user@example.com --channels=email,mercure @@ -206,6 +212,9 @@ private function readRequest(InputInterface $input, SymfonyStyle $io): ?SendNoti subject: $subject, body: $body, channels: $channels, + deliveryPayload: true === $input->getOption('body-is-html') + ? [NotificationChannel::EMAIL->value => ['bodyIsHtml' => true]] + : [], recipientUserId: $userId, recipientEmail: $email, organizationId: $organizationId, diff --git a/src/Notification/MODULE.md b/src/Notification/MODULE.md index 354e0e1a5..159e26848 100644 --- a/src/Notification/MODULE.md +++ b/src/Notification/MODULE.md @@ -226,6 +226,11 @@ Channel details: - optional custom template: `deliveryPayload['email']['template']`, - optional template vars: `deliveryPayload['email']['context']`, - `deliveryPayload['email']['body']` is still supported as default-template body override, + - the default template autoescapes the body as plain text, including an email-only body override. + Producer-trusted HTML requires `deliveryPayload['email']['bodyIsHtml'] === true`; every untrusted + interpolation in that HTML must already be escaped. Template context cannot enable this flag. + Stored notifications and Mercure bodies retain their original content. Dedicated templates keep + their own escaping rules; invitation and digest translated markup escapes organization names, - every transactional email (including the OTP code email) extends `templates/notification/email/layout.html.twig`, which follows the web app's visual language (neutral zinc ramp, vermilion fill for the primary button only, dark scheme via @@ -416,11 +421,17 @@ entries; neither is a separate inbox source yet. - `MERCURE_PUBLIC_URL` - `MERCURE_JWT_SECRET` - Email delivery relies on shared mailer configuration (`MAILER_DSN`, sender config in Shared mail adapter). +- `app:notification:send` treats its `body` argument as text by default. Operators sending trusted + HTML must pass `--body-is-html`; the flag only changes email rendering, not the stored body. ## Testing Security template tests verify escaping, en/fr/es approximate wording and DB-IP attribution; email-change producer tests keep geography out of persistent Notification fields. +Membership, intervention review/reminder and plan-over-quota email coverage follows the producers +through the real default Twig template, requiring tenant markup to remain literal text while +stored notification and Mercure content stays unchanged. Template tests cover strict HTML opt-in +and context override denial, while console tests cover the explicit `--body-is-html` option. - Unit tests: `tests/Unit/Notification` diff --git a/src/Organization/Infrastructure/Image/OrganizationLogoResizer.php b/src/Organization/Infrastructure/Image/OrganizationLogoResizer.php index 8b7d0f7b2..9e2c0ecf1 100644 --- a/src/Organization/Infrastructure/Image/OrganizationLogoResizer.php +++ b/src/Organization/Infrastructure/Image/OrganizationLogoResizer.php @@ -4,9 +4,12 @@ namespace Organization\Infrastructure\Image; +use Intervention\Image\Drivers\Gd\Decoders\BinaryImageDecoder; use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\ImageManager; -use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Application\Contract\Image\InvalidImageInputException; +use Shared\Application\Port\Outbound\{FileStoragePort, ImageInputValidationPort}; use function sprintf; @@ -65,9 +68,11 @@ * @since 1.0.0 * * @param FileStoragePort $fileStorage port used to persist the image + * @param ImageInputValidationPort $imageInputValidation bounds the source before decoding */ public function __construct( private FileStoragePort $fileStorage, + private ImageInputValidationPort $imageInputValidation, ) { } // #endregion @@ -85,11 +90,20 @@ public function __construct( * @param string $sourceContents the raw binary content of the uploaded image * * @return void no return value + * + * @throws InvalidImageInputException if the source is invalid or exceeds the image budget */ public function resize(string $organizationId, string $sourceContents): void { - $manager = new ImageManager(new Driver()); - $image = $manager->read($sourceContents); + $this->imageInputValidation->validate($sourceContents); + $manager = new ImageManager(new Driver(), decodeAnimation: false); + + try { + $image = $manager->read($sourceContents, BinaryImageDecoder::class); + } catch (DecoderException $exception) { + throw new InvalidImageInputException('Unable to decode the uploaded image.', previous: $exception); + } + $image->scaleDown(self::MAX_DIMENSION, self::MAX_DIMENSION); $encoded = $image->toWebp(self::WEBP_QUALITY); diff --git a/src/Organization/MODULE.md b/src/Organization/MODULE.md index 9f4ced6e5..89a451b61 100644 --- a/src/Organization/MODULE.md +++ b/src/Organization/MODULE.md @@ -1214,6 +1214,18 @@ cannot extend a grant, and an unreadable organization status never grants a writ Resolve authenticated identity through public auth-side contracts, enforce membership/admission/policy and quotas in main, then persist the owned mutation. Durable invitation and setup workflows retain their transactional and replay rules described above. +Logo uploads accept JPEG, PNG, WebP and GIF sources of at most 5 MiB, 4,096 pixels +per axis and 4,194,304 pixels in total. Header validation runs before GD allocates +the source raster. Animation decoding is disabled; animated GIF uploads retain +the first frame as a static WebP. GIF logical-screen and first-frame dimensions +are validated independently before decoding; the first frame must fit its screen. +Invalid geometry or malformed image content +returns HTTP 422 without replacing the existing logo or updating its URL. + +Membership email bodies are autoescaped as plain text by the default notification +template; stored notifications and Mercure text preserve the original organization +name. + ## Configuration Bindings are defined in [Organization configuration](../../config/modules/organization.yaml). Persistence consumers name their entity manager explicitly according to [Doctrine mapping](../../config/packages/doctrine.yaml). Runtime and recovery requirements in the sections above remain part of this contract. diff --git a/src/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessor.php b/src/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessor.php index 6c9d101b6..4688f5d05 100644 --- a/src/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessor.php +++ b/src/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessor.php @@ -13,6 +13,7 @@ use Organization\Infrastructure\Image\OrganizationLogoResizer; use Organization\Presentation\Api\Dto\Output\Organization\{OrganizationOutput, OrganizationRegisteredAddressOutput}; use Shared\Application\Port\Inbound\{CommandBusPort, QueryBusPort}; +use Shared\Application\Port\Outbound\ImageInputValidationPort; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; @@ -55,7 +56,7 @@ * * @var int */ - private const int MAX_FILE_SIZE = 5 * 1024 * 1024; + private const int MAX_FILE_SIZE = ImageInputValidationPort::MAX_BYTES; /** * Constant ALLOWED_MIME_TYPES. @@ -164,7 +165,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = ); } - $sourceContents = file_get_contents($file->getPathname()); + $sourceContents = file_get_contents($file->getPathname(), length: self::MAX_FILE_SIZE + 1); if (false === $sourceContents) { throw new UnprocessableEntityHttpException('Failed to read the uploaded file.'); } diff --git a/src/Procurement/Application/Contract/ProcurementOperationState.php b/src/Procurement/Application/Contract/ProcurementOperationState.php index e24710f01..2b5981e87 100644 --- a/src/Procurement/Application/Contract/ProcurementOperationState.php +++ b/src/Procurement/Application/Contract/ProcurementOperationState.php @@ -4,11 +4,12 @@ namespace Procurement\Application\Contract; -/** Stable offline operation keys cannot be reused for a different physical declaration. */ +/** Stable operation keys retain creation and physical declarations without repeating their effects. */ final readonly class ProcurementOperationState { /** * @param array $declaration + * @param string $receiptId the created resource UUID for creation kinds, otherwise the physical receipt UUID */ public function __construct(public string $organizationId, public string $clientOperationId, public string $kind, public string $fingerprint, public string $receiptId, public array $declaration = []) { diff --git a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php index c102ace75..5b2158bfb 100644 --- a/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php +++ b/src/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandler.php @@ -35,6 +35,7 @@ use function is_array; use function is_string; use function json_encode; +use function ksort; use function preg_match; use function strlen; use function strtolower; @@ -69,6 +70,17 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe $finance = $this->authorization->hasPermission($command->actorId, $command->organizationId, 'organization.maintenance_cost.read'); return $this->repository->synchronized($command->organizationId, function () use ($command, $finance): ManageProcurementResult { + $creation = in_array($command->action, ['create_supplier', 'create_order'], true); + $operationId = $creation && null !== ($command->payload['clientOperationId'] ?? null) ? $this->operationId($command) : null; + $creationPayload = $command->payload; + unset($creationPayload['clientOperationId']); + $fingerprint = null === $operationId ? '' : $this->fingerprint($this->creationPayload($creationPayload)); + if (null !== $operationId) { + $replay = $this->replay($command, $operationId, $command->action, $fingerprint, $finance); + if (null !== $replay) { + return $replay; + } + } $result = match ($command->action) { 'create_supplier', 'change_supplier', 'archive_supplier' => $this->supplier($command), 'create_order', 'change_order', 'order', 'cancel_remaining' => $this->order($command, $finance), @@ -78,6 +90,13 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe 'reconcile_return' => $this->reconcileReturn($command, $finance), default => throw ProcurementException::invalid('Unknown procurement mutation.'), }; + if (null !== $operationId) { + $resourceId = $result->data['id'] ?? null; + if (!is_string($resourceId)) { + throw ProcurementException::conflict('The created resource has no durable identity.'); + } + $this->repository->saveOperation(new ProcurementOperationState($command->organizationId, $operationId, $command->action, $fingerprint, $resourceId, $command->payload + ['actorId' => $command->actorId])); + } if (!$result->replayed) { $resourceId = $result->data['id'] ?? null; if (is_string($resourceId)) { @@ -91,7 +110,7 @@ public function __invoke(ManageProcurementCommand $command): ManageProcurementRe private function supplier(ManageProcurementCommand $command): ManageProcurementResult { - $this->fields($command->payload, ['name', 'code', 'email', 'phone', 'contacts']); + $this->fields($command->payload, 'create_supplier' === $command->action ? ['name', 'code', 'email', 'phone', 'contacts', 'clientOperationId'] : ['name', 'code', 'email', 'phone', 'contacts']); $now = $this->clock->now(); if ('create_supplier' === $command->action) { $supplier = Supplier::create($this->ids->generate(), $command->organizationId, $this->text($command->payload, 'name'), $this->optionalText($command->payload, 'code'), $this->optionalText($command->payload, 'email'), $this->optionalText($command->payload, 'phone'), $this->contacts($command->payload['contacts'] ?? []), $now); @@ -111,7 +130,7 @@ private function supplier(ManageProcurementCommand $command): ManageProcurementR private function order(ManageProcurementCommand $command, bool $finance): ManageProcurementResult { - $this->fields($command->payload, ['name', 'supplierId', 'lines']); + $this->fields($command->payload, 'create_order' === $command->action ? ['name', 'supplierId', 'lines', 'clientOperationId'] : ['name', 'supplierId', 'lines']); $now = $this->clock->now(); if ('create_order' === $command->action) { $supplierId = $this->uuid($this->text($command->payload, 'supplierId')); @@ -422,6 +441,16 @@ private function replay(ManageProcurementCommand $command, string $operationId, if ($operation->kind !== $kind || $operation->fingerprint !== $fingerprint) { throw ProcurementException::conflict('The offline operation identifier already belongs to a different declaration.'); } + if ('create_supplier' === $kind) { + $supplier = $this->repository->supplier($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no supplier.'); + + return new ManageProcurementResult('supplier', $this->projection->supplier($supplier), true); + } + if ('create_order' === $kind) { + $order = $this->repository->order($command->organizationId, $operation->receiptId) ?? throw ProcurementException::conflict('The saved creation has no order.'); + + return new ManageProcurementResult('order', $this->projection->order($order, $finance), true); + } if ('reconcile_return' === $kind) { $returnId = $operation->declaration['returnId'] ?? null; $return = is_string($returnId) ? $this->repository->returnDeclaration($command->organizationId, $returnId) : null; @@ -434,13 +463,41 @@ private function replay(ManageProcurementCommand $command, string $operationId, } /** - * @param array $values + * @param array $values */ private function fingerprint(array $values): string { return hash('sha256', json_encode($values, JSON_THROW_ON_ERROR)); } + /** + * Method creationPayload + * + * Keeps object-key order and UUID spelling from changing a retained creation's identity. + * Generated resource and line UUIDs are deliberately absent from the submitted fingerprint. + * + * @access private + * + * @param array $payload the submitted creation values + * + * @return array the deterministic submitted values + */ + private function creationPayload(array $payload): array + { + foreach ($payload as $field => $value) { + if (is_array($value)) { + $payload[$field] = $this->creationPayload($value); + } elseif (is_string($value) && in_array($field, ['id', 'supplierId', 'partId'], true)) { + $payload[$field] = $this->uuid($value); + } + } + if (!array_is_list($payload)) { + ksort($payload); + } + + return $payload; + } + /** * @param array $payload * @param list $allowed diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php index cafa95141..2e1c110af 100644 --- a/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Record/SupplierRecord.php @@ -23,7 +23,7 @@ class SupplierRecord #[ORM\Column(name: 'name', type: 'string', length: 160)] public string $name; - #[ORM\Column(name: 'code', type: 'string', length: 64, nullable: true)] + #[ORM\Column(name: 'code', type: 'string', length: 80, nullable: true)] public ?string $code = null; #[ORM\Column(name: 'email', type: 'string', length: 254, nullable: true)] diff --git a/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php index 653c2b3d4..7f1cf7797 100644 --- a/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php +++ b/src/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepository.php @@ -122,7 +122,7 @@ public function saveOperation(ProcurementOperationState $operation): void $existing = $this->operation($operation->organizationId, $operation->clientOperationId); if (null !== $existing) { if ($existing->kind !== $operation->kind || $existing->fingerprint !== $operation->fingerprint || $existing->receiptId !== $operation->receiptId) { - throw ProcurementException::conflict('The physical declaration key cannot be reused.'); + throw ProcurementException::conflict('The retained operation key cannot be reused.'); } return; diff --git a/src/Procurement/MODULE.md b/src/Procurement/MODULE.md index b60125199..e40894068 100644 --- a/src/Procurement/MODULE.md +++ b/src/Procurement/MODULE.md @@ -33,7 +33,7 @@ All routes are under `/api/organizations/{organizationId}/procurement` and requi Collections use Hydra pagination, default 30 and maximum 100. Supplier filters include search and archived: omission or false selects active suppliers, true selects archived suppliers, and explicit all selects both with the same scoped count and pagination. Order filters include status and supplierId. All resource references are resolved in the same organization. -Mutations of an existing resource require `If-Match: "revision-N"`. Receipt creation uses the order revision; individualization and return declaration use the receipt revision; return reconciliation uses the return revision. A matching saved clientOperationId replays before the stale revision check. UUID representations are normalized to lowercase. +Mutations of an existing resource require `If-Match: "revision-N"`. Receipt creation uses the order revision; individualization and return declaration use the receipt revision; return reconciliation uses the return revision. A matching saved clientOperationId replays before the stale revision check. UUID representations are normalized to lowercase. Supplier and purchase-draft creation also accept clientOperationId: the web client retains it across transport retries, the main transaction saves the created resource and its operation receipt atomically, and an exact retry returns the same resource with replayed=true without another event or revision. Changed submitted values or reuse across operation kinds returns 409. Omitted creation keys remain accepted for older callers; those callers cannot recover a lost response idempotently. ## Flows @@ -51,7 +51,7 @@ A physical supply return preserves its quantity and reason even when available c Presentation translates DTOs into CommandBusPort and QueryBusPort messages. ManageProcurementHandler and ReadProcurementHandler are the sole mutation and read entry points. Domain models enforce order lifecycle, exact quantities, line separation, contact validity and optimistic revisions. -ProcurementRepository uses an explicitly named main DBAL connection. It scopes every lookup and serializes mutations with a transaction-scoped organization advisory lock. Physical receipt and return identity fields are immutable on repository updates. Stable operation declarations and motivated return evidence are retained. +ProcurementRepository uses an explicitly named main DBAL connection. It scopes every lookup and serializes mutations with a transaction-scoped organization advisory lock. Physical receipt and return identity fields are immutable on repository updates. Stable operation declarations and motivated return evidence are retained. Creation receipts share the organization-scoped operation ledger; its historical receipt_id field identifies the created supplier/order for creation kinds and a physical receipt for delivery kinds. Object-key order and UUID spelling do not change a creation fingerprint; generated order-line identities remain attached to the first saved draft. All stock bridges acquire currency before Inventory operation, reference and balance locks; intervention stock paths acquire their publication fence/parent first. Cross-module access uses published Application contracts and ports: InventoryStockReceiptPort, InventoryPartDirectoryPort, EquipmentReserveReceiptPort, MaintenanceCurrencyPort and OrganizationAuthorizationPort. The module never reads sibling persistence records during production execution. @@ -61,11 +61,11 @@ Cross-module access uses published Application contracts and ports: InventorySto The module requires `organization.procurement.read/manage`. Part receipts, supply returns and reconciliation additionally require `organization.inventory.manage`; individualization requires `organization.equipment.write`. Cost visibility independently requires `organization.maintenance_cost.read`; explicitly writing or clearing a unitCost requires `organization.maintenance_cost.manage`. Hidden prices are omitted from projections and DTOs; an authorized unknown cost remains null. -Additive main migrations 20261006112000–20261006112002 add suppliers, orders, physical receipts, immutable operation keys, returns and scoped indexes. UTC timestamps carry datetime_immutable type comments. Decimal columns use NUMERIC(24,6). +Additive main migrations 20261006112000–20261006112002 add suppliers, orders, physical receipts, immutable operation keys, returns and scoped indexes. Additive main migration Version20261008121000 widens supplier codes to the validated 80-character limit. Its rollback is refused by PostgreSQL while references longer than 64 characters remain, preserving retained supplier data. UTC timestamps carry datetime_immutable type comments. Decimal columns use NUMERIC(24,6). ## Testing -Domain tests cover supplier contacts/archive, order transitions, fractional consumables, integer equipment, bounded receipts and returns. Handler and transport tests cover authority, isolated financial visibility, exact decimals, optimistic revision, operation replay and UUID aliases. +Domain tests cover supplier contacts/archive, order transitions, fractional consumables, integer equipment, bounded receipts and returns. Handler and transport tests cover authority, isolated financial visibility, exact decimals, optimistic revision, creation and physical-operation replay, changed creation payload conflicts, UUID aliases and supplier-code boundaries 64/65/80/81. PostgreSQL integration tests use independent connections for organization locks, the last quantity, retained declarations, transaction rollback and outbox visibility. Functional API tests exercise real inventory receipt/return bridges and real quota-backed equipment individualization, including partial delivery, archived suppliers, hidden costs, replay, cancellation and organization denial. diff --git a/src/Procurement/Presentation/Api/Dto/Input/CreatePurchaseOrderInput.php b/src/Procurement/Presentation/Api/Dto/Input/CreatePurchaseOrderInput.php new file mode 100644 index 000000000..87839e353 --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Input/CreatePurchaseOrderInput.php @@ -0,0 +1,50 @@ +>|null + */ + #[Groups(['procurement:write'])] + public ?array $lines = null; + // #endregion +} diff --git a/src/Procurement/Presentation/Api/Dto/Input/CreateSupplierInput.php b/src/Procurement/Presentation/Api/Dto/Input/CreateSupplierInput.php new file mode 100644 index 000000000..a3ada10c3 --- /dev/null +++ b/src/Procurement/Presentation/Api/Dto/Input/CreateSupplierInput.php @@ -0,0 +1,61 @@ +>|null + */ + #[Groups(['procurement:write'])] + public ?array $contacts = null; + // #endregion +} diff --git a/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php b/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php index f9a503075..5236269e3 100644 --- a/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php +++ b/src/Procurement/Presentation/Api/Processor/ProcurementProcessor.php @@ -8,7 +8,7 @@ use ApiPlatform\State\ProcessorInterface; use Procurement\Application\UseCase\Command\ManageProcurement\{ManageProcurementCommand, ManageProcurementResult}; use Procurement\Domain\Exception\ProcurementException; -use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; +use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, CreatePurchaseOrderInput, CreateSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; use Procurement\Presentation\Api\Dto\Output\{ProcurementReceiptOutput, ProcurementReturnOutput, PurchaseOrderOutput, SupplierOutput}; use Procurement\Presentation\Api\Operation\ProcurementOperations; use Shared\Application\Port\Inbound\CommandBusPort; @@ -23,7 +23,7 @@ use function property_exists; /** Maps transport primitives and revisions into the only authorized mutation entry point. - * @implements ProcessorInterface + * @implements ProcessorInterface */ final readonly class ProcurementProcessor implements ProcessorInterface { diff --git a/src/Procurement/Presentation/Api/Resource/ProcurementResource.php b/src/Procurement/Presentation/Api/Resource/ProcurementResource.php index a9090c63b..ed2f771b8 100644 --- a/src/Procurement/Presentation/Api/Resource/ProcurementResource.php +++ b/src/Procurement/Presentation/Api/Resource/ProcurementResource.php @@ -6,7 +6,7 @@ use ApiPlatform\Metadata\{ApiResource, Get, GetCollection, Patch, Post, QueryParameter}; use ApiPlatform\OpenApi\Model\Operation; -use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; +use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, CreatePurchaseOrderInput, CreateSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; use Procurement\Presentation\Api\Dto\Output\{ProcurementReceiptOutput, ProcurementReturnOutput, PurchaseOrderOutput, SupplierOutput}; use Procurement\Presentation\Api\Operation\ProcurementOperations; use Procurement\Presentation\Api\Processor\ProcurementProcessor; @@ -23,12 +23,12 @@ new Get(name: ProcurementOperations::RETURN_DETAIL, uriTemplate: '/organizations/{organizationId}/procurement/returns/{id}', output: ProcurementReturnOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a retained physical return')), new Post(name: ProcurementOperations::RECONCILE_RETURN, uriTemplate: '/organizations/{organizationId}/procurement/returns/{id}/reconcile', output: ProcurementReturnOutput::class, input: IndividualizeReceiptInput::class, read: false, status: 200, processor: ProcurementProcessor::class, openapi: new Operation(tags: ['Procurement'], summary: 'Explicitly reconcile a physical supply return')), new GetCollection(name: ProcurementOperations::SUPPLIERS, uriTemplate: '/organizations/{organizationId}/procurement/suppliers', output: SupplierOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: ['search' => new QueryParameter(schema: ['type' => 'string'], castToArray: false), 'archived' => new QueryParameter(description: 'false or omitted selects active suppliers; true selects archived suppliers; all selects both.', schema: ['type' => 'string', 'enum' => ['false', 'true', 'all']], castToArray: false, castToNativeType: false)], openapi: new Operation(tags: ['Procurement'], summary: 'List suppliers')), - new Post(name: ProcurementOperations::CREATE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: ChangeSupplierInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create an internal supplier')), + new Post(name: ProcurementOperations::CREATE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: CreateSupplierInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create an internal supplier')), new Get(name: ProcurementOperations::SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}', output: SupplierOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a supplier')), new Patch(name: ProcurementOperations::CHANGE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: ChangeSupplierInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Change a supplier')), new Post(name: ProcurementOperations::ARCHIVE_SUPPLIER, uriTemplate: '/organizations/{organizationId}/procurement/suppliers/{id}/archive', output: SupplierOutput::class, processor: ProcurementProcessor::class, input: false, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Archive a supplier retaining current purchases')), new GetCollection(name: ProcurementOperations::ORDERS, uriTemplate: '/organizations/{organizationId}/procurement/orders', output: PurchaseOrderOutput::class, provider: ProcurementProvider::class, paginationEnabled: true, paginationClientItemsPerPage: true, paginationItemsPerPage: 30, paginationMaximumItemsPerPage: 100, parameters: ['status' => new QueryParameter(schema: ['type' => 'string', 'enum' => ['draft', 'ordered', 'partial_received', 'received', 'cancelled']], castToArray: false), 'supplierId' => new QueryParameter(schema: ['type' => 'string', 'format' => 'uuid'], castToArray: false)], openapi: new Operation(tags: ['Procurement'], summary: 'List purchase orders')), - new Post(name: ProcurementOperations::CREATE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: ChangePurchaseOrderInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create a purchase draft')), + new Post(name: ProcurementOperations::CREATE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: CreatePurchaseOrderInput::class, read: false, status: 201, openapi: new Operation(tags: ['Procurement'], summary: 'Create a purchase draft')), new Get(name: ProcurementOperations::ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}', output: PurchaseOrderOutput::class, provider: ProcurementProvider::class, openapi: new Operation(tags: ['Procurement'], summary: 'Read a purchase order')), new Patch(name: ProcurementOperations::CHANGE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: ChangePurchaseOrderInput::class, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Edit a purchase draft')), new Post(name: ProcurementOperations::PLACE_ORDER, uriTemplate: '/organizations/{organizationId}/procurement/orders/{id}/order', output: PurchaseOrderOutput::class, processor: ProcurementProcessor::class, input: false, read: false, status: 200, openapi: new Operation(tags: ['Procurement'], summary: 'Place a purchase order')), diff --git a/src/Shared/Application/Contract/Image/InvalidImageInputException.php b/src/Shared/Application/Contract/Image/InvalidImageInputException.php new file mode 100644 index 000000000..b758030e4 --- /dev/null +++ b/src/Shared/Application/Contract/Image/InvalidImageInputException.php @@ -0,0 +1,19 @@ + self::MAX_BYTES) { + throw new InvalidImageInputException('Image size must be between 1 byte and 5 MiB.'); + } + + $metadata = @getimagesizefromstring($contents); + if (false === $metadata || !in_array($metadata[2], [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF], true)) { + throw new InvalidImageInputException('Invalid image content. Allowed types: JPEG, PNG, WebP and GIF.'); + } + + [$width, $height] = $metadata; + $this->validateDimensions($width, $height); + + if (IMAGETYPE_GIF === $metadata[2]) { + $this->validateGifFirstFrame($contents, $width, $height); + } + } + + /** + * Method validateDimensions + * + * Applies the source budget to each geometry a decoder may allocate. + * + * @access private + * + * @param int $width the declared raster width + * @param int $height the declared raster height + * + * @return void no return value + * + * @throws InvalidImageInputException if the dimensions exceed the budget + */ + private function validateDimensions(int $width, int $height): void + { + if ($width <= 0 || $height <= 0 || $width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) { + throw new InvalidImageInputException('Image width and height must be between 1 and 4096 pixels.'); + } + + if ($width * $height > self::MAX_PIXELS) { + throw new InvalidImageInputException('Image exceeds the 4194304 pixel limit.'); + } + } + + /** + * Method validateGifFirstFrame + * + * GIF's logical screen and first image descriptor carry separate dimensions. + * Inspect both before the static GD decoder can allocate the first frame. + * + * @access private + * + * @param string $contents the bounded binary GIF source + * @param int $canvasWidth the already-validated logical screen width + * @param int $canvasHeight the already-validated logical screen height + * + * @return void no return value + * + * @throws InvalidImageInputException if the first frame is malformed or outside the budget + */ + private function validateGifFirstFrame(string $contents, int $canvasWidth, int $canvasHeight): void + { + $length = strlen($contents); + if ($length < 13) { + throw new InvalidImageInputException('Invalid GIF image content.'); + } + + $packed = ord($contents[10]); + $offset = 13 + (0 !== ($packed & 0x80) ? 3 * (2 << ($packed & 0x07)) : 0); + + while ($offset < $length) { + $marker = $contents[$offset]; + + if ('!' === $marker && $offset + 2 <= $length) { + $offset = $this->skipGifSubBlocks($contents, $offset + 2); + + continue; + } + + if (',' !== $marker || $offset + 10 > $length) { + throw new InvalidImageInputException('Invalid GIF image content.'); + } + + $descriptor = substr($contents, $offset + 1, 9); + $left = ord($descriptor[0]) | (ord($descriptor[1]) << 8); + $top = ord($descriptor[2]) | (ord($descriptor[3]) << 8); + $width = ord($descriptor[4]) | (ord($descriptor[5]) << 8); + $height = ord($descriptor[6]) | (ord($descriptor[7]) << 8); + $this->validateDimensions($width, $height); + + if ($left + $width > $canvasWidth || $top + $height > $canvasHeight) { + throw new InvalidImageInputException('GIF first frame is outside its logical screen.'); + } + + return; + } + + throw new InvalidImageInputException('Invalid GIF image content.'); + } + + /** + * Method skipGifSubBlocks + * + * Advances over a GIF extension's length-prefixed blocks without decoding it. + * + * @access private + * + * @param string $contents the bounded binary GIF source + * @param int $offset the first sub-block offset + * + * @return int the offset after the terminating zero-length block + * + * @throws InvalidImageInputException if an extension block is truncated + */ + private function skipGifSubBlocks(string $contents, int $offset): int + { + $length = strlen($contents); + + while ($offset < $length) { + $blockLength = ord($contents[$offset++]); + if (0 === $blockLength) { + return $offset; + } + + $offset += $blockLength; + } + + throw new InvalidImageInputException('Invalid GIF image content.'); + } + // #endregion +} diff --git a/src/Shared/MODULE.md b/src/Shared/MODULE.md index 62d799bad..0600b2cad 100644 --- a/src/Shared/MODULE.md +++ b/src/Shared/MODULE.md @@ -55,6 +55,19 @@ Failed download/validation retains the installed file. `--check` reports validit ### Outbound Port -> Adapter +`SpreadsheetSafeTextPort` protects formula prefixes, leading control/space text and +literal apostrophes in spreadsheet-facing CSV. A versioned `_fireguard_text_encoding` +column permits the Import parser to restore original text only for explicitly marked +rows; unmarked files retain their historical literal values and trimming rules. +Typed numeric columns bypass text protection. Marked CSV uses RFC4180 quote escaping. + +`ImageInputValidationPort` checks compressed bytes (5 MiB), native header dimensions +(4096 pixels per axis) and the total input budget (4,194,304 pixels) before GD decodes +avatars or organization logos. GIF preflight also checks the first image descriptor +and its containment within the logical screen, independently of GD's version. +Their image manager disables animation decoding and +uses only the native first frame. Invalid headers and over-budget input return 422. + Application code invokes a contract; an infrastructure adapter implements the external operation. Dependency ownership stays inward even when runtime calls go outward. ```mermaid diff --git a/src/User/Infrastructure/Image/AvatarResizer.php b/src/User/Infrastructure/Image/AvatarResizer.php index ac28628db..de22ffcb7 100644 --- a/src/User/Infrastructure/Image/AvatarResizer.php +++ b/src/User/Infrastructure/Image/AvatarResizer.php @@ -4,9 +4,12 @@ namespace User\Infrastructure\Image; +use Intervention\Image\Drivers\Gd\Decoders\BinaryImageDecoder; use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\Exceptions\DecoderException; use Intervention\Image\ImageManager; -use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Application\Contract\Image\InvalidImageInputException; +use Shared\Application\Port\Outbound\{FileStoragePort, ImageInputValidationPort}; use function sprintf; @@ -66,9 +69,11 @@ * @since 1.0.0 * * @param FileStoragePort $fileStorage port used to persist image variants + * @param ImageInputValidationPort $imageInputValidation bounds the source before decoding */ public function __construct( private FileStoragePort $fileStorage, + private ImageInputValidationPort $imageInputValidation, ) { } // #endregion @@ -78,7 +83,8 @@ public function __construct( * Method resize. * * Generates all size variants from the raw source image - * contents and stores each one as a WebP file. + * contents and stores each one as a WebP file. Validation, single-frame + * decoding and all encodings succeed before existing variants are replaced. * * @since 1.0.0 * @@ -86,19 +92,31 @@ public function __construct( * @param string $sourceContents the raw binary content of the uploaded image * * @return void no return value + * + * @throws InvalidImageInputException if the source is invalid or exceeds the image budget */ public function resize(string $userId, string $sourceContents): void { - $manager = new ImageManager(new Driver()); + $this->imageInputValidation->validate($sourceContents); + $manager = new ImageManager(new Driver(), decodeAnimation: false); + + try { + $sourceImage = $manager->read($sourceContents, BinaryImageDecoder::class); + } catch (DecoderException $exception) { + throw new InvalidImageInputException('Unable to decode the uploaded image.', previous: $exception); + } + $variants = []; foreach (self::SIZES as $size) { - $image = $manager->read($sourceContents); + $image = clone $sourceImage; $image->cover($size, $size); - $encoded = $image->toWebp(self::WEBP_QUALITY); + $variants[$size] = (string) $image->toWebp(self::WEBP_QUALITY); + } + foreach ($variants as $size => $contents) { $this->fileStorage->write( path: sprintf('%s/%s/%d.webp', self::STORAGE_BASE, $userId, $size), - contents: (string) $encoded, + contents: $contents, ); } } diff --git a/src/User/MODULE.md b/src/User/MODULE.md index d2965bdbb..1af88c223 100644 --- a/src/User/MODULE.md +++ b/src/User/MODULE.md @@ -42,6 +42,16 @@ frontend's localized typed registries are the source of these values). ## Flows +Avatar uploads accept JPEG, PNG, WebP and GIF sources of at most 5 MiB, 4,096 pixels +per axis and 4,194,304 pixels in total. Header validation runs before GD allocates +the source raster. Animation decoding is disabled; animated GIF uploads retain the +first frame as a static WebP. GIF logical-screen and first-frame dimensions are +validated independently before decoding; the first frame must fit its screen. +The source is decoded once for all four variants, +and all variants are encoded before replacing existing files. Invalid geometry +or malformed image content returns HTTP 422 without deleting the current avatar +or updating its URL. + Creation and email-verification INFO logs retain only the technical user identifier. They omit email addresses and usernames; account events and notification delivery contracts retain their existing behavior. @@ -358,6 +368,7 @@ Presence endpoints require authentication (401). Missing, null or non-boolean server error, while realtime delivery failures do not change a successful response. - `EmailOwnershipUnavailableException` -> inactive/missing account or changed address (403, `email_ownership_unavailable`) +- `InvalidImageInputException` -> avatar source exceeds the byte/dimension/pixel budget or cannot be decoded (422) - `UserAlreadyExistsException` -> user already exists - `UserNotFoundException` -> user not found diff --git a/src/User/Presentation/Api/Processor/User/UploadUserAvatarProcessor.php b/src/User/Presentation/Api/Processor/User/UploadUserAvatarProcessor.php index c2b53900f..8e3666f56 100644 --- a/src/User/Presentation/Api/Processor/User/UploadUserAvatarProcessor.php +++ b/src/User/Presentation/Api/Processor/User/UploadUserAvatarProcessor.php @@ -8,6 +8,7 @@ use ApiPlatform\State\ProcessorInterface; use DateTimeInterface; use Shared\Application\Port\Inbound\{CommandBusPort, QueryBusPort}; +use Shared\Application\Port\Outbound\ImageInputValidationPort; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -51,7 +52,7 @@ * * @var int */ - private const int MAX_FILE_SIZE = 5 * 1024 * 1024; + private const int MAX_FILE_SIZE = ImageInputValidationPort::MAX_BYTES; /** * Constant ALLOWED_MIME_TYPES. @@ -143,12 +144,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = ); } - $sourceContents = file_get_contents($file->getPathname()); + $sourceContents = file_get_contents($file->getPathname(), length: self::MAX_FILE_SIZE + 1); if (false === $sourceContents) { throw new UnprocessableEntityHttpException('Failed to read the uploaded file.'); } - $this->avatarResizer->delete($id); $this->avatarResizer->resize($id, $sourceContents); // Append an upload-time version token so the canonical URL changes on each diff --git a/templates/notification/email/default.html.twig b/templates/notification/email/default.html.twig index d7d0841bf..54981b26d 100644 --- a/templates/notification/email/default.html.twig +++ b/templates/notification/email/default.html.twig @@ -1,12 +1,11 @@ {% extends 'notification/email/layout.html.twig' %} -{# Generic notification email. The notification itself sits in a card; the heading and the note around it do not. - `body` is trusted markup supplied by the notifying module (it is the in-app notification text). #} +{# Generic notification email. Bodies are plain text unless the channel explicitly opts into trusted HTML. #} {% block content %}

{{ subject }}

- +

You are receiving this email because of your notification settings in Fireguard. You can change which notifications you receive from your Fireguard account.

diff --git a/tests/Architecture/Unit/InterventionAuthorizationEnforcementTest.php b/tests/Architecture/Unit/InterventionAuthorizationEnforcementTest.php index a7dbe177b..9c62cdf44 100644 --- a/tests/Architecture/Unit/InterventionAuthorizationEnforcementTest.php +++ b/tests/Architecture/Unit/InterventionAuthorizationEnforcementTest.php @@ -201,6 +201,8 @@ private function authorizationPolicySource(string $handler, string $contents): ? $requiredCalls = match ($handler) { 'WriteTimeEntryHandler' => ['assertWrite'], 'ListTimeEntriesHandler' => ['actor', 'canManage'], + 'ListTimeEntryVersionsHandler' => ['actor', 'canManage'], + 'GetTimeEntryHandler' => ['actor', 'canManage'], default => [], }; if ([] === $requiredCalls || !str_contains($contents, 'InterventionTimeAccessPolicy')) { diff --git a/tests/Architecture/Unit/ModuleBoundaryConfigurationTest.php b/tests/Architecture/Unit/ModuleBoundaryConfigurationTest.php index 6a9b211f9..febddc405 100644 --- a/tests/Architecture/Unit/ModuleBoundaryConfigurationTest.php +++ b/tests/Architecture/Unit/ModuleBoundaryConfigurationTest.php @@ -23,6 +23,51 @@ */ final class ModuleBoundaryConfigurationTest extends TestCase { + /** + * Method vendorImageNamespaceDoesNotGrantAccessToPrivateInterventionClasses + * + * Proves the exact Composer-owned vendor prefix is uncollected while internal + * image infrastructure and existing Intervention records remain private. + * + * @access public + * + * @return void + */ + #[Test] + public function vendorImageNamespaceDoesNotGrantAccessToPrivateInterventionClasses(): void + { + $root = dirname(__DIR__, 3); + $config = new DeptracConfig(); + /** @var callable(DeptracConfig): void $configure */ + $configure = require $root . '/tests/Architecture/deptrac/modules.php'; + $configure($config); + /** @var array{layers: array}>, ruleset: array>} $definition */ + $definition = $config->toArray(); + $public = $definition['layers']['InterventionPublic']['collectors'][0]['value']; + $private = $definition['layers']['InterventionPrivate']['collectors'][0]['value']; + + foreach ([ + 'Intervention\\Image\\ImageManager', + 'Intervention\\Image\\Drivers\\Gd\\Driver', + 'Intervention\\Image\\Drivers\\Gd\\Decoders\\BinaryImageDecoder', + 'Intervention\\Image\\Exceptions\\DecoderException', + ] as $vendorClass) { + self::assertSame(0, preg_match('/' . $public . '/', $vendorClass)); + self::assertSame(0, preg_match('/' . $private . '/', $vendorClass)); + } + + foreach ([ + 'Intervention\\Infrastructure\\Image\\PrivateRecord', + 'Intervention\\Infrastructure\\Persistence\\Doctrine\\Record\\InterventionRecord', + ] as $privateClass) { + self::assertSame(0, preg_match('/' . $public . '/', $privateClass)); + self::assertSame(1, preg_match('/' . $private . '/', $privateClass)); + } + foreach (['UserPrivate', 'UserPublic', 'OrganizationPrivate', 'OrganizationPublic'] as $consumer) { + self::assertNotContains('InterventionPrivate', $definition['ruleset'][$consumer]); + } + } + #[Test] public function everyDocumentedModuleHasDisjointPublicAndPrivateCollectors(): void { diff --git a/tests/Architecture/deptrac/modules.baseline.yaml b/tests/Architecture/deptrac/modules.baseline.yaml index 3576858ba..d0849d471 100644 --- a/tests/Architecture/deptrac/modules.baseline.yaml +++ b/tests/Architecture/deptrac/modules.baseline.yaml @@ -1439,9 +1439,6 @@ deptrac: - Shared\Infrastructure\DataFixtures\SeedTimeline - Shared\Infrastructure\DataFixtures\SeedUuid - User\Infrastructure\DataFixtures\UserFixtures - Organization\Infrastructure\Image\OrganizationLogoResizer: - - Intervention\Image\Drivers\Gd\Driver - - Intervention\Image\ImageManager Organization\Infrastructure\Persistence\Doctrine\Record\OrganizationRecord: - Equipment\Infrastructure\Persistence\Doctrine\Record\EquipmentRecord - Equipment\Infrastructure\Persistence\Doctrine\Record\TagRecord @@ -1720,9 +1717,6 @@ deptrac: - Shared\Infrastructure\DataFixtures\SeedUuid - Shared\Infrastructure\Service\UuidEventIdProvider - Shared\Infrastructure\Symfony\Adapter\Outbound\UuidGeneratorAdapter - User\Infrastructure\Image\AvatarResizer: - - Intervention\Image\Drivers\Gd\Driver - - Intervention\Image\ImageManager User\Infrastructure\Persistence\Doctrine\Record\UserRecord: - Authorization\Infrastructure\Persistence\Doctrine\Record\RoleRecord User\Infrastructure\Persistence\Doctrine\Repository\UserRepository: diff --git a/tests/Architecture/deptrac/modules.php b/tests/Architecture/deptrac/modules.php index 4e14cf9ad..36a705f1f 100644 --- a/tests/Architecture/deptrac/modules.php +++ b/tests/Architecture/deptrac/modules.php @@ -21,6 +21,10 @@ $surface = '(?:Application\\(?:Port|Contract|Message)\\|Domain\\)'; } $prefix = '^' . preg_quote($module, '/') . '\\'; + if ('Intervention' === $module) { + // Composer resolves this more-specific prefix to intervention/image in vendor. + $prefix .= '(?!Image\\)'; + } $public[$module] = Layer::withName($module . 'Public')->collectors(ClassLikeConfig::create($prefix . $surface)); $private[$module] = Layer::withName($module . 'Private')->collectors(ClassLikeConfig::create($prefix . '(?!' . $surface . ').*')); $config->layers($public[$module], $private[$module]); diff --git a/tests/Functional/Api/InventoryApiTest.php b/tests/Functional/Api/InventoryApiTest.php index 3c4a38aab..8b59ca7a6 100644 --- a/tests/Functional/Api/InventoryApiTest.php +++ b/tests/Functional/Api/InventoryApiTest.php @@ -6,11 +6,11 @@ use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use Intervention\Infrastructure\Persistence\Doctrine\Record\InterventionRecord; +use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionRecord, InterventionWorkItemRecord}; use Inventory\Application\Contract\Stock\{InventoryReceiptRequest,InventoryReceiptReturnRequest}; use Inventory\Application\Port\Inbound\{InventoryInterventionResourcesPort, InventoryStockReceiptPort}; use Organization\Infrastructure\Persistence\Doctrine\Record\{OrganizationMemberRecord, OrganizationMemberRoleRecord, OrganizationRecord, OrganizationRoleRecord}; -use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\{DataProvider, Test}; use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; @@ -35,6 +35,74 @@ final class InventoryApiTest extends WebTestCase private int $operation = 100; + /** + * @return iterable + */ + public static function retainedConsumptionScopes(): iterable + { + yield 'abandoned confirmed consumption' => [true, false]; + yield 'abandoned pending consumption' => [false, false]; + yield 'prepared task confirmed consumption' => [true, true]; + yield 'prepared task pending consumption' => [false, true]; + } + + #[Test] + #[DataProvider('retainedConsumptionScopes')] + public function physicalFactsRetainTheirWorkAndRemainReturnableOrReconcilable(bool $confirmed, bool $targetTask): void + { + $client = $this->client(); + [$part, $warehouse] = $this->catalog($client); + $this->correct($client, $part, $warehouse, '2', '3'); + $work = $this->manager()->find(InterventionRecord::class, self::INTERVENTION); + self::assertInstanceOf(InterventionRecord::class, $work); + $work->type = 'corrective_maintenance'; + $taskId = null; + if ($targetTask) { + $work->status = 'draft'; + $task = new InterventionWorkItemRecord(); + $taskId = $this->op(); + $task->id = $taskId; + $task->intervention = $work; + $task->action = 'inventory'; + $task->status = 'planned'; + $task->source = 'planned'; + $task->createdAt = $task->updatedAt = new DateTimeImmutable(); + $this->manager()->persist($task); + $this->manager()->flush(); + } + $this->manager()->flush(); + $payload = $this->consumption($part, $warehouse, $confirmed ? '1' : '3'); + $payload['workItemId'] = $taskId; + $declaration = $this->request($client, 'POST', 'inventory-consumptions', $payload, 201); + self::assertSame($confirmed ? 'confirmed' : 'received_pending', $declaration['status']); + self::assertIsString($declaration['id']); + if (!$targetTask) { + $this->workflowRequest($client, 'PATCH', '/api/interventions/' . self::INTERVENTION, 1, ['status' => 'abandoned']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + $path = $targetTask ? '/api/intervention-work-items/' . $taskId : '/api/interventions/' . self::INTERVENTION; + $before = $this->balance($client); + $this->workflowRequest($client, 'DELETE', $path, $targetTask ? 1 : 2); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $retained = $this->request($client, 'GET', 'inventory-consumptions/' . $declaration['id']); + self::assertSame($declaration['status'], $retained['status']); + self::assertSame($declaration['movementId'], $retained['movementId']); + self::assertSame($before['quantity'], $this->balance($client)['quantity']); + if ($confirmed) { + $this->request($client, 'POST', 'inventory-returns', ['clientOperationId' => $this->op(), 'consumptionId' => $declaration['id'], 'quantity' => '1', 'reason' => 'Unused after abandonment'], 201); + self::assertSame('2.000000', $this->balance($client)['quantity']); + // Even a fully returned issue is a retained immutable history. + $this->workflowRequest($client, 'DELETE', $path, $targetTask ? 1 : 2); + self::assertSame(409, $client->getResponse()->getStatusCode()); + } else { + $this->correct($client, $part, $warehouse, '1', '3'); + $resolved = $this->request($client, 'POST', 'inventory-consumptions/' . $declaration['id'] . '/reconcile', [], 200); + self::assertSame('confirmed', $resolved['status']); + self::assertSame($declaration['id'], $resolved['id']); + self::assertSame('0.000000', $this->balance($client)['quantity']); + } + } + #[Test] public function supplierReturnsAreValuedAtCurrentCumpWhileBoundedByOriginalQuantity(): void { @@ -406,6 +474,14 @@ private function op(): string return 'beb10000-0000-4000-8000-' . str_pad((string) ++$this->operation, 12, '0', STR_PAD_LEFT); } + /** + * @param array|null $body workflow payload + */ + private function workflowRequest(KernelBrowser $client, string $method, string $path, int $revision, ?array $body = null): void + { + $client->request($method, $path, server: ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json', 'HTTP_IF_MATCH' => '"revision-' . $revision . '"'], content: null === $body ? null : json_encode($body, JSON_THROW_ON_ERROR)); + } + private function manager(): EntityManagerInterface { $em = self::getContainer()->get('doctrine.orm.main_entity_manager'); diff --git a/tests/Functional/Api/MaintenanceCostApiTest.php b/tests/Functional/Api/MaintenanceCostApiTest.php index 6409edc08..4804a26b7 100644 --- a/tests/Functional/Api/MaintenanceCostApiTest.php +++ b/tests/Functional/Api/MaintenanceCostApiTest.php @@ -14,6 +14,10 @@ use PHPUnit\Framework\Attributes\{DataProvider, Test}; use Shared\Application\Port\Inbound\CommandBusPort; use Symfony\Bundle\FrameworkBundle\{KernelBrowser, Test\WebTestCase}; +use Tests\Support\Auth\InteractiveTokenFactory; +use Tests\Support\Factory\UserTestFactory; +use User\Application\Port\Outbound\UserRepositoryPort; +use User\Domain\ValueObject\UserId; use function json_decode; use function json_encode; @@ -38,6 +42,69 @@ final class MaintenanceCostApiTest extends WebTestCase private const string TASK = '750e8400-e29b-41d4-a716-448040000021'; + #[Test] + public function draftExpenseWithoutTimeRetainsItsInterventionAndReportTotal(): void + { + $client = $this->client(); + $this->ownerBearerSession($client); + $work = $this->main()->find(InterventionRecord::class, self::WORK); + self::assertInstanceOf(InterventionRecord::class, $work); + $work->createdAt = new DateTimeImmutable('2026-01-01T00:00:00Z'); + $this->main()->flush(); + $expenseId = $this->seedExpense(); + $client->request('DELETE', '/api/interventions/' . self::WORK, server: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $cost = $this->request($client, 'GET'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsArray($cost['current']); + self::assertSame('50.000001', $cost['current']['total']); + self::assertIsArray($cost['current']['items']); + self::assertCount(1, $cost['current']['items']); + self::assertIsArray($cost['current']['items'][0]); + self::assertSame($expenseId, $cost['current']['items'][0]['sourceId']); + $client->request('GET', '/api/organizations/' . self::ORG . '/maintenance-cost/reports?from=2026-01-01&to=2026-01-31', server: ['HTTP_ACCEPT' => 'application/ld+json']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $report = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR); + self::assertIsArray($report); + self::assertSame(1, $report['interventionCount']); + self::assertIsArray($report['current']); + self::assertSame('50.000001', $report['current']['total']); + } + + #[Test] + public function taskExpenseRetainsItsPreparedTask(): void + { + $client = $this->client(); + $this->ownerBearerSession($client); + $expenseId = $this->seedExpense(); + $client->request('DELETE', '/api/intervention-work-items/' . self::TASK, server: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $cost = $this->request($client, 'GET'); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsArray($cost['current']); + self::assertIsArray($cost['current']['items']); + self::assertIsArray($cost['current']['items'][0]); + self::assertSame($expenseId, $cost['current']['items'][0]['sourceId']); + self::assertSame(self::TASK, $cost['current']['items'][0]['workItemId']); + } + + #[Test] + public function preparedFinancialResourcesRetainTheirTaskAndParent(): void + { + $client = $this->client(); + $this->ownerBearerSession($client); + $this->request($client, 'PATCH', '/planning', ['resources' => [['workItemId' => self::TASK, 'kind' => 'external', 'description' => 'Prepared specialist visit', 'amount' => '30']]], ['HTTP_IF_MATCH' => '"revision-0"']); + self::assertSame(200, $client->getResponse()->getStatusCode()); + foreach (['/api/intervention-work-items/' . self::TASK, '/api/interventions/' . self::WORK] as $path) { + $client->request('DELETE', $path, server: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + } + $planning = $this->costStore()->planning(self::ORG, self::WORK); + self::assertSame(1, $planning->revision); + self::assertSame(self::TASK, $planning->resources[0]['workItemId']); + self::assertSame('30.000000', $planning->resources[0]['amount']); + } + #[Test] public function anEmptyInterventionHasAKnownZeroCost(): void { @@ -490,6 +557,15 @@ private function costStore(): MaintenanceCostStorePort return $store; } + private function ownerBearerSession(KernelBrowser $client): void + { + $client->disableReboot(); + $users = $this->createStub(UserRepositoryPort::class); + $users->method('findById')->willReturnCallback(static fn (UserId $id) => UserTestFactory::createActive((string) $id, (string) $id . '@example.com')); + self::getContainer()->set(UserRepositoryPort::class, $users); + $client->setServerParameter('HTTP_AUTHORIZATION', 'Bearer ' . InteractiveTokenFactory::issue(self::getContainer(), self::OWNER, self::OWNER . '@example.com')); + } + private function main(): EntityManagerInterface { /** @var EntityManagerInterface $em */ diff --git a/tests/Functional/Api/MaintenancePlanApiTest.php b/tests/Functional/Api/MaintenancePlanApiTest.php index f85186bd5..0cbeae688 100644 --- a/tests/Functional/Api/MaintenancePlanApiTest.php +++ b/tests/Functional/Api/MaintenancePlanApiTest.php @@ -38,6 +38,55 @@ final class MaintenancePlanApiTest extends WebTestCase private ?string $loggedUserId = null; + #[Test] + public function abandonedOccurrenceWorkCannotBeDeletedAndRetriesKeepItsOriginalIdentity(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $input = $this->input('maintenance', 'P1M'); + $input['active'] = true; + $plan = $this->request($client, 'POST', '/plans', $input); + self::assertSame(201, $client->getResponse()->getStatusCode()); + self::assertIsString($plan['id']); + $this->request($client, 'POST', '/plans/activate'); + $first = $this->request($client, 'POST', '/plans/' . $plan['id'] . '/generate', []); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertIsString($first['interventionId']); + $before = $this->request($client, 'GET', '/plans/' . $plan['id']); + self::assertIsArray($before['openOccurrence']); + $taskId = $this->main()->getConnection()->fetchOne('SELECT id FROM intervention_work_items WHERE intervention_id = :id', ['id' => $first['interventionId']]); + self::assertIsString($taskId); + $this->request($client, 'DELETE', '/api/intervention-work-items/' . $taskId, headers: ['HTTP_IF_MATCH' => '"revision-1"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $currentWork = $this->request($client, 'GET', '/api/interventions/' . $first['interventionId']); + self::assertIsInt($currentWork['revision']); + $abandoned = $this->request($client, 'PATCH', '/api/interventions/' . $first['interventionId'], ['status' => 'abandoned', 'responsible' => '780e8402' . substr(self::ADMIN, 8)], ['HTTP_IF_MATCH' => '"revision-' . $currentWork['revision'] . '"']); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame('abandoned', $abandoned['status']); + self::assertIsInt($abandoned['revision']); + $this->request($client, 'DELETE', '/api/interventions/' . $first['interventionId'], headers: ['HTTP_IF_MATCH' => '"revision-' . $abandoned['revision'] . '"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $replay = $this->request($client, 'POST', '/plans/' . $plan['id'] . '/generate', []); + self::assertTrue($replay['replayed']); + self::assertSame($first['interventionId'], $replay['interventionId']); + $retry = $this->request($client, 'POST', '/plans/' . $plan['id'] . '/generate', ['retry' => true]); + self::assertSame(200, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + self::assertSame($first['occurrenceId'], $retry['occurrenceId']); + self::assertIsString($retry['interventionId']); + self::assertNotSame($first['interventionId'], $retry['interventionId']); + $after = $this->request($client, 'GET', '/plans/' . $plan['id']); + self::assertIsArray($after['openOccurrence']); + self::assertSame($before['openOccurrence']['dueAt'], $after['openOccurrence']['dueAt']); + self::assertSame(2, $after['openOccurrence']['attempt']); + $newWork = $this->request($client, 'GET', '/api/interventions/' . $retry['interventionId']); + self::assertSame(200, $client->getResponse()->getStatusCode()); + self::assertSame('draft', $newWork['status']); + $this->request($client, 'DELETE', '/api/interventions/' . $first['interventionId'], headers: ['HTTP_IF_MATCH' => '"revision-' . $abandoned['revision'] . '"']); + self::assertSame(409, $client->getResponse()->getStatusCode(), 'Previous task occurrence identities retain the old attempt after retry.'); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM maintenance_occurrences WHERE organization_id = :org', ['org' => self::ORG])); + } + #[Test] public function preparePreviewEnableGenerateAndReplayKeepIndependentOperations(): void { @@ -273,10 +322,11 @@ public function invalidCadenceAndUnknownEquipmentAreRejected(): void /** * @param array|null $body + * @param array $headers request preconditions * * @return array */ - private function request(KernelBrowser &$client, string $method, string $path, ?array $body = null): array + private function request(KernelBrowser &$client, string $method, string $path, ?array $body = null, array $headers = []): array { self::ensureKernelShutdown(); $client = static::createClient(); @@ -284,7 +334,7 @@ private function request(KernelBrowser &$client, string $method, string $path, ? $this->login($client, $this->loggedUserId); } $url = str_starts_with($path, '/api/') ? $path : '/api/organizations/' . self::ORG . '/maintenance' . $path; - $client->request($method, $url, server: ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: null === $body ? null : json_encode((object) $body, JSON_THROW_ON_ERROR)); + $client->request($method, $url, server: $headers + ['CONTENT_TYPE' => 'PATCH' === $method ? 'application/merge-patch+json' : 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'], content: null === $body ? null : json_encode((object) $body, JSON_THROW_ON_ERROR)); $content = (string) $client->getResponse()->getContent(); if ('' === $content) { diff --git a/tests/Functional/Api/OnboardingSetupApiTest.php b/tests/Functional/Api/OnboardingSetupApiTest.php index e92ff2f90..7729ee529 100644 --- a/tests/Functional/Api/OnboardingSetupApiTest.php +++ b/tests/Functional/Api/OnboardingSetupApiTest.php @@ -138,16 +138,46 @@ public function aPartialFacilityBatchResumesOnlyRemainingItemsAndEquipmentAssign self::assertResponseStatusCodeSame(201); $this->call($client, 'POST', '/api/onboarding/organization/steps/create_first_facility/execute'); self::assertResponseStatusCodeSame(200); - $equipment = ['type' => 'fire_extinguisher', 'facility' => '/api/facilities/' . $facility]; + $equipment = [ + 'type' => 'fire_extinguisher', + 'facility' => '/api/facilities/' . $facility, + 'name' => 'Entrance extinguisher', + 'assetCode' => 'FG-001', + 'criticality' => 'high', + 'technicalProperties' => [['key' => 'capacity', 'value' => '6', 'unit' => 'kg']], + ]; $this->prepare($client, $session, 'create_first_equipment', [['itemKey' => 'equipment', 'payload' => $equipment]]); $this->call($client, 'POST', '/api/organizations/' . $org . '/equipment', [...$equipment, 'onboardingSessionId' => $session, 'onboardingItemKey' => 'equipment']); self::assertResponseStatusCodeSame(201); $created = $this->body($client); self::assertSame($facility, $created['facilityId']); + foreach (['name', 'assetCode', 'criticality', 'technicalProperties'] as $field) { + self::assertSame($equipment[$field], $created[$field]); + } $this->call($client, 'POST', '/api/organizations/' . $org . '/equipment', [...$equipment, 'onboardingSessionId' => $session, 'onboardingItemKey' => 'equipment']); self::assertResponseStatusCodeSame(201); self::assertSame($created['id'], $this->body($client)['id']); self::assertSame($created['installedAt'], $this->body($client)['installedAt']); + $changes = [ + 'name' => 'Different extinguisher', + 'assetCode' => 'FG-002', + 'criticality' => 'critical', + 'technicalProperties' => [['key' => 'capacity', 'value' => '9', 'unit' => 'kg']], + ]; + foreach ($changes as $field => $value) { + $this->call($client, 'POST', '/api/organizations/' . $org . '/equipment', [ + ...$equipment, + $field => $value, + 'onboardingSessionId' => $session, + 'onboardingItemKey' => 'equipment', + ]); + self::assertResponseStatusCodeSame(409); + } + $manager = static::getContainer()->get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $manager); + $equipmentCount = $manager->getConnection()->fetchOne('SELECT COUNT(*) FROM equipment WHERE organization_id = ?', [$org]); + self::assertTrue(is_int($equipmentCount) || is_string($equipmentCount)); + self::assertSame(1, (int) $equipmentCount); } #[Test] diff --git a/tests/Functional/Api/ProcurementApiTest.php b/tests/Functional/Api/ProcurementApiTest.php index fcb4cfb39..b8260f572 100644 --- a/tests/Functional/Api/ProcurementApiTest.php +++ b/tests/Functional/Api/ProcurementApiTest.php @@ -16,6 +16,7 @@ use function array_column; use function json_decode; use function json_encode; +use function str_repeat; use function strtoupper; use function substr; @@ -50,6 +51,86 @@ final class ProcurementApiTest extends WebTestCase private ?string $loggedUserId = null; + #[Test] + public function creationRetriesReuseSavedSuppliersAndDraftsAndRejectChangedDeclarations(): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $supplierInput = ['clientOperationId' => self::OPERATION, 'name' => 'Retry supplier', 'contacts' => [['name' => 'Buyer', 'email' => 'buyer@example.com']]]; + $firstSupplier = $this->request($client, 'POST', '/suppliers', $supplierInput); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $supplierInput['clientOperationId'] = strtoupper(self::OPERATION); + $supplierReplay = $this->request($client, 'POST', '/suppliers', $supplierInput); + self::assertSame(201, $client->getResponse()->getStatusCode()); + self::assertSame($firstSupplier['id'], $supplierReplay['id']); + self::assertTrue($supplierReplay['replayed']); + self::assertSame(1, $supplierReplay['revision']); + $supplierInput['contacts'][0]['email'] = 'changed@example.com'; + $this->request($client, 'POST', '/suppliers', $supplierInput); + self::assertSame(409, $client->getResponse()->getStatusCode()); + $orderInput = ['clientOperationId' => self::SECOND_OPERATION, 'name' => 'Retry draft', 'supplierId' => $this->id($firstSupplier), 'lines' => [['kind' => 'part', 'partId' => self::PART, 'quantity' => '2', 'unitCost' => '3']]]; + $firstOrder = $this->request($client, 'POST', '/orders', $orderInput); + self::assertSame(201, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + $orderReplay = $this->request($client, 'POST', '/orders', $orderInput); + self::assertSame(201, $client->getResponse()->getStatusCode()); + self::assertSame($firstOrder['id'], $orderReplay['id']); + self::assertSame($firstOrder['lines'], $orderReplay['lines']); + self::assertTrue($orderReplay['replayed']); + self::assertSame(1, $orderReplay['revision']); + $orderInput['lines'][0]['unitCost'] = '4'; + $this->request($client, 'POST', '/orders', $orderInput); + self::assertSame(409, $client->getResponse()->getStatusCode()); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_suppliers WHERE organization_id = ?', [self::ORG])); + self::assertSame(1, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_orders WHERE organization_id = ?', [self::ORG])); + self::assertSame(2, $this->main()->getConnection()->fetchOne('SELECT COUNT(*) FROM procurement_operations WHERE organization_id = ?', [self::ORG])); + $this->login($client, self::READER); + $this->request($client, 'POST', '/orders', $orderInput); + self::assertSame(403, $client->getResponse()->getStatusCode()); + $this->login($client, self::OUTSIDER); + $this->request($client, 'POST', '/orders', $orderInput); + self::assertSame(404, $client->getResponse()->getStatusCode()); + } + + /** + * @param int $length the public supplier reference length + * @param int $status the expected validation status + */ + #[Test] + #[DataProvider('supplierCodeLengths')] + public function supplierCodeBoundaryMatchesPersistenceForCreateAndPatch(int $length, int $status): void + { + $client = static::createClient(); + $this->seed(); + $this->login($client, self::ADMIN); + $code = str_repeat('C', $length); + $created = $this->request($client, 'POST', '/suppliers', ['name' => 'Code boundary', 'code' => $code]); + self::assertSame($status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + if (201 === $status) { + self::assertSame($code, $created['code']); + $supplierId = $this->id($created); + } else { + $supplierId = $this->id($this->supplier($client)); + } + $changed = $this->request($client, 'PATCH', '/suppliers/' . $supplierId, ['code' => $code], 1); + self::assertSame(201 === $status ? 200 : $status, $client->getResponse()->getStatusCode(), (string) $client->getResponse()->getContent()); + if (201 === $status) { + self::assertSame($code, $changed['code']); + self::assertSame($code, $this->main()->getConnection()->fetchOne('SELECT code FROM procurement_suppliers WHERE id = ?', [$supplierId])); + } + } + + /** + * @return iterable + */ + public static function supplierCodeLengths(): iterable + { + yield 'former limit' => [64, 201]; + yield 'above former limit' => [65, 201]; + yield 'public limit' => [80, 201]; + yield 'above public limit' => [81, 422]; + } + #[Test] public function suppliersRetainContactsAndHistoryWithOptimisticRevision(): void { diff --git a/tests/Functional/Api/WorkloadApiTest.php b/tests/Functional/Api/WorkloadApiTest.php index b5a4100e3..063eef941 100644 --- a/tests/Functional/Api/WorkloadApiTest.php +++ b/tests/Functional/Api/WorkloadApiTest.php @@ -7,6 +7,8 @@ use Auth\Infrastructure\Security\User\SecurityUser; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Intervention\Infrastructure\Persistence\Doctrine\Record\{InterventionTimeEntryRecord, InterventionTimeEntryVersionRecord}; +use Intervention\Infrastructure\Persistence\Doctrine\Repository\InterventionTimeEntryRepository; use Organization\Infrastructure\Adapter\Workforce\OrganizationWorkforceDirectoryAdapter; use Organization\Infrastructure\Persistence\Doctrine\Record\{OrganizationMemberRecord, OrganizationMemberRoleRecord, OrganizationRecord, OrganizationRoleRecord}; use PHPUnit\Framework\Attributes\Test; @@ -18,9 +20,12 @@ use function array_keys; use function array_unique; +use function count; use function json_decode; use function json_encode; +use function memory_get_usage; use function random_int; +use function range; use function sort; use const JSON_THROW_ON_ERROR; @@ -292,7 +297,9 @@ public function testTimeAfterPublicationIsIndependentVersionedAndIdempotent(): v self::assertResponseIsSuccessful(); $entry = $this->value($this->value($client), 'entry'); self::assertSame(2, $this->value($entry, 'revision')); - self::assertCount(2, $this->listValue($this->value($entry, 'versions'))); + self::assertCount(1, $this->listValue($this->value($entry, 'versions'))); + self::assertSame(2, $this->value($entry, 'totalVersions')); + self::assertSame(2, $this->value($entry, 'nextBeforeRevision')); $this->requestApi('PATCH', $path . '/' . $id, [...$correction, 'minutes' => 60], 1); self::assertResponseStatusCodeSame(412); $this->requestApi('DELETE', $path . '/' . $id, null, 2); @@ -301,7 +308,182 @@ public function testTimeAfterPublicationIsIndependentVersionedAndIdempotent(): v self::assertResponseIsSuccessful(); $entry = $this->value($this->value($client), 'entries', 0); self::assertTrue($this->value($entry, 'cancelled')); - self::assertCount(3, $this->listValue($this->value($entry, 'versions'))); + self::assertCount(1, $this->listValue($this->value($entry, 'versions'))); + self::assertSame(3, $this->value($entry, 'totalVersions')); + $client = $this->requestApi('GET', $path . '/' . $id . '/versions'); + self::assertResponseIsSuccessful(); + self::assertCount(3, $this->listValue($this->value($client, 'versions'))); + self::assertSame(3, $this->value($client, 'totalItems')); + self::assertNull($this->value($client, 'nextBeforeRevision')); + self::assertSame(3, $this->value($client, 'versions', 0, 'revision')); + self::assertSame(1, $this->value($client, 'versions', 2, 'revision')); + } + + #[Test] + public function testTimeJournalAndHistoryStayBoundedAndEveryRetainedRowIsReachable(): void + { + $this->seed(['*']); + $task = $this->seedTask(180, 'published', $this->memberId); + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $connection = $em->getConnection(); + $identifiers = []; + for ($index = 0; $index < 125; ++$index) { + $id = Uuid::v4()->toRfc4122(); + $identifiers[] = $id; + $connection->executeStatement( + 'INSERT INTO intervention_time_entries (id, work_item_id, organization_id, member_id, worked_on, minutes, note, cancelled, revision, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 65, ?, false, 65, ?, ?, NOW(), NOW())', + [$id, $task, $this->organizationId, $this->memberId, '2026-01-01', 'Volume journal', $this->memberId, $this->memberId], + ); + $connection->executeStatement( + 'INSERT INTO intervention_time_entry_versions (entry_id, revision, worked_on, minutes, note, cancelled, actor_id, recorded_at) SELECT ?, revision, ?, revision, ?, false, ?, NOW() FROM generate_series(1, 65) AS revision', + [$id, '2026-01-01', 'Volume journal', $this->memberId], + ); + } + $em->clear(); + $em->getClassMetadata(InterventionTimeEntryRecord::class); + $memoryBefore = memory_get_usage(); + $bounded = new InterventionTimeEntryRepository($em)->list($task, null, 1, 100); + self::assertCount(100, $bounded); + self::assertCount(100, $em->getUnitOfWork()->getIdentityMap()[InterventionTimeEntryRecord::class] ?? []); + self::assertSame([], $em->getUnitOfWork()->getIdentityMap()[InterventionTimeEntryVersionRecord::class] ?? []); + self::assertLessThan(8 * 1024 * 1024, memory_get_usage() - $memoryBefore, 'One journal page must not allocate its retained history.'); + $path = '/api/intervention-work-items/' . $task . '/time-entries'; + $default = $this->value($this->requestApi('GET', $path)); + self::assertResponseIsSuccessful(); + self::assertCount(30, $this->listValue($this->value($default, 'entries'))); + self::assertSame(125, $this->value($default, 'totalItems')); + $seen = []; + $page = 1; + do { + $body = $this->value($this->requestApi('GET', $path . '?page=' . $page . '&itemsPerPage=100')); + self::assertResponseIsSuccessful(); + foreach ($this->listValue($this->value($body, 'entries')) as $entry) { + $seen[] = $this->stringValue($entry, 'id'); + self::assertCount(1, $this->listValue($this->value($entry, 'versions'))); + self::assertSame(65, $this->value($entry, 'totalVersions')); + self::assertSame(65, $this->value($entry, 'nextBeforeRevision')); + } + $page = $this->value($body, 'nextPage'); + if (null !== $page) { + self::assertIsInt($page); + } + } while (null !== $page); + self::assertCount(125, array_unique($seen)); + $historyPath = $path . '/' . $identifiers[0] . '/versions'; + $current = $this->value($this->requestApi('GET', $path . '/' . $identifiers[0])); + self::assertResponseIsSuccessful(); + self::assertSame($identifiers[0], $this->value($current, 'entry', 'id')); + self::assertCount(1, $this->listValue($this->value($current, 'entry', 'versions'))); + $versions = []; + $cursor = null; + do { + $body = $this->value($this->requestApi('GET', $historyPath . '?itemsPerPage=10' . (null !== $cursor ? '&beforeRevision=' . $cursor : ''))); + self::assertResponseIsSuccessful(); + self::assertSame(65, $this->value($body, 'totalItems')); + $batch = $this->listValue($this->value($body, 'versions')); + self::assertLessThanOrEqual(10, count($batch)); + foreach ($batch as $version) { + $versions[] = $this->value($version, 'revision'); + } + $cursor = $this->value($body, 'nextBeforeRevision'); + if (null !== $cursor) { + self::assertIsInt($cursor); + } + } while (null !== $cursor); + self::assertSame(range(65, 1), $versions); + $client = $this->requestApi('POST', $path, ['id' => $identifiers[0], 'workedOn' => '2026-01-01', 'minutes' => 1, 'note' => 'Volume journal']); + self::assertResponseStatusCodeSame(201); + self::assertSame(65, $this->value($client, 'entry', 'revision')); + self::assertCount(1, $this->listValue($this->value($client, 'entry', 'versions'))); + $client = $this->requestApi('PATCH', $path . '/' . $identifiers[0], ['workedOn' => '2026-01-01', 'minutes' => 66, 'note' => 'Correction'], 65); + self::assertResponseIsSuccessful(); + self::assertSame(66, $this->value($client, 'entry', 'totalVersions')); + self::assertCount(1, $this->listValue($this->value($client, 'entry', 'versions'))); + $body = $this->value($this->requestApi('GET', $historyPath . '?beforeRevision=56&itemsPerPage=10')); + self::assertSame(55, $this->value($body, 'versions', 0, 'revision')); + self::assertSame(66, $this->value($body, 'totalItems')); + } + + #[Test] + public function testTimeManagerOwnOnlyFiltersBeforePaginationAndExactCounting(): void + { + $this->seed(['organization.interventions.time.manage']); + $task = $this->seedTask(60, 'published', $this->memberId); + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $otherMember = Uuid::v4()->toRfc4122(); + $ownIdentifiers = []; + for ($day = 1; $day <= 6; ++$day) { + $id = Uuid::v4()->toRfc4122(); + $beneficiary = $day <= 3 ? $this->memberId : $otherMember; + if ($day <= 3) { + $ownIdentifiers[] = $id; + } + $em->getConnection()->executeStatement( + 'INSERT INTO intervention_time_entries (id, work_item_id, organization_id, member_id, worked_on, minutes, cancelled, revision, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 60, false, 1, ?, ?, NOW(), NOW())', + [$id, $task, $this->organizationId, $beneficiary, '2026-01-0' . $day, $this->memberId, $this->memberId], + ); + } + $path = '/api/intervention-work-items/' . $task . '/time-entries'; + $first = $this->value($this->requestApi('GET', $path . '?ownOnly=true&itemsPerPage=2')); + self::assertResponseIsSuccessful(); + self::assertSame(3, $this->value($first, 'totalItems')); + self::assertSame(2, $this->value($first, 'nextPage')); + self::assertCount(2, $this->listValue($this->value($first, 'entries'))); + self::assertSame($ownIdentifiers[2], $this->value($first, 'entries', 0, 'id')); + self::assertSame($ownIdentifiers[1], $this->value($first, 'entries', 1, 'id')); + $second = $this->value($this->requestApi('GET', $path . '?ownOnly=true&itemsPerPage=2&page=2')); + self::assertResponseIsSuccessful(); + self::assertSame(3, $this->value($second, 'totalItems')); + self::assertNull($this->value($second, 'nextPage')); + self::assertCount(1, $this->listValue($this->value($second, 'entries'))); + self::assertSame($ownIdentifiers[0], $this->value($second, 'entries', 0, 'id')); + foreach (['?itemsPerPage=2', '?ownOnly=false&itemsPerPage=2'] as $filter) { + $all = $this->value($this->requestApi('GET', $path . $filter)); + self::assertResponseIsSuccessful(); + self::assertSame(6, $this->value($all, 'totalItems')); + self::assertSame($otherMember, $this->value($all, 'entries', 0, 'memberId')); + } + } + + #[Test] + public function testTimePaginationRejectsMalformedValuesAndHistoryPreservesBeneficiaryIsolation(): void + { + $this->seed([]); + $task = $this->seedTask(60, 'published', $this->memberId); + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get('doctrine.orm.main_entity_manager'); + $id = Uuid::v4()->toRfc4122(); + $em->getConnection()->executeStatement( + 'INSERT INTO intervention_time_entries (id, work_item_id, organization_id, member_id, worked_on, minutes, cancelled, revision, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 60, false, 1, ?, ?, NOW(), NOW())', + [$id, $task, $this->organizationId, Uuid::v4()->toRfc4122(), '2026-01-01', $this->memberId, $this->memberId], + ); + $path = '/api/intervention-work-items/' . $task . '/time-entries'; + foreach (['page=0', 'page=-1', 'page=1.5', 'page[]=1', 'itemsPerPage=0', 'itemsPerPage=101', 'itemsPerPage=abc', 'ownOnly=invalid', 'ownOnly[]=true', 'ownOnly='] as $filter) { + $this->requestApi('GET', $path . '?' . $filter); + self::assertResponseStatusCodeSame(400); + } + foreach (['beforeRevision=0', 'beforeRevision=-1', 'beforeRevision=1.5', 'beforeRevision[]=1', 'itemsPerPage=101'] as $filter) { + $this->requestApi('GET', $path . '/' . $id . '/versions?' . $filter); + self::assertResponseStatusCodeSame(400); + } + $body = $this->value($this->requestApi('GET', $path)); + self::assertSame(0, $this->value($body, 'totalItems')); + $body = $this->value($this->requestApi('GET', $path . '?ownOnly=false')); + self::assertResponseIsSuccessful(); + self::assertSame(0, $this->value($body, 'totalItems')); + self::assertSame([], $this->value($body, 'entries')); + $this->requestApi('GET', $path . '/' . $id . '/versions'); + self::assertResponseStatusCodeSame(404); + $this->requestApi('GET', $path . '/' . $id); + self::assertResponseStatusCodeSame(404); + $otherTask = $this->seedTask(60, 'published', $this->memberId); + $this->requestApi('GET', '/api/intervention-work-items/' . $otherTask . '/time-entries/' . $id . '/versions'); + self::assertResponseStatusCodeSame(404); + $this->userId = Uuid::v4()->toRfc4122(); + $this->requestApi('GET', $path . '/' . $id . '/versions'); + self::assertResponseStatusCodeSame(404); } #[Test] diff --git a/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php b/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php new file mode 100644 index 000000000..621e2296d --- /dev/null +++ b/tests/Integration/Intervention/Infrastructure/Adapter/Workflow/InterventionRetentionConcurrencyTest.php @@ -0,0 +1,234 @@ +get('doctrine.orm.main_entity_manager'); + self::assertInstanceOf(EntityManagerInterface::class, $main); + $this->main = $main; + $this->a = $main->getConnection(); + $this->b = DriverManager::getConnection($this->a->getParams()); + $this->writer = new EntityManager($this->b, $main->getConfiguration()); + $this->clean(); + self::assertNotSame($this->a->fetchOne('SELECT pg_backend_pid()'), $this->b->fetchOne('SELECT pg_backend_pid()')); + $now = new DateTimeImmutable('2026-10-01T00:00:00Z'); + $organization = new OrganizationRecord(); + $organization->id = self::ORG; + $organization->name = 'Retention concurrency'; + $organization->slug = 'retention-concurrency'; + $organization->ownerUserId = $organization->createdByUserId = self::USER; + $organization->status = 'active'; + $organization->isActive = true; + $organization->createdAt = $organization->updatedAt = $now; + $main->persist($organization); + $member = new OrganizationMemberRecord(); + $member->id = 'bec50000-0000-4000-8000-000000000007'; + $member->organization = $organization; + $member->userId = self::USER; + $member->isActive = true; + $member->joinedAt = $now; + $main->persist($member); + $role = new OrganizationRoleRecord(); + $role->id = 'bec50000-0000-4000-8000-000000000010'; + $role->organization = $organization; + $role->name = 'retention-owner'; + $role->permissions = ['*']; + $role->isSystem = false; + $role->createdAt = $now; + $main->persist($role); + $assignment = new OrganizationMemberRoleRecord(); + $assignment->member = $member; + $assignment->role = $role; + $assignment->assignedAt = $now; + $main->persist($assignment); + $work = new InterventionRecord(); + $work->id = self::WORK; + $work->organization = $organization; + $work->name = 'Prepared retained work'; + $work->number = 1; + $work->type = 'corrective_maintenance'; + $work->status = 'draft'; + $work->responsibleId = $member->id; + $work->createdAt = $work->updatedAt = $now; + $main->persist($work); + $task = new InterventionWorkItemRecord(); + $task->id = self::TASK; + $task->intervention = $work; + $task->action = 'inventory'; + $task->status = 'planned'; + $task->source = 'planned'; + $task->createdAt = $task->updatedAt = $now; + $main->persist($task); + $main->flush(); + $store = new InventoryRepository($main); + $this->a->transactional(function () use ($store): void { + $store->saveReference('parts', new InventoryReference(self::PART, self::ORG, 'RET', 'Retained part', 'piece', 'part')); + $store->saveReference('warehouses', new InventoryReference(self::WAREHOUSE, self::ORG, 'RET', 'Retained warehouse')); + $store->saveBalance(new StockBalance('bec50000-0000-4000-8000-000000000008', self::ORG, self::PART, self::WAREHOUSE, '1.000000', '7.000000', 'EUR')); + }); + } + + protected function tearDown(): void + { + foreach ([$this->a, $this->b] as $connection) { + while ($connection->isTransactionActive()) { + $connection->rollBack(); + } + } + $this->a->executeStatement("SET lock_timeout = '0'"); + $this->clean(); + $this->b->close(); + parent::tearDown(); + } + + /** + * @return iterable + */ + public static function ownerScopes(): iterable + { + yield 'expense retains parent' => ['expense', false]; + yield 'expense retains task' => ['expense', true]; + yield 'stock retains parent' => ['stock', false]; + yield 'stock retains task' => ['stock', true]; + } + + #[Test] + #[DataProvider('ownerScopes')] + public function deletionWaitsForTheOwnerWriteThenRetainsCommittedFacts(string $kind, bool $taskScope): void + { + $this->b->beginTransaction(); + $work = new InterventionCostSourceFactsAdapter($this->writer); + $currency = new MaintenanceCurrencyAdapter($this->b); + $stock = new InventoryRepository($this->writer); + $inventory = new InventoryInterventionResourcesAdapter($stock, $currency); + $costs = new MaintenanceCostRepository($this->writer); + $calculator = new MaintenanceCostCalculator(); + $projection = new MaintenanceCostProjection($work, $inventory, $currency, new MaintenanceRateAdapter($this->b), $costs, $calculator); + $transactions = new DoctrineTransactionManagerAdapter($this->writer); + /** @var UuidGeneratorPort $ids */ + $ids = self::getContainer()->get(UuidGeneratorPort::class); + if ('expense' === $kind) { + /** @var MaintenanceCostAccessGuard $access */ + $access = self::getContainer()->get(MaintenanceCostAccessGuard::class); + /** @var ClockPort $clock */ + $clock = self::getContainer()->get(ClockPort::class); + $handler = new WriteMaintenanceCostHandler($access, $work, $costs, $currency, $projection, $calculator, $transactions, $ids, $clock); + $handler(new WriteMaintenanceCostCommand(self::USER, self::ORG, self::WORK, 'expense', ['clientId' => 'retention-expense', 'amount' => '7', 'description' => 'Retained specialist work', 'incurredAt' => '2026-10-01T10:00:00Z', 'workItemId' => $taskScope ? self::TASK : null])); + } else { + /** @var OrganizationAuthorizationPort $authorization */ + $authorization = self::getContainer()->get(OrganizationAuthorizationPort::class); + /** @var InterventionResourceGatewayPort $resources */ + $resources = self::getContainer()->get(InterventionResourceGatewayPort::class); + /** @var InterventionMemberPolicy $members */ + $members = self::getContainer()->get(InterventionMemberPolicy::class); + $context = new InterventionInventoryContextAdapter($this->writer, $authorization, $resources, $members); + $handler = new ApplyInventoryStockHandler($stock, $transactions, $ids, $currency, $context); + $result = $handler(new ApplyInventoryStockCommand(self::ORG, self::USER, 'consumption', 'bec50000-0000-4000-8000-000000000009', self::PART, self::WAREHOUSE, '1', new DateTimeImmutable('2026-10-01T10:00:00Z'), self::WORK, $taskScope ? self::TASK : null)); + self::assertNotNull($result->declaration); + self::assertSame('confirmed', $result->declaration->status); + } + /** @var InterventionWorkflowGatewayPort $gateway */ + $gateway = self::getContainer()->get(InterventionWorkflowGatewayPort::class); + $delete = new InterventionWorkflowMutation(resource: $taskScope ? 'work_item' : 'intervention', action: 'delete', userId: self::USER, id: $taskScope ? self::TASK : self::WORK, payload: [], expectedRevision: 1); + $this->a->executeStatement("SET lock_timeout = '150ms'"); + + try { + $gateway->mutate($delete); + self::fail('Deletion must wait for the owner transaction instead of passing an empty history check.'); + } catch (DriverException $exception) { + self::assertSame('55P03', $exception->getSQLState()); + } + $this->b->commit(); + $this->a->executeStatement("SET lock_timeout = '0'"); + + try { + $gateway->mutate($delete); + self::fail('The committed owner fact must retain its operational context.'); + } catch (InterventionConflictException $exception) { + self::assertStringContainsString('history must be retained', $exception->getMessage()); + } + self::assertSame(1, $this->a->fetchOne('SELECT COUNT(*) FROM interventions WHERE id = :id', ['id' => self::WORK])); + self::assertSame(1, $this->a->fetchOne('SELECT COUNT(*) FROM intervention_work_items WHERE id = :id', ['id' => self::TASK])); + self::assertSame('7.000000', $projection->view(self::ORG, self::WORK)->current->total); + if ('stock' === $kind) { + self::assertSame('0.000000', $this->a->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id = :org', ['org' => self::ORG])); + self::assertSame(1, $this->a->fetchOne('SELECT COUNT(*) FROM inventory_declarations WHERE organization_id = :org', ['org' => self::ORG])); + self::assertSame(1, $this->a->fetchOne("SELECT COUNT(*) FROM inventory_movements WHERE organization_id = :org AND kind = 'consumption'", ['org' => self::ORG])); + } else { + self::assertCount(1, $costs->expenses(self::ORG, self::WORK)); + } + } + + private function clean(): void + { + foreach (['maintenance_cost_expenses', 'maintenance_cost_planning', 'maintenance_cost_snapshots', 'maintenance_cost_currency_settings', 'inventory_operation_receipts', 'inventory_declarations', 'inventory_movements', 'inventory_balances', 'inventory_parts', 'inventory_warehouses'] as $table) { + $this->a->delete($table, ['organization_id' => self::ORG]); + } + $this->a->delete('organizations', ['id' => self::ORG]); + $this->main->clear(); + } +} diff --git a/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php index 95fadfb8a..22fd3b187 100644 --- a/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php +++ b/tests/Integration/Inventory/InventoryLastPieceConcurrencyTest.php @@ -9,14 +9,25 @@ use Doctrine\DBAL\{Connection,DriverManager}; use Doctrine\DBAL\Exception\DriverException; use Doctrine\ORM\{EntityManager,EntityManagerInterface}; +use Equipment\Application\Port\Inbound\EquipmentReserveReceiptPort; use Intervention\Application\Contract\Inventory\InventoryInterventionContext; use Intervention\Application\Port\Inbound\InterventionInventoryContextPort; use Inventory\Application\UseCase\Command\ApplyInventoryStock\{ApplyInventoryStockCommand,ApplyInventoryStockHandler}; use Inventory\Domain\Model\Stock\{InventoryReference,StockBalance}; +use Inventory\Infrastructure\Adapter\Procurement\{InventoryPartDirectoryAdapter, InventoryStockReceiptAdapter}; use Inventory\Infrastructure\Persistence\Doctrine\Repository\InventoryRepository; use MaintenanceCost\Application\Port\Inbound\MaintenanceCurrencyPort; +use MaintenanceCost\Infrastructure\Adapter\Currency\MaintenanceCurrencyAdapter; +use Organization\Application\Contract\Authorization\OrganizationAccessDecision; +use Organization\Application\Port\Inbound\OrganizationAuthorizationPort; use PHPUnit\Framework\Attributes\Test; -use Shared\Application\Port\Outbound\{TransactionManagerPort,UuidGeneratorPort}; +use Procurement\Application\Service\ProcurementProjection; +use Procurement\Application\UseCase\Command\ManageProcurement\{ManageProcurementCommand, ManageProcurementHandler}; +use Procurement\Domain\Model\{PurchaseOrder, Supplier}; +use Procurement\Domain\ValueObject\ProcurementLine; +use Procurement\Infrastructure\Persistence\Doctrine\Repository\ProcurementRepository; +use Shared\Application\Port\Inbound\CommandBusPort; +use Shared\Application\Port\Outbound\{ClockPort, EventDispatcherPort, TransactionManagerPort,UuidGeneratorPort}; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use function str_pad; @@ -69,6 +80,53 @@ protected function tearDown(): void parent::tearDown(); } + #[Test] + public function procurementReceptionAndConsumptionShareCurrencyBeforeIdentityAndReferenceLocks(): void + { + $other = new EntityManager($this->b, $this->main->getConfiguration()); + $procurement = $this->procurement($other); + $receive = new ManageProcurementCommand('bec10000-0000-4000-8000-000000000005', self::ORG, 'receive', 'bec10000-0000-4000-8000-000000000101', 2, ['clientOperationId' => 'bec10000-0000-4000-8000-000000000103', 'lineId' => 'bec10000-0000-4000-8000-000000000102', 'warehouseId' => self::WAREHOUSE, 'quantity' => '1', 'receivedAt' => '2026-10-06T10:00:00Z']); + $currency = new MaintenanceCurrencyAdapter($this->a); + $interleavedCurrency = $this->createStub(MaintenanceCurrencyPort::class); + $delivered = null; + $interleavedCurrency->method('lock')->willReturnCallback(function (string $organizationId) use ($currency, $procurement, $receive, &$delivered): string { + if (null === $delivered) { + // Session B enters the real reception bridge while A is active, immediately before A's currency lock. + $delivered = $procurement($receive); + self::assertSame('stock_received', $delivered->data['status']); + // A must not yet own its operation lock: Procurement already owns currency before entering Inventory. + self::assertTrue($this->b->fetchOne('SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0))', ['inventory-operation:' . self::ORG . ':' . $this->consume(10)->clientOperationId])); + } + + return $currency->lock($organizationId); + }); + $workerA = $this->handler($this->main, $this->a, 10, $interleavedCurrency); + $this->b->executeStatement("SET lock_timeout='150ms'"); + $this->a->executeStatement("SET lock_timeout='150ms'"); + $this->b->beginTransaction(); + + try { + $workerA($this->consume(10)); + self::fail('Consumption must serialize behind the reception transaction currency lock.'); + } catch (DriverException $exception) { + self::assertSame('55P03', $exception->getSQLState(), 'The wait is bounded by the test, never resolved by a deadlock victim.'); + } + self::assertNotNull($delivered, 'Reception must acquire references successfully before A acquires currency.'); + self::assertSame(0, $this->b->fetchOne('SELECT COUNT(*) FROM inventory_declarations WHERE organization_id=?', [self::ORG])); + $this->b->commit(); + $consumed = $workerA($this->consume(10)); + self::assertNotNull($consumed->declaration); + self::assertSame('confirmed', $consumed->declaration->status); + self::assertTrue($workerA($this->consume(10))->replayed); + self::assertTrue($procurement($receive)->replayed); + self::assertSame('1.000000', $this->a->fetchOne('SELECT quantity FROM inventory_balances WHERE organization_id=?', [self::ORG])); + self::assertSame('7.000000', $this->a->fetchOne('SELECT total_value FROM inventory_balances WHERE organization_id=?', [self::ORG])); + self::assertSame(2, $this->a->fetchOne('SELECT COUNT(*) FROM inventory_movements WHERE organization_id=?', [self::ORG])); + self::assertSame(2, $this->a->fetchOne('SELECT COUNT(*) FROM inventory_operation_receipts WHERE organization_id=?', [self::ORG])); + self::assertSame(1, $this->a->fetchOne('SELECT COUNT(*) FROM procurement_receipts WHERE organization_id=?', [self::ORG])); + self::assertSame(1, $this->a->fetchOne('SELECT COUNT(*) FROM procurement_operations WHERE organization_id=?', [self::ORG])); + } + #[Test] public function theSecondWorkerWaitsThenPersistsItsFullPendingDeclaration(): void { @@ -100,20 +158,41 @@ public function theSecondWorkerWaitsThenPersistsItsFullPendingDeclaration(): voi self::assertSame(2, $this->b->fetchOne('SELECT COUNT(*) FROM inventory_declarations WHERE organization_id=?', [self::ORG])); } + private function procurement(EntityManagerInterface $em): ManageProcurementHandler + { + $repository = new ProcurementRepository($this->b); + $now = new DateTimeImmutable('2026-10-06T12:00:00Z'); + $supplier = Supplier::create('bec10000-0000-4000-8000-000000000100', self::ORG, 'Concurrency supplier', null, null, null, [], $now); + $order = PurchaseOrder::create('bec10000-0000-4000-8000-000000000101', self::ORG, $supplier->id, 'EUR', 'Concurrent receipt', [ProcurementLine::create('bec10000-0000-4000-8000-000000000102', 'part', self::PART, null, [], '1', '7')], $now); + $order->order(1, $now); + $repository->saveSupplier($supplier); + $repository->saveOrder($order); + $inventory = $this->handler($em, $this->b, 30); + $bus = $this->createStub(CommandBusPort::class); + $bus->method('dispatch')->willReturnCallback($inventory(...)); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('resolveAccess')->willReturn(OrganizationAccessDecision::GRANTED); + $authorization->method('hasPermission')->willReturn(true); + $clock = $this->createStub(ClockPort::class); + $clock->method('now')->willReturn($now); + $ids = $this->createStub(UuidGeneratorPort::class); + $ids->method('generate')->willReturn('bec10000-0000-4000-8000-000000000104'); + + return new ManageProcurementHandler($repository, $authorization, new MaintenanceCurrencyAdapter($this->b), new InventoryPartDirectoryAdapter(new InventoryRepository($em)), new InventoryStockReceiptAdapter($bus), $this->createStub(EquipmentReserveReceiptPort::class), new ProcurementProjection(), $clock, $ids, $this->createStub(EventDispatcherPort::class)); + } + private function consume(int $n): ApplyInventoryStockCommand { return new ApplyInventoryStockCommand(self::ORG, 'bec10000-0000-4000-8000-000000000005', 'consumption', 'bec10000-0000-4000-8000-' . str_pad((string) $n, 12, '0', STR_PAD_LEFT), self::PART, self::WAREHOUSE, '1', new DateTimeImmutable('2026-10-06T10:00:00+00:00'), 'bec10000-0000-4000-8000-' . str_pad((string) ($n + 100), 12, '0', STR_PAD_LEFT)); } - private function handler(EntityManagerInterface $em, Connection $connection, int $counter): ApplyInventoryStockHandler + private function handler(EntityManagerInterface $em, Connection $connection, int $counter, ?MaintenanceCurrencyPort $currency = null): ApplyInventoryStockHandler { $tx = $this->createStub(TransactionManagerPort::class); $tx->method('transactional')->willReturnCallback(static fn (callable $call): mixed => $connection->transactional(static fn (Connection $active): mixed => $call())); $ids = $this->createStub(UuidGeneratorPort::class); $ids->method('generate')->willReturnCallback(static function () use (&$counter): string {return 'bec20000-0000-4000-8000-' . str_pad((string) ++$counter, 12, '0', STR_PAD_LEFT); }); - $currency = $this->createStub(MaintenanceCurrencyPort::class); - $currency->method('lock')->willReturn('EUR'); - $currency->method('forOrganization')->willReturn('EUR'); + $currency ??= new MaintenanceCurrencyAdapter($connection); $context = $this->createStub(InterventionInventoryContextPort::class); $context->method('validate')->willReturn(new InventoryInterventionContext(false)); @@ -122,7 +201,7 @@ private function handler(EntityManagerInterface $em, Connection $connection, int private function clean(): void { - foreach (['inventory_operation_receipts', 'inventory_declarations', 'inventory_movements', 'inventory_balances', 'inventory_parts', 'inventory_warehouses'] as $table) { + foreach (['procurement_operations', 'procurement_receipts', 'procurement_orders', 'procurement_suppliers', 'inventory_operation_receipts', 'inventory_declarations', 'inventory_movements', 'inventory_balances', 'inventory_parts', 'inventory_warehouses', 'maintenance_cost_currency_settings'] as $table) { $this->a->executeStatement('DELETE FROM ' . $table . ' WHERE organization_id=?', [self::ORG]); } $this->main->clear(); diff --git a/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php index 8fa6f4853..a35231436 100644 --- a/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php +++ b/tests/Integration/Procurement/Infrastructure/Persistence/Doctrine/Repository/ProcurementRepositoryTest.php @@ -82,6 +82,44 @@ protected function tearDown(): void parent::tearDown(); } + #[Test] + public function creationResourcesAndReplayIdentitiesCommitOrRollbackTogether(): void + { + $supplier = Supplier::create(self::SUPPLIER, self::ORG, 'Creation retry supplier', null, null, null, [], $this->now()); + $order = PurchaseOrder::create(self::ORDER, self::ORG, self::SUPPLIER, 'EUR', 'Creation retry draft', [ProcurementLine::create(self::LINE, 'part', self::PART, null, [], '1', null)], $this->now()); + $write = function () use ($supplier, $order): void { + $this->repository->saveSupplier($supplier); + $this->repository->saveOrder($order); + $this->repository->saveOperation(new ProcurementOperationState(self::ORG, self::OPERATION, 'create_supplier', 'supplier-fingerprint', self::SUPPLIER)); + $this->repository->saveOperation(new ProcurementOperationState(self::ORG, self::RECEIPT, 'create_order', 'order-fingerprint', self::ORDER)); + $other = new ProcurementRepository($this->b); + self::assertNull($other->supplier(self::ORG, self::SUPPLIER)); + self::assertNull($other->order(self::ORG, self::ORDER)); + self::assertNull($other->operation(self::ORG, self::OPERATION)); + self::assertNull($other->operation(self::ORG, self::RECEIPT)); + }; + + try { + $this->repository->synchronized(self::ORG, static function () use ($write): void { + $write(); + + throw new RuntimeException('Fail before the creation transaction commits.'); + }); + } catch (RuntimeException $exception) { + self::assertSame('Fail before the creation transaction commits.', $exception->getMessage()); + } + self::assertNull($this->repository->supplier(self::ORG, self::SUPPLIER)); + self::assertNull($this->repository->order(self::ORG, self::ORDER)); + self::assertNull($this->repository->operation(self::ORG, self::OPERATION)); + self::assertNull($this->repository->operation(self::ORG, self::RECEIPT)); + $this->repository->synchronized(self::ORG, $write); + $other = new ProcurementRepository($this->b); + self::assertSame(self::SUPPLIER, $other->supplier(self::ORG, self::SUPPLIER)?->id); + self::assertSame(self::ORDER, $other->order(self::ORG, self::ORDER)?->id); + self::assertSame(self::SUPPLIER, $other->operation(self::ORG, self::OPERATION)?->receiptId); + self::assertSame(self::ORDER, $other->operation(self::ORG, self::RECEIPT)?->receiptId); + } + #[Test] public function exactQuantitiesCostsAndMotivatedDeclarationsRoundTrip(): void { diff --git a/tests/Support/Image/ImageFixtures.php b/tests/Support/Image/ImageFixtures.php new file mode 100644 index 000000000..5de52ef45 --- /dev/null +++ b/tests/Support/Image/ImageFixtures.php @@ -0,0 +1,108 @@ +createMock(NotificationRepositoryPort::class); + $repository->expects(self::once())->method('save') + ->willReturnCallback(function (Notification $notification): void { + $this->storedEmailNotification = $notification; + }); + $preferences = $this->createMock(NotificationPreferenceRepositoryPort::class); + $preferences->expects(self::once())->method('findByUserIdAndCategory') + ->with($userId, $category)->willReturn(null); + $mercure = $this->createMock(MercureNotificationChannelPort::class); + $mercure->expects(self::once())->method('publish')->with(self::isInstanceOf(Notification::class), []) + ->willReturnCallback(function (Notification $notification, array $payload): void { + $this->mercureEmailNotification = $notification; + }); + $directory = $this->createMock(RecipientDirectoryPort::class); + if ($resolveEmail) { + $directory->expects(self::once())->method('emailForUserId')->with($userId)->willReturn($email); + } else { + $directory->expects(self::never())->method('emailForUserId'); + } + $logger = $this->createMock(LoggerPort::class); + $logger->expects(self::never())->method('warning'); + $generator = $this->createMock(UuidGeneratorPort::class); + $generator->expects(self::once())->method('generate')->willReturn('550e8400-e29b-41d4-a716-446655449010'); + $mailer = $this->createMock(MailerPort::class); + $mailer->expects(self::once())->method('send')->with([$email], $subject, self::isString()) + ->willReturnCallback(function (array $to, string $mailSubject, string $body): void { + $this->renderedNotificationEmail = $body; + }); + $twig = new Environment(new FilesystemLoader(dirname(__DIR__, 3) . '/templates'), ['autoescape' => 'html']); + $twig->addExtension(new TranslationExtension(new Translator('en'))); + + return new NotificationService(new SendNotificationHandler( + notificationRepository: $repository, + preferenceRepository: $preferences, + emailChannel: new EmailNotificationChannelAdapter($mailer, $twig), + mercureChannel: $mercure, + recipientDirectory: $directory, + logger: $logger, + uuidFactory: new UuidFactory($generator), + )); + } + + /** + * @param array $payload the source producer's persistent payload + */ + private function assertEmailPipelinePreservesPlainText(string $type, string $body, array $payload, string $userId, string $organizationId): void + { + $stored = $this->storedEmailNotification; + $mercure = $this->mercureEmailNotification; + $rendered = $this->renderedNotificationEmail; + self::assertInstanceOf(Notification::class, $stored); + self::assertSame($type, $stored->type()); + self::assertSame($body, $stored->body()); + self::assertSame($payload, $stored->payload()); + self::assertSame($userId, $stored->recipientUserId()); + self::assertSame($organizationId, $stored->organizationId()); + self::assertSame($stored, $mercure); + self::assertInstanceOf(Notification::class, $mercure); + self::assertSame($body, $mercure->body()); + self::assertIsString($rendered); + + $document = new DOMDocument(); + self::assertTrue($document->loadHTML($rendered, LIBXML_NOERROR | LIBXML_NOWARNING)); + $xpath = new DOMXPath($document); + $bodyCells = $xpath->query('//td[@class="prose"]'); + self::assertNotFalse($bodyCells); + self::assertCount(1, $bodyCells); + $bodyCell = $bodyCells->item(0); + self::assertInstanceOf(DOMElement::class, $bodyCell); + self::assertSame($body, $bodyCell->textContent); + $descendants = $xpath->query('.//*', $bodyCell); + self::assertNotFalse($descendants); + self::assertCount(0, $descendants); + } +} diff --git a/tests/Unit/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandlerTest.php b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandlerTest.php index 284c73287..861557bd5 100644 --- a/tests/Unit/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandlerTest.php +++ b/tests/Unit/Equipment/Application/UseCase/Command/Equipment/CreateEquipment/CreateEquipmentHandlerTest.php @@ -297,6 +297,10 @@ static function (OrganizationSetupContext $actualContext, string $step, ?string self::assertSame('create_first_equipment', $step); self::assertSame($organizationId, $actualOrganizationId); self::assertSame('/api/facilities/' . $facilityId, $payload['facility']); + self::assertSame('Entrance extinguisher', $payload['name']); + self::assertSame('FG-001', $payload['assetCode']); + self::assertSame('high', $payload['criticality']); + self::assertSame([['key' => 'capacity', 'value' => '6', 'unit' => 'kg']], $payload['technicalProperties']); return new OrganizationSetupOperation($step, $context->itemKey, []); }, @@ -346,6 +350,10 @@ static function (Equipment $equipment) use (&$calls, $facilityId): void { resourceId: null, setupContext: $context, facilityId: $facilityId, + name: 'Entrance extinguisher', + assetCode: 'FG-001', + criticality: 'high', + technicalProperties: [['key' => 'capacity', 'value' => '6', 'unit' => 'kg']], ), ); diff --git a/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentsControllerTest.php b/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentsControllerTest.php index 19e0766b7..82505ea34 100644 --- a/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentsControllerTest.php +++ b/tests/Unit/Equipment/Presentation/Api/Controller/ExportEquipmentsControllerTest.php @@ -17,6 +17,7 @@ use RuntimeException; use Shared\Application\Port\Inbound\QueryBusPort; use Shared\Application\Port\Outbound\EventDispatcherPort; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\{Request, StreamedResponse}; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException, UnprocessableEntityHttpException}; @@ -140,7 +141,7 @@ public function testItRefusesAnUnauthenticatedCaller(): void queryBus: $queryBus, eventDispatcher: $this->createStub(EventDispatcherPort::class), security: $security, - csvWriter: new EquipmentCsvWriter(), + csvWriter: new EquipmentCsvWriter(new SpreadsheetSafeTextAdapter()), ); $this->expectException(AccessDeniedHttpException::class); @@ -216,7 +217,7 @@ private function createController(QueryBusPort $queryBus, EventDispatcherPort $e queryBus: $queryBus, eventDispatcher: $eventDispatcher, security: $security, - csvWriter: new EquipmentCsvWriter(), + csvWriter: new EquipmentCsvWriter(new SpreadsheetSafeTextAdapter()), ); } } diff --git a/tests/Unit/Equipment/Presentation/Api/Service/EquipmentCsvWriterTest.php b/tests/Unit/Equipment/Presentation/Api/Service/EquipmentCsvWriterTest.php index 3e9eac905..dc8179902 100644 --- a/tests/Unit/Equipment/Presentation/Api/Service/EquipmentCsvWriterTest.php +++ b/tests/Unit/Equipment/Presentation/Api/Service/EquipmentCsvWriterTest.php @@ -8,6 +8,7 @@ use Equipment\Presentation\Api\Service\EquipmentCsvWriter; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\TestCase; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use function array_slice; use function explode; @@ -48,7 +49,7 @@ public function testHeaderBeginsWithTheImportRoundTripContractColumnsInOrder(): #[Test] public function testWriteEmitsTheHeaderAndOneRowPerEquipment(): void { - $writer = new EquipmentCsvWriter(); + $writer = new EquipmentCsvWriter(new SpreadsheetSafeTextAdapter()); $row = new EquipmentExportRow( id: 'equipment-1', type: 'fire_extinguisher', @@ -86,7 +87,7 @@ public function testWriteEmitsTheHeaderAndOneRowPerEquipment(): void #[Test] public function testWriteFallsBackToTheFacilityIdWhenTheNameIsUnresolvedAndToEmptyWhenAbsent(): void { - $writer = new EquipmentCsvWriter(); + $writer = new EquipmentCsvWriter(new SpreadsheetSafeTextAdapter()); $unresolved = new EquipmentExportRow( id: 'equipment-2', type: 'smoke_detector', diff --git a/tests/Unit/Facility/Presentation/Api/Controller/ExportFacilitiesControllerTest.php b/tests/Unit/Facility/Presentation/Api/Controller/ExportFacilitiesControllerTest.php index b2912c812..3013909e8 100644 --- a/tests/Unit/Facility/Presentation/Api/Controller/ExportFacilitiesControllerTest.php +++ b/tests/Unit/Facility/Presentation/Api/Controller/ExportFacilitiesControllerTest.php @@ -17,6 +17,7 @@ use RuntimeException; use Shared\Application\Port\Inbound\QueryBusPort; use Shared\Application\Port\Outbound\EventDispatcherPort; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\{Request, StreamedResponse}; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException, UnprocessableEntityHttpException}; @@ -141,7 +142,7 @@ public function testItRefusesAnUnauthenticatedCaller(): void eventDispatcher: $this->createStub(EventDispatcherPort::class), security: $security, criteriaFactory: new FacilityExportCriteriaFactory(), - csvWriter: new FacilityCsvWriter(), + csvWriter: new FacilityCsvWriter(new SpreadsheetSafeTextAdapter()), ); $this->expectException(AccessDeniedHttpException::class); @@ -218,7 +219,7 @@ private function createController(QueryBusPort $queryBus, EventDispatcherPort $e eventDispatcher: $eventDispatcher, security: $security, criteriaFactory: new FacilityExportCriteriaFactory(), - csvWriter: new FacilityCsvWriter(), + csvWriter: new FacilityCsvWriter(new SpreadsheetSafeTextAdapter()), ); } } diff --git a/tests/Unit/Facility/Presentation/Api/Service/FacilityCsvWriterTest.php b/tests/Unit/Facility/Presentation/Api/Service/FacilityCsvWriterTest.php index f336765d2..4a340563d 100644 --- a/tests/Unit/Facility/Presentation/Api/Service/FacilityCsvWriterTest.php +++ b/tests/Unit/Facility/Presentation/Api/Service/FacilityCsvWriterTest.php @@ -8,6 +8,7 @@ use Facility\Presentation\Api\Service\FacilityCsvWriter; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\TestCase; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use function array_slice; use function fclose; @@ -46,7 +47,7 @@ public function testHeaderBeginsWithTheFacilityRowFactoryImportContract(): void #[Test] public function testWriteFormatsCoordinatesAsPlainDecimalStringsAndFallsBackToEmptyForNulls(): void { - $writer = new FacilityCsvWriter(); + $writer = new FacilityCsvWriter(new SpreadsheetSafeTextAdapter()); $rowWithCoordinates = new FacilityExportRow( id: 'facility-1', @@ -91,7 +92,7 @@ public function testWriteFormatsCoordinatesAsPlainDecimalStringsAndFallsBackToEm public function testHeaderEndsWithLevelIndexAfterTheFrozenImportContract(): void { self::assertSame( - ['parentCode', 'id', 'status', 'createdAt', 'updatedAt', 'levelIndex'], + ['parentCode', 'id', 'status', 'createdAt', 'updatedAt', 'levelIndex', '_fireguard_text_encoding'], array_slice(FacilityCsvWriter::HEADER, 6), 'levelIndex is a new trailing column — it must never be inserted before the frozen import prefix.', ); @@ -100,7 +101,7 @@ public function testHeaderEndsWithLevelIndexAfterTheFrozenImportContract(): void #[Test] public function testWriteFormatsLevelIndexAsAPlainIntegerStringAndFallsBackToEmptyForNull(): void { - $writer = new FacilityCsvWriter(); + $writer = new FacilityCsvWriter(new SpreadsheetSafeTextAdapter()); $rowWithLevelIndex = new FacilityExportRow( id: 'facility-3', @@ -140,6 +141,6 @@ public function testWriteFormatsLevelIndexAsAPlainIntegerStringAndFallsBackToEmp fclose($handle); self::assertStringContainsString('facility-3,active,2026-08-01T00:00:00+00:00,2026-08-02T00:00:00+00:00,-1', $csv); - self::assertStringContainsString('facility-4,active,2026-08-01T00:00:00+00:00,2026-08-02T00:00:00+00:00,' . "\n", $csv); + self::assertStringContainsString('facility-4,active,2026-08-01T00:00:00+00:00,2026-08-02T00:00:00+00:00,,apostrophe-v1' . "\n", $csv); } } diff --git a/tests/Unit/Import/Infrastructure/Csv/CsvRowStreamerTest.php b/tests/Unit/Import/Infrastructure/Csv/CsvRowStreamerTest.php index f4ecfe9fa..3d7ab256e 100644 --- a/tests/Unit/Import/Infrastructure/Csv/CsvRowStreamerTest.php +++ b/tests/Unit/Import/Infrastructure/Csv/CsvRowStreamerTest.php @@ -9,6 +9,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\TestCase; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use function array_fill; use function array_keys; @@ -43,13 +44,13 @@ public static function blankContentsProvider(): iterable #[Test] public function testItImplementsTheCsvRowStreamerPort(): void { - self::assertInstanceOf(CsvRowStreamerPort::class, new CsvRowStreamer()); + self::assertInstanceOf(CsvRowStreamerPort::class, new CsvRowStreamer(new SpreadsheetSafeTextAdapter())); } #[Test] public function testItStreamsCommaSeparatedRowsKeyedByHeader(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("name,type\nExtincteur,fire_extinguisher\nDetecteur,smoke_detector\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name,type\nExtincteur,fire_extinguisher\nDetecteur,smoke_detector\n")); self::assertSame([1, 2], array_keys($rows)); self::assertSame(['name' => 'Extincteur', 'type' => 'fire_extinguisher'], $rows[1]); @@ -59,7 +60,7 @@ public function testItStreamsCommaSeparatedRowsKeyedByHeader(): void #[Test] public function testItSniffsTheFrenchExcelSemicolonDelimiter(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("nom;type\nExtincteur;fire_extinguisher\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("nom;type\nExtincteur;fire_extinguisher\n")); self::assertSame(['nom' => 'Extincteur', 'type' => 'fire_extinguisher'], $rows[1]); } @@ -69,7 +70,7 @@ public function testItKeepsTheCommaDelimiterWhenBothSeparatorsAppear(): void { // A comma anywhere in the header means the file is comma-delimited and // the semicolons belong to the data. - $rows = iterator_to_array(new CsvRowStreamer()->rows("name,note\nExtincteur,\"a;b\"\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name,note\nExtincteur,\"a;b\"\n")); self::assertSame(['name' => 'Extincteur', 'note' => 'a;b'], $rows[1]); } @@ -77,7 +78,7 @@ public function testItKeepsTheCommaDelimiterWhenBothSeparatorsAppear(): void #[Test] public function testItStripsAUtf8Bom(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("\xEF\xBB\xBFname,type\nExtincteur,fire_extinguisher\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("\xEF\xBB\xBFname,type\nExtincteur,fire_extinguisher\n")); self::assertArrayHasKey('name', $rows[1]); self::assertSame('Extincteur', $rows[1]['name']); @@ -86,15 +87,32 @@ public function testItStripsAUtf8Bom(): void #[Test] public function testItTrimsHeaderAndCellWhitespace(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows(" name , type \n Extincteur , fire_extinguisher \n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows(" name , type \n Extincteur , fire_extinguisher \n")); self::assertSame(['name' => 'Extincteur', 'type' => 'fire_extinguisher'], $rows[1]); } + #[Test] + public function testItDoesNotStripLiteralApostrophesFromUnmarkedImports(): void + { + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name\n'=1+1\n''literal\n")); + + self::assertSame("'=1+1", $rows[1]['name']); + self::assertSame("''literal", $rows[2]['name']); + } + + #[Test] + public function testItDoesNotDecodeAnUnknownEncodingVersion(): void + { + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name,_fireguard_text_encoding\n'=1+1,unknown-version\n")); + + self::assertSame(['name' => "'=1+1"], $rows[1]); + } + #[Test] public function testItSkipsFullyBlankLinesWithoutConsumingARowNumber(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("name\nA\n\nB\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name\nA\n\nB\n")); self::assertSame([1, 2], array_keys($rows)); self::assertSame('A', $rows[1]['name']); @@ -104,7 +122,7 @@ public function testItSkipsFullyBlankLinesWithoutConsumingARowNumber(): void #[Test] public function testItPadsShortRowsAndDropsUnnamedColumns(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("name,type,\nExtincteur\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name,type,\nExtincteur\n")); self::assertSame(['name' => 'Extincteur', 'type' => ''], $rows[1]); } @@ -112,7 +130,7 @@ public function testItPadsShortRowsAndDropsUnnamedColumns(): void #[Test] public function testItIgnoresExtraTrailingColumns(): void { - $rows = iterator_to_array(new CsvRowStreamer()->rows("name\nExtincteur,ignored,also-ignored\n")); + $rows = iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("name\nExtincteur,ignored,also-ignored\n")); self::assertSame(['name' => 'Extincteur'], $rows[1]); } @@ -124,7 +142,7 @@ public function testItRefusesAnEmptyFile(string $contents): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The CSV file is empty.'); - iterator_to_array(new CsvRowStreamer()->rows($contents)); + iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows($contents)); } #[Test] @@ -133,13 +151,13 @@ public function testItRefusesAFileThatIsNothingButABom(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The CSV file has no header row.'); - iterator_to_array(new CsvRowStreamer()->rows("\xEF\xBB\xBF")); + iterator_to_array(new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows("\xEF\xBB\xBF")); } #[Test] public function testItRefusesAFileExceedingTheRowCap(): void { - $streamer = new CsvRowStreamer(maxRows: 2); + $streamer = new CsvRowStreamer(new SpreadsheetSafeTextAdapter(), maxRows: 2); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The CSV file exceeds the maximum of 2 data rows.'); @@ -150,7 +168,7 @@ public function testItRefusesAFileExceedingTheRowCap(): void #[Test] public function testItAcceptsExactlyTheRowCap(): void { - self::assertSame(2, new CsvRowStreamer(maxRows: 2)->countDataRows("name\nA\nB\n")); + self::assertSame(2, new CsvRowStreamer(new SpreadsheetSafeTextAdapter(), maxRows: 2)->countDataRows("name\nA\nB\n")); } #[Test] @@ -158,13 +176,13 @@ public function testCountDataRowsExcludesTheHeader(): void { $contents = "name\n" . implode('', array_fill(0, 10, "row\n")); - self::assertSame(10, new CsvRowStreamer()->countDataRows($contents)); + self::assertSame(10, new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->countDataRows($contents)); } #[Test] public function testCountDataRowsReturnsZeroForAHeaderOnlyFile(): void { - self::assertSame(0, new CsvRowStreamer()->countDataRows("name,type\n")); + self::assertSame(0, new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->countDataRows("name,type\n")); } #[Test] @@ -174,7 +192,7 @@ public function testItStreamsWithoutMaterializingEveryRow(): void // the rest of the file has been parsed. $contents = "name\n" . str_repeat("Extincteur\n", 1000); - $generator = new CsvRowStreamer()->rows($contents); + $generator = new CsvRowStreamer(new SpreadsheetSafeTextAdapter())->rows($contents); self::assertSame(['name' => 'Extincteur'], $generator->current()); self::assertSame(1, $generator->key()); diff --git a/tests/Unit/Import/Infrastructure/Csv/ExportSpreadsheetRoundTripTest.php b/tests/Unit/Import/Infrastructure/Csv/ExportSpreadsheetRoundTripTest.php new file mode 100644 index 000000000..65908184d --- /dev/null +++ b/tests/Unit/Import/Infrastructure/Csv/ExportSpreadsheetRoundTripTest.php @@ -0,0 +1,240 @@ + inert formula-like and literal text values + */ + public static function protectedText(): iterable + { + yield 'equals' => ['=1+1']; + yield 'plus' => ['+1']; + yield 'minus text' => ['-1']; + yield 'at' => ['@SUM(1)']; + yield 'leading tab' => ["\t=1+1"]; + yield 'leading carriage return' => ["\r=1+1"]; + yield 'leading newline' => ["\n=1+1"]; + yield 'leading spaces' => [' =1+1']; + yield 'leading unicode space' => ["\u{00a0}=1+1"]; + yield 'leading unicode format control' => ["\u{feff}=1+1"]; + yield 'control-only prefix' => ["\tordinary text"]; + yield 'literal apostrophe' => ["'literal"]; + yield 'two literal apostrophes' => ["''literal"]; + yield 'embedded csv syntax' => ['=SUM(1,2) "quoted"\\']; + } + + /** + * Method equipmentTextRemainsSafeAndReimportsWithoutProtectionCharacters + * + * @access public + * + * @param string $value original user-controlled text + * + * @return void + */ + #[Test] + #[DataProvider('protectedText')] + public function equipmentTextRemainsSafeAndReimportsWithoutProtectionCharacters(string $value): void + { + $codec = new SpreadsheetSafeTextAdapter(); + $row = new EquipmentExportRow( + id: 'equipment-1', + type: 'fire_extinguisher', + subType: null, + brand: $value, + model: $value, + serialNumber: $value, + locationLabel: $value, + status: 'operational', + facilityId: 'facility-1', + facilityCode: $value, + facilityName: $value, + installedAt: null, + commissionedAt: null, + createdAt: '2026-10-01T00:00:00+00:00', + updatedAt: '2026-10-01T00:00:00+00:00', + ); + [$contents, $cells] = $this->export(new EquipmentCsvWriter($codec), $row); + + foreach ([2, 3, 4, 5, 6, 10] as $column) { + self::assertSame("'" . $value, $cells[$column]); + } + $imported = iterator_to_array(new CsvRowStreamer($codec)->rows($contents))[1]; + foreach (['brand', 'model', 'serialNumber', 'locationLabel', 'facilityCode', 'facilityName'] as $column) { + self::assertSame($value, $imported[$column]); + } + $request = new EquipmentRowFactory()->map('organization-1', $imported); + self::assertSame(trim($value), $request->brand); + self::assertSame(trim($value), $request->facilityCode); + } + + /** + * Method facilityTextRemainsSafeAndNegativeNumbersRemainNumeric + * + * @access public + * + * @param string $value original user-controlled text + * + * @return void + */ + #[Test] + #[DataProvider('protectedText')] + public function facilityTextRemainsSafeAndNegativeNumbersRemainNumeric(string $value): void + { + $codec = new SpreadsheetSafeTextAdapter(); + $row = new FacilityExportRow( + id: 'facility-1', + type: 'building', + name: $value, + code: $value, + address: $value, + latitude: -48.8566, + longitude: -2.3522, + parentCode: $value, + status: 'active', + createdAt: '2026-10-01T00:00:00+00:00', + updatedAt: '2026-10-01T00:00:00+00:00', + levelIndex: -1, + ); + [$contents, $cells] = $this->export(new FacilityCsvWriter($codec), $row); + + foreach ([1, 2, 3, 6] as $column) { + self::assertSame("'" . $value, $cells[$column]); + } + self::assertSame('-48.8566', $cells[4]); + self::assertSame('-2.3522', $cells[5]); + self::assertSame('-1', $cells[11]); + $imported = iterator_to_array(new CsvRowStreamer($codec)->rows($contents))[1]; + foreach (['name', 'code', 'address', 'parentCode'] as $column) { + self::assertSame($value, $imported[$column]); + } + $request = new FacilityRowFactory()->map('organization-1', $imported); + self::assertSame(trim($value), $request->name); + self::assertSame(trim($value), $request->code); + self::assertSame(-48.8566, $request->latitude); + self::assertSame(-2.3522, $request->longitude); + } + + /** + * Method interventionDisplayNamesAreProtectedAtTheCsvBoundary + * + * @access public + * + * @param string $value original user-controlled text + * + * @return void + */ + #[Test] + #[DataProvider('protectedText')] + public function interventionDisplayNamesAreProtectedAtTheCsvBoundary(string $value): void + { + $codec = new SpreadsheetSafeTextAdapter(); + $row = new InterventionExportRow( + id: 'intervention-1', + name: $value, + type: 'inspection_campaign', + status: 'planned', + priority: 'high', + siteId: 'facility-1', + siteName: $value, + responsibleId: 'member-1', + responsibleName: $value, + dueAt: null, + createdAt: '2026-10-01T00:00:00+00:00', + updatedAt: '2026-10-01T00:00:00+00:00', + ); + [$contents, $cells] = $this->export(new InterventionCsvWriter($codec), $row); + + foreach ([1, 5, 6] as $column) { + self::assertSame("'" . $value, $cells[$column]); + } + $imported = iterator_to_array(new CsvRowStreamer($codec)->rows($contents))[1]; + self::assertSame($value, $imported['name']); + self::assertSame($value, $imported['facility']); + self::assertSame($value, $imported['assignee']); + } + + /** + * Method export + * + * @access private + * + * @param EquipmentCsvWriter|FacilityCsvWriter|InterventionCsvWriter $writer real CSV presentation writer + * @param EquipmentExportRow|FacilityExportRow|InterventionExportRow $row stored export values + * + * @return array{string, list} output bytes and spreadsheet-visible cells + */ + private function export( + EquipmentCsvWriter|FacilityCsvWriter|InterventionCsvWriter $writer, + EquipmentExportRow|FacilityExportRow|InterventionExportRow $row, + ): array { + $stream = fopen('php://memory', 'w+b'); + self::assertIsResource($stream); + if ($writer instanceof EquipmentCsvWriter && $row instanceof EquipmentExportRow) { + $writer->write([$row], $stream); + } elseif ($writer instanceof FacilityCsvWriter && $row instanceof FacilityExportRow) { + $writer->write([$row], $stream); + } elseif ($writer instanceof InterventionCsvWriter && $row instanceof InterventionExportRow) { + $writer->write([$row], $stream); + } else { + self::fail('The export fixture requires a matching writer and row.'); + } + rewind($stream); + $contents = (string) stream_get_contents($stream); + rewind($stream); + $header = fgetcsv($stream, escape: ''); + $cells = fgetcsv($stream, escape: ''); + fclose($stream); + self::assertIsArray($header); + self::assertIsArray($cells); + self::assertSame(SpreadsheetSafeTextPort::ENCODING_COLUMN, $header[count($header) - 1]); + self::assertSame(SpreadsheetSafeTextPort::ENCODING_VERSION, $cells[count($cells) - 1]); + + return [$contents, $cells]; + } + // #endregion +} diff --git a/tests/Unit/Intervention/Application/Service/InterventionNotificationEmailTest.php b/tests/Unit/Intervention/Application/Service/InterventionNotificationEmailTest.php new file mode 100644 index 000000000..40ac50eed --- /dev/null +++ b/tests/Unit/Intervention/Application/Service/InterventionNotificationEmailTest.php @@ -0,0 +1,119 @@ +Review & "confirm"'; + + #[Test] + #[DataProvider('notificationSources')] + public function interventionMarkupIsTextInRenderedEmail(string $source, string $type, string $subject, string $plainBody): void + { + $member = OrganizationMember::reconstitute( + id: new OrganizationMemberId(self::MEMBER_ID), + organizationId: new OrganizationId(self::ORGANIZATION_ID), + userId: self::USER_ID, + isActive: true, + joinedAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + ); + $members = $this->createMock(OrganizationMemberRepositoryPort::class); + if ('submitted' === $source) { + $members->expects(self::never())->method('findById'); + } else { + $members->expects(self::once())->method('findById')->with(new OrganizationMemberId(self::MEMBER_ID))->willReturn($member); + } + $resolvesOrganizationRecipients = 'dueSoon' !== $source; + $members->expects($resolvesOrganizationRecipients ? self::once() : self::never())->method('findByOrganizationId') + ->with(new OrganizationId(self::ORGANIZATION_ID))->willReturn([$member]); + $authorization = $this->createMock(OrganizationAuthorizationPort::class); + $authorization->expects($resolvesOrganizationRecipients ? self::once() : self::never())->method('getUserPermissions') + ->with(self::USER_ID, self::ORGANIZATION_ID)->willReturn(['organization.interventions.review', 'organization.interventions.plan']); + $policy = $this->createMock(OrganizationNotificationPolicyPort::class); + $policy->expects(self::once())->method('notificationPolicy')->with(self::ORGANIZATION_ID) + ->willReturn(OrganizationNotificationSettings::fromArray(['email_enabled' => true, 'in_app_enabled' => true])); + $eventContext = $this->createStub(DurableEventContextPort::class); + $eventContext->method('eventId')->willReturn(null); + $consumer = $this->createMock(IdempotentConsumerPort::class); + $consumer->expects(self::never())->method('consume'); + $service = new InterventionNotificationService( + notifications: $this->emailPipeline(self::USER_ID, 'reviewer@example.com', 'intervention', $subject, resolveEmail: true), + members: $members, + policy: $policy, + reviewers: new InterventionReviewerRecipientResolver($members, $authorization), + admins: new InterventionRecurrenceRecipientResolver($members, $authorization), + eventContext: $eventContext, + eventConsumer: $consumer, + ); + + if ('submitted' === $source) { + $service->submitted(self::INTERVENTION_ID, self::NAME, self::ORGANIZATION_ID, 'submitting-user'); + } else { + $service->{$source}( + self::INTERVENTION_ID, + 12, + self::NAME, + self::ORGANIZATION_ID, + new DateTimeImmutable('2026-01-11T00:00:00+00:00'), + [self::MEMBER_ID], + ); + } + + $this->assertEmailPipelinePreservesPlainText($type, $plainBody, ['interventionId' => self::INTERVENTION_ID], self::USER_ID, self::ORGANIZATION_ID); + } + + /** + * @return iterable + */ + public static function notificationSources(): iterable + { + yield 'submitted for review' => [ + 'submitted', + 'intervention.submitted', + 'Intervention submitted for review', + '"' . self::NAME . '" was submitted and awaits review.', + ]; + yield 'due soon' => [ + 'dueSoon', + 'intervention.due_soon', + 'Intervention due soon', + '"' . self::NAME . '" (FG-12) is due 2026-01-11. /organizations/' . self::ORGANIZATION_ID . '/interventions/' . self::INTERVENTION_ID, + ]; + yield 'overdue' => [ + 'overdue', + 'intervention.overdue', + 'Intervention overdue', + '"' . self::NAME . '" (FG-12) is due 2026-01-11. /organizations/' . self::ORGANIZATION_ID . '/interventions/' . self::INTERVENTION_ID, + ]; + } +} diff --git a/tests/Unit/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandlerTest.php new file mode 100644 index 000000000..a91574ebb --- /dev/null +++ b/tests/Unit/Intervention/Application/UseCase/Query/Time/GetTimeEntry/GetTimeEntryHandlerTest.php @@ -0,0 +1,71 @@ +createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entry = new TimeEntryView('entry', 'task', 'actor', '2026-01-01', 60, null, 500, false, 'actor', 'actor', 'now', 'now'); + $entries->expects(self::once())->method('find')->with('entry')->willReturn($entry); + $entries->expects(self::never())->method('list'); + $entries->expects(self::never())->method('versions'); + $result = new GetTimeEntryHandler($entries, $this->policy())(new GetTimeEntryQuery('user', 'task', 'entry')); + self::assertSame($entry, $result->entry); + } + + #[Test] + public function testOtherBeneficiaryRemainsHidden(): void + { + $entries = $this->createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->expects(self::once())->method('find')->with('entry')->willReturn(new TimeEntryView('entry', 'task', 'other', '2026-01-01', 60, null, 500, false, 'other', 'other', 'now', 'now')); + $this->expectException(InterventionNotFoundException::class); + new GetTimeEntryHandler($entries, $this->policy())(new GetTimeEntryQuery('user', 'task', 'entry')); + } + + /** + * Method policy + * + * Supplies one active member with own-journal visibility. + * + * @access private + * + * @return InterventionTimeAccessPolicy real policy using mocked outbound ports + */ + private function policy(): InterventionTimeAccessPolicy + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('isMemberOf')->willReturn(true); + $authorization->method('hasPermission')->willReturn(false); + $workforce = $this->createStub(OrganizationWorkforceDirectoryPort::class); + $workforce->method('members')->willReturn([new OrganizationWorkforceMember('actor', 'user', true)]); + + return new InterventionTimeAccessPolicy($authorization, $workforce); + } + // #endregion +} diff --git a/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesHandlerTest.php new file mode 100644 index 000000000..d174f6789 --- /dev/null +++ b/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntries/ListTimeEntriesHandlerTest.php @@ -0,0 +1,83 @@ +createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->expects(self::once())->method('list')->with('task', 'actor', 4, 20)->willReturn([]); + $entries->expects(self::once())->method('count')->with('task', 'actor')->willReturn(65); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('isMemberOf')->willReturn(true); + $authorization->method('hasPermission')->willReturn(false); + $workforce = $this->createStub(OrganizationWorkforceDirectoryPort::class); + $workforce->method('members')->willReturn([new OrganizationWorkforceMember('actor', 'user', true)]); + $policy = new InterventionTimeAccessPolicy($authorization, $workforce); + $result = new ListTimeEntriesHandler($entries, $policy)(new ListTimeEntriesQuery('user', 'task', 4, 20, false)); + self::assertSame(65, $result->totalItems); + self::assertSame(4, $result->page); + self::assertSame(20, $result->itemsPerPage); + } + + #[Test] + public function testOwnOnlyKeepsAManagersPageAndTotalLimitedToTheCaller(): void + { + $entries = $this->createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->expects(self::once())->method('list')->with('task', 'actor', 2, 2)->willReturn([]); + $entries->expects(self::once())->method('count')->with('task', 'actor')->willReturn(3); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('isMemberOf')->willReturn(true); + $authorization->method('hasPermission')->willReturn(true); + $workforce = $this->createStub(OrganizationWorkforceDirectoryPort::class); + $workforce->method('members')->willReturn([new OrganizationWorkforceMember('actor', 'user', true)]); + $policy = new InterventionTimeAccessPolicy($authorization, $workforce); + self::assertTrue($policy->canManage(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], []), 'user')); + $result = new ListTimeEntriesHandler($entries, $policy)(new ListTimeEntriesQuery('user', 'task', 2, 2, true)); + self::assertSame(3, $result->totalItems); + self::assertSame(2, $result->page); + self::assertSame(2, $result->itemsPerPage); + } + + #[Test] + public function testDefaultScopeRetainsTheManagersCompleteJournal(): void + { + $entries = $this->createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->expects(self::once())->method('list')->with('task', null, 1, 30)->willReturn([]); + $entries->expects(self::once())->method('count')->with('task', null)->willReturn(6); + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('isMemberOf')->willReturn(true); + $authorization->method('hasPermission')->willReturn(true); + $workforce = $this->createStub(OrganizationWorkforceDirectoryPort::class); + $workforce->method('members')->willReturn([new OrganizationWorkforceMember('actor', 'user', true)]); + $policy = new InterventionTimeAccessPolicy($authorization, $workforce); + $result = new ListTimeEntriesHandler($entries, $policy)(new ListTimeEntriesQuery('user', 'task')); + self::assertSame(6, $result->totalItems); + } + // #endregion +} diff --git a/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandlerTest.php b/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandlerTest.php new file mode 100644 index 000000000..3b8fa632f --- /dev/null +++ b/tests/Unit/Intervention/Application/UseCase/Query/Time/ListTimeEntryVersions/ListTimeEntryVersionsHandlerTest.php @@ -0,0 +1,86 @@ +createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->method('find')->willReturn(new TimeEntryView('entry', 'task', 'actor', '2026-01-01', 60, null, 500, false, 'actor', 'actor', 'now', 'now')); + $entries->expects(self::once())->method('versions')->with('entry', 401, 3)->willReturn([ + new TimeEntryVersionView(400, '2026-01-01', 60, null, false, 'actor', 'now'), + new TimeEntryVersionView(399, '2026-01-01', 60, null, false, 'actor', 'now'), + new TimeEntryVersionView(398, '2026-01-01', 60, null, false, 'actor', 'now'), + ]); + $entries->expects(self::once())->method('countVersions')->with('entry')->willReturn(500); + $result = new ListTimeEntryVersionsHandler($entries, $this->policy())(new ListTimeEntryVersionsQuery('user', 'task', 'entry', 401, 2)); + self::assertSame(500, $result->totalItems); + self::assertCount(2, $result->versions); + self::assertSame(399, $result->nextBeforeRevision); + } + + #[Test] + public function testHidesAnotherBeneficiaryBeforeReadingItsVersions(): void + { + $entries = $this->createMock(InterventionTimeEntryRepositoryPort::class); + $entries->method('context')->willReturn(new TimeEntryTaskContext('task', 'intervention', 'org', 'actor', null, [], [])); + $entries->method('find')->willReturn(new TimeEntryView('entry', 'task', 'other', '2026-01-01', 60, null, 500, false, 'other', 'other', 'now', 'now')); + $entries->expects(self::never())->method('versions'); + $entries->expects(self::never())->method('countVersions'); + $this->expectException(InterventionNotFoundException::class); + new ListTimeEntryVersionsHandler($entries, $this->policy())(new ListTimeEntryVersionsQuery('user', 'task', 'entry')); + } + + #[Test] + public function testRejectsAnUnboundedWindowAtTheApplicationBoundary(): void + { + $this->expectException(InvalidArgumentException::class); + new ListTimeEntryVersionsQuery('user', 'task', 'entry', null, 101); + } + + /** + * Method policy + * + * Supplies an active caller without the other-beneficiary management grant. + * + * @access private + * + * @return InterventionTimeAccessPolicy real policy using mocked outbound ports + */ + private function policy(): InterventionTimeAccessPolicy + { + $authorization = $this->createStub(OrganizationAuthorizationPort::class); + $authorization->method('isMemberOf')->willReturn(true); + $authorization->method('hasPermission')->willReturn(false); + $workforce = $this->createStub(OrganizationWorkforceDirectoryPort::class); + $workforce->method('members')->willReturn([new OrganizationWorkforceMember('actor', 'user', true)]); + + return new InterventionTimeAccessPolicy($authorization, $workforce); + } + // #endregion +} diff --git a/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepositoryTest.php b/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepositoryTest.php new file mode 100644 index 000000000..6b63bb563 --- /dev/null +++ b/tests/Unit/Intervention/Infrastructure/Persistence/Doctrine/Repository/InterventionTimeEntryRepositoryTest.php @@ -0,0 +1,79 @@ +createMock(EntityRepository::class); + $records->expects(self::once())->method('findBy')->with(['workItem' => 'task', 'memberId' => 'actor'], ['workedOn' => 'DESC', 'id' => 'ASC'], 30, 60)->willReturn([$this->record()]); + $manager = $this->createMock(EntityManagerInterface::class); + $manager->expects(self::once())->method('getRepository')->with(InterventionTimeEntryRecord::class)->willReturn($records); + $result = new InterventionTimeEntryRepository($manager)->list('task', 'actor', 3, 30); + self::assertCount(1, $result); + self::assertCount(1, $result[0]->versions); + self::assertSame(100000, $result[0]->totalVersions); + self::assertSame(100000, $result[0]->nextBeforeRevision); + } + + #[Test] + public function testFindNeverQueriesHistoryEvenForAHeavilyRevisedEntry(): void + { + $manager = $this->createMock(EntityManagerInterface::class); + $record = $this->record(); + $manager->expects(self::once())->method('find')->with(InterventionTimeEntryRecord::class, 'entry')->willReturn($record); + $manager->expects(self::once())->method('refresh')->with($record); + $manager->expects(self::never())->method('getRepository'); + $result = new InterventionTimeEntryRepository($manager)->find('entry'); + self::assertNotNull($result); + self::assertCount(1, $result->versions); + self::assertSame(100000, $result->versions[0]->revision); + } + + /** + * Method record + * + * Represents a long-lived journal entry without allocating its entire history. + * + * @access private + * + * @return InterventionTimeEntryRecord current scalar entry + */ + private function record(): InterventionTimeEntryRecord + { + $task = new InterventionWorkItemRecord(); + $task->id = 'task'; + $record = new InterventionTimeEntryRecord(); + $record->id = 'entry'; + $record->workItem = $task; + $record->memberId = 'actor'; + $record->workedOn = '2026-01-01'; + $record->minutes = 60; + $record->revision = 100000; + $record->createdBy = $record->updatedBy = 'actor'; + $record->createdAt = $record->updatedAt = new DateTimeImmutable(); + + return $record; + } + // #endregion +} diff --git a/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionsControllerTest.php b/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionsControllerTest.php index b95031123..7170c9562 100644 --- a/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionsControllerTest.php +++ b/tests/Unit/Intervention/Presentation/Api/Controller/ExportInterventionsControllerTest.php @@ -17,6 +17,7 @@ use RuntimeException; use Shared\Application\Port\Inbound\QueryBusPort; use Shared\Application\Port\Outbound\EventDispatcherPort; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\{Request, StreamedResponse}; use Symfony\Component\HttpKernel\Exception\{AccessDeniedHttpException, BadRequestHttpException, NotFoundHttpException, UnprocessableEntityHttpException}; @@ -143,7 +144,7 @@ public function testItRefusesAnUnauthenticatedCaller(): void eventDispatcher: $this->createStub(EventDispatcherPort::class), security: $security, criteriaFactory: new InterventionExportCriteriaFactory(), - csvWriter: new InterventionCsvWriter(), + csvWriter: new InterventionCsvWriter(new SpreadsheetSafeTextAdapter()), ); $this->expectException(AccessDeniedHttpException::class); @@ -220,7 +221,7 @@ private function createController(QueryBusPort $queryBus, EventDispatcherPort $e eventDispatcher: $eventDispatcher, security: $security, criteriaFactory: new InterventionExportCriteriaFactory(), - csvWriter: new InterventionCsvWriter(), + csvWriter: new InterventionCsvWriter(new SpreadsheetSafeTextAdapter()), ); } } diff --git a/tests/Unit/Intervention/Presentation/Api/Provider/InterventionTimeProviderTest.php b/tests/Unit/Intervention/Presentation/Api/Provider/InterventionTimeProviderTest.php new file mode 100644 index 000000000..941b2ce39 --- /dev/null +++ b/tests/Unit/Intervention/Presentation/Api/Provider/InterventionTimeProviderTest.php @@ -0,0 +1,149 @@ +createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListTimeEntriesQuery $query): bool => 2 === $query->page && 30 === $query->itemsPerPage && 'task' === $query->taskId && !$query->ownOnly))->willReturn(new ListTimeEntriesResult([], 61, 2, 30)); + $output = new InterventionTimeProvider($queries, $this->security())->provide(new Get(name: InterventionTimeOperations::LIST), ['taskId' => 'task'], ['filters' => ['page' => '2']]); + self::assertInstanceOf(TimeJournalOutput::class, $output); + self::assertSame(3, $output->nextPage); + self::assertSame(61, $output->totalItems); + } + + #[Test] + public function testParsedApiParametersTakePrecedenceOverLegacyContext(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListTimeEntriesQuery $query): bool => 3 === $query->page && 20 === $query->itemsPerPage && $query->ownOnly))->willReturn(new ListTimeEntriesResult([], 61, 3, 20)); + $operation = new Get(name: InterventionTimeOperations::LIST, parameters: [ + 'page' => new QueryParameter(extraProperties: ['_api_values' => '3']), + 'itemsPerPage' => new QueryParameter(extraProperties: ['_api_values' => '20']), + 'ownOnly' => new QueryParameter(extraProperties: ['_api_values' => 'true']), + ]); + $output = new InterventionTimeProvider($queries, $this->security())->provide($operation, ['taskId' => 'task'], ['filters' => ['page' => '1', 'itemsPerPage' => '100', 'ownOnly' => false]]); + self::assertInstanceOf(TimeJournalOutput::class, $output); + self::assertSame(3, $output->page); + self::assertSame(20, $output->itemsPerPage); + self::assertSame(4, $output->nextPage); + } + + #[Test] + public function testAbsentParsedApiParameterKeepsTheLegacyContextValue(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListTimeEntriesQuery $query): bool => 2 === $query->page && 30 === $query->itemsPerPage && $query->ownOnly))->willReturn(new ListTimeEntriesResult([], 61, 2, 30)); + $operation = new Get(name: InterventionTimeOperations::LIST, parameters: ['page' => new QueryParameter(), 'ownOnly' => new QueryParameter()]); + $output = new InterventionTimeProvider($queries, $this->security())->provide($operation, ['taskId' => 'task'], ['filters' => ['page' => '2', 'ownOnly' => true]]); + self::assertInstanceOf(TimeJournalOutput::class, $output); + self::assertSame(2, $output->page); + } + + #[Test] + public function testMalformedParsedApiPageSizeIsRejectedBeforeDispatch(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $operation = new Get(name: InterventionTimeOperations::LIST, parameters: ['itemsPerPage' => new QueryParameter(extraProperties: ['_api_values' => ['30']])]); + $this->expectException(BadRequestHttpException::class); + new InterventionTimeProvider($queries, $this->security())->provide($operation, ['taskId' => 'task']); + } + + #[Test] + public function testExplicitFalseScopeDoesNotCoerceToTrue(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListTimeEntriesQuery $query): bool => !$query->ownOnly))->willReturn(new ListTimeEntriesResult([], 0, 1, 30)); + $operation = new Get(name: InterventionTimeOperations::LIST, parameters: ['ownOnly' => new QueryParameter(extraProperties: ['_api_values' => 'false'])]); + new InterventionTimeProvider($queries, $this->security())->provide($operation, ['taskId' => 'task'], ['filters' => ['ownOnly' => true]]); + } + + #[Test] + public function testMalformedOwnOnlyScopeIsRejectedBeforeDispatch(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $operation = new Get(name: InterventionTimeOperations::LIST, parameters: ['ownOnly' => new QueryParameter(extraProperties: ['_api_values' => ['true']])]); + $this->expectException(BadRequestHttpException::class); + new InterventionTimeProvider($queries, $this->security())->provide($operation, ['taskId' => 'task']); + } + + #[Test] + public function testHistoryTranslatesAnExclusiveCursorWithoutExpandingTheJournal(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (ListTimeEntryVersionsQuery $query): bool => 51 === $query->beforeRevision && 20 === $query->itemsPerPage && 'entry' === $query->entryId))->willReturn(new ListTimeEntryVersionsResult([], 60, 20, 31)); + $output = new InterventionTimeProvider($queries, $this->security())->provide(new Get(name: InterventionTimeOperations::VERSIONS), ['taskId' => 'task', 'entryId' => 'entry'], ['filters' => ['beforeRevision' => '51', 'itemsPerPage' => '20']]); + self::assertInstanceOf(TimeEntryHistoryOutput::class, $output); + self::assertSame(31, $output->nextBeforeRevision); + self::assertSame(60, $output->totalItems); + } + + #[Test] + public function testRejectsOversizedHistoryBeforeAnyQuery(): void + { + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::never())->method('ask'); + $this->expectException(BadRequestHttpException::class); + new InterventionTimeProvider($queries, $this->security())->provide(new Get(name: InterventionTimeOperations::VERSIONS), ['taskId' => 'task', 'entryId' => 'entry'], ['filters' => ['itemsPerPage' => '101']]); + } + + #[Test] + public function testCurrentEntryReadDispatchesOnlyTheSelectedIdentifier(): void + { + $entry = new TimeEntryView('entry', 'task', 'actor', '2026-01-01', 60, null, 50, false, 'actor', 'actor', 'now', 'now'); + $queries = $this->createMock(QueryBusPort::class); + $queries->expects(self::once())->method('ask')->with(self::callback(static fn (GetTimeEntryQuery $query): bool => 'task' === $query->taskId && 'entry' === $query->entryId))->willReturn(new GetTimeEntryResult($entry)); + $output = new InterventionTimeProvider($queries, $this->security())->provide(new Get(name: InterventionTimeOperations::GET), ['taskId' => 'task', 'entryId' => 'entry']); + self::assertInstanceOf(TimeEntryOutput::class, $output); + self::assertSame($entry, $output->entry); + } + + /** + * Method security + * + * Resolves one authenticated caller for HTTP translation. + * + * @access private + * + * @return Security security boundary stub + */ + private function security(): Security + { + $security = $this->createStub(Security::class); + $security->method('getUser')->willReturn(new SecurityUser('user', 'user@example.test', 'hashed', ['ROLE_USER'])); + + return $security; + } + // #endregion +} diff --git a/tests/Unit/Intervention/Presentation/Api/Service/InterventionCsvWriterTest.php b/tests/Unit/Intervention/Presentation/Api/Service/InterventionCsvWriterTest.php index 53e0e4646..e51a6513f 100644 --- a/tests/Unit/Intervention/Presentation/Api/Service/InterventionCsvWriterTest.php +++ b/tests/Unit/Intervention/Presentation/Api/Service/InterventionCsvWriterTest.php @@ -8,6 +8,7 @@ use Intervention\Presentation\Api\Service\InterventionCsvWriter; use PHPUnit\Framework\Attributes\{CoversClass, Test}; use PHPUnit\Framework\TestCase; +use Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter; use function explode; use function fclose; @@ -29,7 +30,7 @@ final class InterventionCsvWriterTest extends TestCase #[Test] public function testWriteEmitsTheHeaderRowFirst(): void { - $writer = new InterventionCsvWriter(); + $writer = new InterventionCsvWriter(new SpreadsheetSafeTextAdapter()); $handle = fopen('php://memory', 'w+'); self::assertNotFalse($handle); @@ -41,7 +42,7 @@ public function testWriteEmitsTheHeaderRowFirst(): void $lines = explode("\n", $content); self::assertSame( - ['id', 'name', 'type', 'status', 'priority', 'facility', 'assignee', 'due_at', 'created_at', 'updated_at'], + ['id', 'name', 'type', 'status', 'priority', 'facility', 'assignee', 'due_at', 'created_at', 'updated_at', '_fireguard_text_encoding'], str_getcsv($lines[0], escape: '\\'), ); } @@ -64,7 +65,7 @@ public function testWriteFallsBackToTheRawIdentifierWhenTheNameCouldNotBeResolve updatedAt: '2026-08-02T00:00:00+00:00', ); - $writer = new InterventionCsvWriter(); + $writer = new InterventionCsvWriter(new SpreadsheetSafeTextAdapter()); $handle = fopen('php://memory', 'w+'); self::assertNotFalse($handle); @@ -99,7 +100,7 @@ public function testWriteEmitsAnEmptyCellWhenNoSiteOrResponsibleIsSet(): void updatedAt: '2026-08-02T00:00:00+00:00', ); - $writer = new InterventionCsvWriter(); + $writer = new InterventionCsvWriter(new SpreadsheetSafeTextAdapter()); $handle = fopen('php://memory', 'w+'); self::assertNotFalse($handle); diff --git a/tests/Unit/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapterTest.php b/tests/Unit/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapterTest.php index 3edd50595..b40b1a485 100644 --- a/tests/Unit/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapterTest.php +++ b/tests/Unit/Notification/Infrastructure/Adapter/Channel/EmailNotificationChannelAdapterTest.php @@ -4,16 +4,26 @@ namespace Tests\Unit\Notification\Infrastructure\Adapter\Channel; +use DOMDocument; +use DOMElement; +use DOMXPath; use Notification\Domain\Model\Notification\{Notification, NotificationTarget}; use Notification\Domain\ValueObject\NotificationId; use Notification\Infrastructure\Adapter\Channel\EmailNotificationChannelAdapter; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shared\Application\Port\Outbound\MailerPort; use Shared\Domain\ValueObject\Email; +use Symfony\Bridge\Twig\Extension\TranslationExtension; +use Symfony\Component\Translation\Translator; use Twig\Environment; -use Twig\Loader\ArrayLoader; +use Twig\Loader\{ArrayLoader, FilesystemLoader}; + +use function dirname; + +use const LIBXML_NOERROR; +use const LIBXML_NOWARNING; #[CoversClass(EmailNotificationChannelAdapter::class)] final class EmailNotificationChannelAdapterTest extends TestCase @@ -22,7 +32,7 @@ final class EmailNotificationChannelAdapterTest extends TestCase public function testSendRendersDefaultTemplateWhenNoTemplateProvided(): void { $twig = new Environment(new ArrayLoader([ - 'notification/email/default.html.twig' => '

{{ subject }}

{{ body|raw }}
', + 'notification/email/default.html.twig' => '

{{ subject }}

{% if bodyIsHtml %}{{ body|raw }}{% else %}{{ body }}{% endif %}
', ])); /** @var MailerPort&MockObject $mailer */ @@ -32,7 +42,7 @@ public function testSendRendersDefaultTemplateWhenNoTemplateProvided(): void ->with( ['member@example.com'], 'Invitation to join Fireguard HQ', - '

Invitation to join Fireguard HQ

Open invitation details.

', + '

Invitation to join Fireguard HQ

<p>Open invitation details.</p>
', [], [], [], @@ -50,7 +60,7 @@ public function testSendRendersDefaultTemplateWhenNoTemplateProvided(): void public function testSendRendersCustomTemplateWithContext(): void { $twig = new Environment(new ArrayLoader([ - 'notification/email/default.html.twig' => '

{{ subject }}

{{ body|raw }}
', + 'notification/email/default.html.twig' => '

{{ subject }}

{% if bodyIsHtml %}{{ body|raw }}{% else %}{{ body }}{% endif %}
', 'notification/email/organization_invitation.html.twig' => '{{ organizationName }}|{{ token }}|{{ expiresAt }}|{{ subject }}', ])); @@ -89,7 +99,7 @@ public function testSendRendersCustomTemplateWithContext(): void public function testSendSkipsWhenNotificationHasNoRecipientEmail(): void { $twig = new Environment(new ArrayLoader([ - 'notification/email/default.html.twig' => '

{{ subject }}

{{ body|raw }}
', + 'notification/email/default.html.twig' => '

{{ subject }}

{% if bodyIsHtml %}{{ body|raw }}{% else %}{{ body }}{% endif %}
', ])); /** @var MailerPort&MockObject $mailer */ @@ -109,7 +119,7 @@ public function testSendSkipsWhenNotificationHasNoRecipientEmail(): void public function testSendPrefersTheChannelBodyOverrideAndSkipsNonStringContextKeys(): void { $twig = new Environment(new ArrayLoader([ - 'notification/email/default.html.twig' => '

{{ subject }}

{{ body|raw }}
{{ cta|default("none") }}', + 'notification/email/default.html.twig' => '

{{ subject }}

{% if bodyIsHtml %}{{ body|raw }}{% else %}{{ body }}{% endif %}
{{ cta|default("none") }}', ])); /** @var MailerPort&MockObject $mailer */ @@ -119,7 +129,7 @@ public function testSendPrefersTheChannelBodyOverrideAndSkipsNonStringContextKey ->with( ['member@example.com'], 'Invitation to join Fireguard HQ', - '

Invitation to join Fireguard HQ

Channel-specific body.

Accept', + '

Invitation to join Fireguard HQ

<p>Channel-specific body.</p>
Accept', [], [], [], @@ -146,7 +156,7 @@ public function testSendPrefersTheChannelBodyOverrideAndSkipsNonStringContextKey public function testSendKeepsTheAggregateBodyWhenTheChannelBodyIsBlank(): void { $twig = new Environment(new ArrayLoader([ - 'notification/email/default.html.twig' => '

{{ subject }}

{{ body|raw }}
', + 'notification/email/default.html.twig' => '

{{ subject }}

{% if bodyIsHtml %}{{ body|raw }}{% else %}{{ body }}{% endif %}
', ])); /** @var MailerPort&MockObject $mailer */ @@ -156,7 +166,7 @@ public function testSendKeepsTheAggregateBodyWhenTheChannelBodyIsBlank(): void ->with( ['member@example.com'], 'Invitation to join Fireguard HQ', - '

Invitation to join Fireguard HQ

Open invitation details.

', + '

Invitation to join Fireguard HQ

<p>Open invitation details.</p>
', [], [], [], @@ -173,13 +183,87 @@ public function testSendKeepsTheAggregateBodyWhenTheChannelBodyIsBlank(): void ); } - private function createNotification(?string $recipientEmail = 'member@example.com'): Notification + /** + * @param array $channelPayload + */ + #[Test] + #[DataProvider('textOnlyPayloads')] + public function testRealDefaultTemplateEscapesBodiesWithoutStrictHtmlOptIn(array $channelPayload): void + { + $body = 'Review & "confirm"'; + $rendered = null; + $mailer = $this->createMock(MailerPort::class); + $mailer->expects(self::once())->method('send') + ->willReturnCallback(static function (array $to, string $subject, string $html) use (&$rendered): void { + $rendered = $html; + }); + $adapter = new EmailNotificationChannelAdapter($mailer, $this->realTemplates()); + $adapter->send($this->createNotification(body: $body), $channelPayload); + + self::assertIsString($rendered); + $document = new DOMDocument(); + self::assertTrue($document->loadHTML($rendered, LIBXML_NOERROR | LIBXML_NOWARNING)); + $xpath = new DOMXPath($document); + $cells = $xpath->query('//td[@class="prose"]'); + self::assertNotFalse($cells); + self::assertCount(1, $cells); + $cell = $cells->item(0); + self::assertInstanceOf(DOMElement::class, $cell); + self::assertSame($body, $cell->textContent); + $elements = $xpath->query('.//*', $cell); + self::assertNotFalse($elements); + self::assertCount(0, $elements); + } + + #[Test] + public function testRealDefaultTemplatePreservesExplicitTrustedHtmlDespiteContextOverride(): void + { + $body = '

Trusted message & details

'; + $rendered = null; + $mailer = $this->createMock(MailerPort::class); + $mailer->expects(self::once())->method('send') + ->willReturnCallback(static function (array $to, string $subject, string $html) use (&$rendered): void { + $rendered = $html; + }); + $adapter = new EmailNotificationChannelAdapter($mailer, $this->realTemplates()); + $adapter->send($this->createNotification(body: $body), [ + 'bodyIsHtml' => true, + 'context' => ['bodyIsHtml' => false], + ]); + + self::assertIsString($rendered); + self::assertStringContainsString('' . $body . '', $rendered); + } + + /** + * @return iterable}> + */ + public static function textOnlyPayloads(): iterable + { + yield 'default' => [[]]; + yield 'false' => [['bodyIsHtml' => false]]; + yield 'string true' => [['bodyIsHtml' => 'true']]; + yield 'integer one' => [['bodyIsHtml' => 1]]; + yield 'null' => [['bodyIsHtml' => null]]; + yield 'context cannot enable HTML' => [['context' => ['bodyIsHtml' => true]]]; + yield 'false cannot be overridden by context' => [['bodyIsHtml' => false, 'context' => ['bodyIsHtml' => true]]]; + } + + private function realTemplates(): Environment + { + $twig = new Environment(new FilesystemLoader(dirname(__DIR__, 6) . '/templates'), ['autoescape' => 'html']); + $twig->addExtension(new TranslationExtension(new Translator('en'))); + + return $twig; + } + + private function createNotification(?string $recipientEmail = 'member@example.com', string $body = '

Open invitation details.

'): Notification { return Notification::create( id: new NotificationId('550e8400-e29b-41d4-a716-446655442300'), type: 'organization.invitation', subject: 'Invitation to join Fireguard HQ', - body: '

Open invitation details.

', + body: $body, channels: ['email'], payload: ['organizationName' => 'Fireguard HQ'], target: new NotificationTarget( diff --git a/tests/Unit/Notification/Infrastructure/Console/SendNotificationConsoleCommandTest.php b/tests/Unit/Notification/Infrastructure/Console/SendNotificationConsoleCommandTest.php index 22d9eddba..9286b5af8 100644 --- a/tests/Unit/Notification/Infrastructure/Console/SendNotificationConsoleCommandTest.php +++ b/tests/Unit/Notification/Infrastructure/Console/SendNotificationConsoleCommandTest.php @@ -13,12 +13,13 @@ use Notification\Application\Contract\Notification\NotificationType; use Notification\Application\Port\Inbound\NotificationPort; use Notification\Infrastructure\Console\SendNotificationConsoleCommand; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +use Tests\Support\Notification\EmailPipelineTestTrait; /** * Test SendNotificationConsoleCommand. @@ -31,6 +32,8 @@ #[CoversClass(SendNotificationConsoleCommand::class)] final class SendNotificationConsoleCommandTest extends TestCase { + use EmailPipelineTestTrait; + // #region Constants private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001'; @@ -49,6 +52,7 @@ public function testConfigureDeclaresArgumentsAndOptions(): void self::assertTrue($definition->hasOption('user-id')); self::assertTrue($definition->hasOption('email')); self::assertTrue($definition->hasOption('organization-id')); + self::assertFalse($definition->getOption('body-is-html')->getDefault()); self::assertSame( NotificationChannel::EMAIL->value, $definition->getOption('channels')->getDefault(), @@ -68,7 +72,8 @@ public function testSendsThroughTheEmailChannelByDefault(): void && [NotificationChannel::EMAIL] === $request->channels && 'user@example.com' === $request->recipientEmail && null === $request->recipientUserId - && null === $request->organizationId)) + && null === $request->organizationId + && [] === $request->deliveryPayload)) ->willReturn($this->sentNotification()); $tester = new CommandTester($this->createCommand($port)); @@ -84,6 +89,62 @@ public function testSendsThroughTheEmailChannelByDefault(): void self::assertStringContainsString('notification-id', $tester->getDisplay()); } + #[Test] + #[DataProvider('htmlModes')] + public function testTrustedHtmlRequiresAnExplicitOperatorFlag(bool $bodyIsHtml): void + { + $body = '

Operator announcement

'; + $port = $this->createMock(NotificationPort::class); + $port->expects(self::once())->method('send') + ->with(self::callback(static fn (SendNotificationRequest $request): bool => $body === $request->body + && ($bodyIsHtml ? [NotificationChannel::EMAIL->value => ['bodyIsHtml' => true]] : []) === $request->deliveryPayload)) + ->willReturn($this->sentNotification()); + $tester = new CommandTester($this->createCommand($port)); + $arguments = [ + 'type' => NotificationType::SYSTEM_ANNOUNCEMENT, + 'subject' => 'Announcement', + 'body' => $body, + '--email' => 'user@example.com', + ]; + if ($bodyIsHtml) { + $arguments['--body-is-html'] = true; + } + + self::assertSame(Command::SUCCESS, $tester->execute($arguments)); + } + + /** + * @return iterable + */ + public static function htmlModes(): iterable + { + yield 'plain text by default' => [false]; + yield 'explicit trusted HTML' => [true]; + } + + #[Test] + public function testExplicitOperatorHtmlSurvivesTheRealDeliveryPipeline(): void + { + $body = '

Operator announcement

'; + $notifications = $this->emailPipeline(self::USER_ID, 'user@example.com', 'system', 'Announcement', false); + $tester = new CommandTester($this->createCommand($notifications)); + + self::assertSame(Command::SUCCESS, $tester->execute([ + 'type' => NotificationType::SYSTEM_ANNOUNCEMENT, + 'subject' => 'Announcement', + 'body' => $body, + '--email' => 'user@example.com', + '--user-id' => self::USER_ID, + '--channels' => 'email,mercure', + '--body-is-html' => true, + ])); + self::assertNotNull($this->storedEmailNotification); + self::assertSame($body, $this->storedEmailNotification->body()); + self::assertSame($this->storedEmailNotification, $this->mercureEmailNotification); + self::assertIsString($this->renderedNotificationEmail); + self::assertStringContainsString('' . $body . '', $this->renderedNotificationEmail); + } + #[Test] public function testSendsThroughMultipleChannelsAndForwardsTheOrganization(): void { diff --git a/tests/Unit/Organization/Application/UseCase/Command/Organization/AddOrganizationMember/AddOrganizationMemberEmailTest.php b/tests/Unit/Organization/Application/UseCase/Command/Organization/AddOrganizationMember/AddOrganizationMemberEmailTest.php new file mode 100644 index 000000000..b9a436106 --- /dev/null +++ b/tests/Unit/Organization/Application/UseCase/Command/Organization/AddOrganizationMember/AddOrganizationMemberEmailTest.php @@ -0,0 +1,191 @@ +Review & "confirm"'; + $plainBody = 'You now have access to ' . $name . '.'; + $organization = Organization::reconstitute(core: new RestoredOrganizationCore( + id: new OrganizationId($organizationId), + name: new OrganizationName($name), + createdByUserId: $userId, + isActive: true, + createdAt: new DateTimeImmutable('-1 day'), + )); + $role = OrganizationRole::reconstitute( + id: new OrganizationRoleId($roleId), + organizationId: new OrganizationId($organizationId), + name: new OrganizationRoleName('member'), + permissions: ['organization.read'], + isSystem: true, + createdAt: new DateTimeImmutable('-1 day'), + ); + $inactiveMember = $reactivating ? OrganizationMember::reconstitute( + id: new OrganizationMemberId($memberId), + organizationId: new OrganizationId($organizationId), + userId: $userId, + isActive: false, + joinedAt: new DateTimeImmutable('-5 days'), + ) : null; + + $organizationRepository = $this->createMock(OrganizationRepositoryPort::class); + $organizationRepository->expects(self::once())->method('findById')->willReturn($organization); + $userRepository = $this->createMock(UserRepositoryPort::class); + $userRepository->expects(self::once())->method('findById') + ->willReturn(UserTestFactory::createActive($userId, 'member@example.com')); + $roleRepository = $this->createMock(OrganizationRoleRepositoryPort::class); + $roleRepository->expects(self::once())->method('findByIdsInOrganization')->willReturn([$role]); + $memberRepository = $this->createMock(OrganizationMemberRepositoryPort::class); + $memberRepository->expects(self::once())->method('findByOrganizationAndUser')->willReturn($inactiveMember); + $memberRepository->expects(self::once())->method('save'); + $memberRepository->expects(self::once())->method('assignRole'); + $memberRepository->expects(self::exactly($reactivating ? 2 : 1))->method('findRoleIdsForMember')->willReturn([$roleId]); + $grantGuard = $this->createMock(OrganizationMemberGrantGuardPort::class); + $grantGuard->expects(self::exactly($reactivating ? 2 : 1))->method('assertCanGrant') + ->with(self::isInstanceOf(OrganizationMemberGrant::class), $organizationId, 'member@example.com', [$roleId]); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::once())->method('assertCanAdd')->with($organizationId, OrganizationQuotaResource::MEMBERS); + $transactionManager = $this->createMock(TransactionManagerPort::class); + $transactionManager->expects(self::once())->method('transactional') + ->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $eventDispatcher = $this->createMock(EventDispatcherPort::class); + $eventDispatcher->expects(self::once())->method('dispatch'); + $uuidFactory = $this->createStub(UuidFactory::class); + $uuidFactory->method('create')->willReturnMap([ + [OrganizationMemberId::class, new OrganizationMemberId($memberId)], + [NotificationId::class, new NotificationId('550e8400-e29b-41d4-a716-446655449010')], + ]); + $warnings = []; + $logger = $this->createStub(LoggerPort::class); + $logger->method('warning')->willReturnCallback(static function (string $message, array $context) use (&$warnings): void { + $warnings[] = [$message, $context]; + }); + + $notificationRepository = $this->createMock(NotificationRepositoryPort::class); + $notificationRepository->expects(self::once())->method('save') + ->with(self::callback(static fn (Notification $notification): bool => NotificationType::ORGANIZATION_MEMBER_ADDED === $notification->type() + && $plainBody === $notification->body() + && $name === $notification->payload()['organizationName'])); + $preferences = $this->createMock(NotificationPreferenceRepositoryPort::class); + $preferences->expects(self::once())->method('findByUserIdAndCategory') + ->with($userId, 'organization')->willReturn(null); + $mercure = $this->createMock(MercureNotificationChannelPort::class); + $mercure->expects(self::once())->method('publish') + ->with(self::callback(static fn (Notification $notification): bool => $plainBody === $notification->body()), []); + $renderedHtml = null; + $mailer = $this->createMock(MailerPort::class); + $mailer->expects(self::once())->method('send') + ->with(['member@example.com'], 'You have been added to ' . $name, self::isString()) + ->willReturnCallback(static function (array $to, string $subject, string $body) use (&$renderedHtml): void { + $renderedHtml = $body; + }); + $twig = new Environment(new FilesystemLoader(dirname(__DIR__, 8) . '/templates'), ['autoescape' => 'html']); + $twig->addExtension(new TranslationExtension(new Translator('en'))); + $notificationService = new NotificationService(new SendNotificationHandler( + notificationRepository: $notificationRepository, + preferenceRepository: $preferences, + emailChannel: new EmailNotificationChannelAdapter($mailer, $twig), + mercureChannel: $mercure, + recipientDirectory: $this->createStub(RecipientDirectoryPort::class), + logger: $logger, + uuidFactory: $uuidFactory, + )); + $handler = new AddOrganizationMemberHandler( + organizationRepository: $organizationRepository, + memberRepository: $memberRepository, + roleRepository: $roleRepository, + userRepository: $userRepository, + notificationPort: $notificationService, + logger: $logger, + uuidFactory: $uuidFactory, + transactionManager: $transactionManager, + quota: $quota, + eventDispatcher: $eventDispatcher, + grantGuard: $grantGuard, + ); + + $result = $handler(new AddOrganizationMemberCommand( + organizationId: $organizationId, + userId: $userId, + grant: OrganizationMemberGrant::forActor('actor'), + roleIds: [$roleId], + )); + + self::assertTrue($result->isActive); + self::assertTrue($result->wasCreatedOrReactivated); + self::assertSame([], $warnings); + self::assertIsString($renderedHtml); + self::assertStringContainsString('<a href="https://untrusted.invalid">Review & "confirm"</a>', $renderedHtml); + $document = new DOMDocument(); + self::assertTrue($document->loadHTML($renderedHtml, LIBXML_NOERROR | LIBXML_NOWARNING)); + $bodyCells = new DOMXPath($document)->query('//td[@class="prose"]'); + self::assertNotFalse($bodyCells); + self::assertCount(1, $bodyCells); + $bodyCell = $bodyCells->item(0); + self::assertInstanceOf(DOMElement::class, $bodyCell); + self::assertSame($plainBody, $bodyCell->textContent); + $elements = new DOMXPath($document)->query('.//*', $bodyCell); + self::assertNotFalse($elements); + self::assertCount(0, $elements); + } + + /** + * @return iterable + */ + public static function membershipChanges(): iterable + { + yield 'new member' => [false]; + yield 'reactivated member' => [true]; + } +} diff --git a/tests/Unit/Organization/Application/UseCase/Command/Organization/ChangeOrganizationPlan/ChangeOrganizationPlanEmailTest.php b/tests/Unit/Organization/Application/UseCase/Command/Organization/ChangeOrganizationPlan/ChangeOrganizationPlanEmailTest.php new file mode 100644 index 000000000..929f58262 --- /dev/null +++ b/tests/Unit/Organization/Application/UseCase/Command/Organization/ChangeOrganizationPlan/ChangeOrganizationPlanEmailTest.php @@ -0,0 +1,106 @@ +Review & "confirm"'; + $plainBody = 'The plan applied to ' . $name . ' has lower limits than its current usage (members 60/50). Existing data is preserved, but new creations stay blocked until usage fits the plan.'; + $organization = Organization::reconstitute(core: new RestoredOrganizationCore( + id: new OrganizationId($organizationId), + name: new OrganizationName($name), + createdByUserId: $ownerUserId, + isActive: true, + createdAt: new DateTimeImmutable('2026-01-01T00:00:00+00:00'), + )); + $plan = Plan::create( + id: new PlanId($planId), + key: new PlanKey('pro'), + name: 'Pro', + limits: ['members' => 50], + ); + $organizationRepository = $this->createMock(OrganizationRepositoryPort::class); + $organizationRepository->expects(self::once())->method('findById')->with(new OrganizationId($organizationId))->willReturn($organization); + $organizationRepository->expects(self::once())->method('save')->with($organization); + $planRepository = $this->createMock(PlanRepositoryPort::class); + $planRepository->expects(self::once())->method('findById')->with(new PlanId($planId))->willReturn($plan); + $quota = $this->createMock(OrganizationQuotaPort::class); + $quota->expects(self::once())->method('getUsage')->with($organizationId, OrganizationQuotaResource::MEMBERS)->willReturn(60); + $userRepository = $this->createMock(UserRepositoryPort::class); + $userRepository->expects(self::once())->method('findById')->with(new UserId($ownerUserId)) + ->willReturn(UserTestFactory::createActive($ownerUserId, 'owner@example.com')); + $logger = $this->createMock(LoggerPort::class); + $logger->expects(self::never())->method('warning'); + $transactionManager = $this->createMock(TransactionManagerPort::class); + $transactionManager->expects(self::once())->method('transactional') + ->willReturnCallback(static fn (callable $operation): mixed => $operation()); + $eventDispatcher = $this->createMock(EventDispatcherPort::class); + $eventDispatcher->expects(self::once())->method('dispatch') + ->with(self::callback(static fn (OrganizationPlanChangedEvent $event): bool => $organizationId === $event->organizationId + && $planId === $event->planId + && null === $event->previousPlanId + && ['members'] === $event->overQuotaResources)); + $handler = new ChangeOrganizationPlanHandler( + organizationRepository: $organizationRepository, + planRepository: $planRepository, + quota: $quota, + userRepository: $userRepository, + notificationPort: $this->emailPipeline($ownerUserId, 'owner@example.com', 'organization', $name . ' exceeds its new plan limits', resolveEmail: false), + logger: $logger, + transactionManager: $transactionManager, + eventDispatcher: $eventDispatcher, + ); + + $result = $handler(new ChangeOrganizationPlanCommand( + organizationId: $organizationId, + planId: $planId, + acknowledgeOveruse: true, + )); + + self::assertSame($organizationId, $result->organizationId); + self::assertSame($planId, $result->planId); + self::assertSame($planId, (string) $organization->planId()); + $this->assertEmailPipelinePreservesPlainText( + NotificationType::ORGANIZATION_PLAN_OVER_QUOTA, + $plainBody, + ['organizationId' => $organizationId, 'overQuotaResources' => ['members']], + $ownerUserId, + $organizationId, + ); + } +} diff --git a/tests/Unit/Organization/Infrastructure/Image/OrganizationLogoResizerTest.php b/tests/Unit/Organization/Infrastructure/Image/OrganizationLogoResizerTest.php index fbd4e6cb6..9727457ad 100644 --- a/tests/Unit/Organization/Infrastructure/Image/OrganizationLogoResizerTest.php +++ b/tests/Unit/Organization/Infrastructure/Image/OrganizationLogoResizerTest.php @@ -5,11 +5,15 @@ namespace Tests\Unit\Organization\Infrastructure\Image; use Organization\Infrastructure\Image\OrganizationLogoResizer; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shared\Application\Contract\Image\InvalidImageInputException; use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Infrastructure\Image\ImageInputValidationAdapter; +use Tests\Support\Image\ImageFixtures; +use function getimagesizefromstring; use function imagecreatetruecolor; use function imagepng; use function ob_get_clean; @@ -66,7 +70,7 @@ public function testResizeWritesWebpUnderTheOrganizationPath(): void && 'WEBP' === substr($contents, 8, 4)), ); - new OrganizationLogoResizer($fileStorage)->resize(self::ORGANIZATION_ID, $this->pngBytes(1024, 768)); + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->resize(self::ORGANIZATION_ID, $this->pngBytes(1024, 768)); } #[Test] @@ -80,7 +84,7 @@ public function testResizeCapsAnOversizedSourceToTheMaximumDimension(): void $written = $contents; }); - new OrganizationLogoResizer($fileStorage)->resize(self::ORGANIZATION_ID, $this->pngBytes(2048, 2048)); + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->resize(self::ORGANIZATION_ID, $this->pngBytes(2048, 2048)); self::assertNotNull($written); self::assertLessThan( @@ -100,7 +104,7 @@ public function testDeleteRemovesAnExistingLogo(): void ->method('delete') ->with(OrganizationLogoResizer::pathFor(self::ORGANIZATION_ID)); - new OrganizationLogoResizer($fileStorage)->delete(self::ORGANIZATION_ID); + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->delete(self::ORGANIZATION_ID); } #[Test] @@ -111,7 +115,85 @@ public function testDeleteIsANoOpWhenNoLogoIsStored(): void $fileStorage->method('exists')->willReturn(false); $fileStorage->expects(self::never())->method('delete'); - new OrganizationLogoResizer($fileStorage)->delete(self::ORGANIZATION_ID); + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->delete(self::ORGANIZATION_ID); + } + + #[Test] + public function testRejectsOversizedGeometryBeforeAnyStorageMutation(): void + { + $fileStorage = $this->createMock(FileStoragePort::class); + $fileStorage->expects(self::never())->method('write'); + $fileStorage->expects(self::never())->method('delete'); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage('Image exceeds the 4194304 pixel limit.'); + + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->resize(self::ORGANIZATION_ID, ImageFixtures::pngHeader(2048, 2049)); + } + + #[Test] + #[DataProvider('undecodableSources')] + public function testRejectsAnUndecodableImageBeforeAnyStorageMutation(string $contents): void + { + $fileStorage = $this->createMock(FileStoragePort::class); + $fileStorage->expects(self::never())->method('write'); + $fileStorage->expects(self::never())->method('delete'); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage('Unable to decode the uploaded image.'); + + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->resize(self::ORGANIZATION_ID, $contents); + } + + #[Test] + #[DataProvider('acceptedSources')] + public function testAcceptedSourcesProduceAStaticWebpLogo(string $contents, int $expectedDimension): void + { + $fileStorage = $this->createMock(FileStoragePort::class); + $fileStorage->expects(self::once()) + ->method('write') + ->willReturnCallback(static function (string $path, string $contents) use ($expectedDimension): void { + $dimensions = getimagesizefromstring($contents); + self::assertNotFalse($dimensions); + self::assertSame([$expectedDimension, $expectedDimension], [$dimensions[0], $dimensions[1]]); + self::assertSame('image/webp', $dimensions['mime']); + self::assertStringNotContainsString('ANIM', $contents); + self::assertStringNotContainsString('ANMF', $contents); + }); + + new OrganizationLogoResizer($fileStorage, new ImageInputValidationAdapter())->resize(self::ORGANIZATION_ID, $contents); + } + + /** + * Method undecodableSources + * + * Exercises sources with bounded headers that native decoding must reject. + * + * @access public + * + * @return iterable the malformed image sources + */ + public static function undecodableSources(): iterable + { + yield 'truncated PNG pixels' => [ImageFixtures::pngHeader(1, 1)]; + yield 'WebP lossy canvas mismatch' => [ImageFixtures::webpWithCanvas(false, 1)]; + yield 'WebP lossless canvas mismatch' => [ImageFixtures::webpWithCanvas(true, 1)]; + } + + /** + * Method acceptedSources + * + * Exercises first-frame GIF conversion and consistent extended WebP sources. + * + * @access public + * + * @return iterable the valid source and output dimension + */ + public static function acceptedSources(): iterable + { + yield 'animated GIF' => [ImageFixtures::animatedGif(), 1]; + yield 'WebP lossy matching canvas' => [ImageFixtures::webpWithCanvas(false, 32), 32]; + yield 'WebP lossless matching canvas' => [ImageFixtures::webpWithCanvas(true, 32), 32]; } /** diff --git a/tests/Unit/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessorTest.php b/tests/Unit/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessorTest.php index 43a8689c9..93b7eba2a 100644 --- a/tests/Unit/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessorTest.php +++ b/tests/Unit/Organization/Presentation/Api/Processor/Organization/UploadOrganizationLogoProcessorTest.php @@ -16,13 +16,15 @@ use Organization\Domain\Exception\OrganizationNotFoundException; use Organization\Infrastructure\Image\OrganizationLogoResizer; use Organization\Presentation\Api\Processor\Organization\UploadOrganizationLogoProcessor; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; +use Shared\Application\Contract\Image\InvalidImageInputException; use Shared\Application\Exception\MessengerRuntimeException; use Shared\Application\Port\Inbound\{CommandBusPort, QueryBusPort}; use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Infrastructure\Image\ImageInputValidationAdapter; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\{Request, RequestStack}; @@ -31,6 +33,7 @@ BadRequestHttpException, UnprocessableEntityHttpException }; +use Tests\Support\Image\ImageFixtures; use function file_put_contents; use function imagecreatetruecolor; @@ -281,6 +284,45 @@ public function getMimeType(): string } } + #[Test] + #[DataProvider('rejectedImageHeaders')] + public function testRejectedImagesPreserveTheExistingLogoAndOrganizationState(string $contents, string $message): void + { + $request = new Request(); + $request->files->set('logo', new UploadedFile($this->temporaryFile($contents), 'logo.png', 'image/png', test: true)); + + $storage = $this->createMock(FileStoragePort::class); + $storage->expects(self::never())->method('delete'); + $storage->expects(self::never())->method('write'); + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::never())->method('dispatch'); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::never())->method('ask'); + + $processor = $this->createProcessor(request: $request, commandBus: $commandBus, queryBus: $queryBus, fileStorage: $storage); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage($message); + + $processor->process(null, new Post(), ['organizationId' => self::ORGANIZATION_ID]); + } + + /** + * Method rejectedImageHeaders + * + * Covers geometry rejection before decode and header-valid truncated pixels. + * + * @access public + * + * @return iterable the rejected source cases + */ + public static function rejectedImageHeaders(): iterable + { + yield 'oversized geometry' => [ImageFixtures::pngHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + yield 'malformed pixels' => [ImageFixtures::pngHeader(1, 1), 'Unable to decode the uploaded image.']; + yield 'GIF first-frame geometry' => [ImageFixtures::gifFirstFrameHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + } + private function createProcessor( ?Request $request = null, ?CommandBusPort $commandBus = null, @@ -306,6 +348,7 @@ private function createProcessor( requestStack: $requestStack, logoResizer: new OrganizationLogoResizer( $fileStorage ?? $this->createStub(FileStoragePort::class), + new ImageInputValidationAdapter(), ), commandBus: $commandBus ?? $this->createStub(CommandBusPort::class), queryBus: $queryBus, diff --git a/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php index 8e044bf1d..770d373bc 100644 --- a/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php +++ b/tests/Unit/Procurement/Application/UseCase/Command/ManageProcurement/ManageProcurementHandlerTest.php @@ -132,6 +132,64 @@ protected function setUp(): void $this->handler = new ManageProcurementHandler($this->repository, $this->authorization, $currencies, $parts, $this->stock, $this->equipment, new ProcurementProjection(), $clock, $ids, $this->events); } + #[Test] + public function supplierCreationRetryUsesTheCommittedIdentityAndEmitsOnce(): void + { + $this->repository->expects(self::once())->method('saveSupplier')->willReturnCallback(function (Supplier $supplier): void { $this->supplier = $supplier; }); + $this->repository->expects(self::once())->method('saveOperation'); + $this->events->expects(self::once())->method('dispatch'); + $input = ['clientOperationId' => self::OPERATION, 'name' => 'Supplier', 'contacts' => []]; + $first = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: $input)); + $retry = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: ['contacts' => [], 'name' => 'Supplier', 'clientOperationId' => strtoupper(self::OPERATION)])); + self::assertSame(self::RECEIPT, $first->data['id']); + self::assertSame($first->data, $retry->data); + self::assertTrue($retry->replayed); + self::assertSame(1, $this->supplier->revision()); + } + + #[Test] + public function supplierCreationIdentityRejectsChangedPayloadBeforeAnotherSave(): void + { + $this->repository->expects(self::once())->method('saveSupplier'); + $this->events->expects(self::once())->method('dispatch'); + $input = ['clientOperationId' => self::OPERATION, 'name' => 'Supplier']; + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: $input)); + $input['name'] = 'Another supplier'; + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('different declaration'); + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_supplier', payload: $input)); + } + + #[Test] + public function draftCreationRetryRetainsGeneratedLineIdsAndDoesNotSaveOrEmitTwice(): void + { + $this->repository->expects(self::once())->method('saveOrder'); + $this->repository->expects(self::once())->method('saveOperation'); + $this->events->expects(self::once())->method('dispatch'); + $input = ['clientOperationId' => self::OPERATION, 'name' => 'Draft', 'supplierId' => self::SUPPLIER, 'lines' => [['kind' => 'part', 'partId' => self::PART, 'quantity' => '2']]]; + $first = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); + $input['supplierId'] = strtoupper(self::SUPPLIER); + $input['lines'][0]['partId'] = strtoupper(self::PART); + $retry = ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); + self::assertSame(self::RECEIPT, $first->data['id']); + self::assertSame($first->data, $retry->data); + self::assertTrue($retry->replayed); + self::assertSame(1, $this->order->revision()); + } + + #[Test] + public function draftCreationIdentityRejectsChangedLines(): void + { + $this->repository->expects(self::once())->method('saveOrder'); + $this->events->expects(self::once())->method('dispatch'); + $input = ['clientOperationId' => self::OPERATION, 'name' => 'Draft', 'supplierId' => self::SUPPLIER, 'lines' => [['kind' => 'part', 'partId' => self::PART, 'quantity' => '2']]]; + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); + $input['lines'][0]['quantity'] = '3'; + $this->expectException(ProcurementException::class); + $this->expectExceptionMessage('different declaration'); + ($this->handler)(new ManageProcurementCommand(self::ACTOR, self::ORG, 'create_order', payload: $input)); + } + #[Test] public function partialDecimalReceiptReplaysWithoutAnotherStockMovementOrRevision(): void { diff --git a/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php b/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php index ae7672905..05257ea80 100644 --- a/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php +++ b/tests/Unit/Procurement/Presentation/Api/Processor/ProcurementProcessorTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; use Procurement\Application\UseCase\Command\ManageProcurement\{ManageProcurementCommand, ManageProcurementResult}; use Procurement\Domain\Exception\ProcurementException; -use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; +use Procurement\Presentation\Api\Dto\Input\{ChangePurchaseOrderInput, ChangeSupplierInput, CreateSupplierInput, IndividualizeReceiptInput, ReceivePurchaseOrderInput, ReturnProcurementReceiptInput}; use Procurement\Presentation\Api\Dto\Output\{ProcurementReceiptOutput, PurchaseOrderOutput, SupplierOutput}; use Procurement\Presentation\Api\Operation\ProcurementOperations; use Procurement\Presentation\Api\Processor\ProcurementProcessor; @@ -66,12 +66,13 @@ final class ProcurementProcessorTest extends TestCase #[Test] public function testSupplierCreationForwardsOnlyExplicitFields(): void { - $input = new ChangeSupplierInput(); + $input = new CreateSupplierInput(); + $input->clientOperationId = self::OPERATION; $input->name = 'Supplier'; $input->email = 'sales@example.com'; - $requests = $this->requests(['name' => 'Supplier', 'email' => 'sales@example.com']); + $requests = $this->requests(['name' => 'Supplier', 'email' => 'sales@example.com', 'clientOperationId' => self::OPERATION]); $bus = $this->createMock(CommandBusPort::class); - $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => self::ACTOR === $command->actorId && self::ORGANIZATION === $command->organizationId && 'create_supplier' === $command->action && null === $command->id && null === $command->expectedRevision && ['name' => 'Supplier', 'email' => 'sales@example.com'] === $command->payload))->willReturn(new ManageProcurementResult('supplier', $this->projection('supplier'))); + $bus->expects(self::once())->method('dispatch')->with(self::callback(static fn (ManageProcurementCommand $command): bool => self::ACTOR === $command->actorId && self::ORGANIZATION === $command->organizationId && 'create_supplier' === $command->action && null === $command->id && null === $command->expectedRevision && ['name' => 'Supplier', 'email' => 'sales@example.com', 'clientOperationId' => self::OPERATION] === $command->payload))->willReturn(new ManageProcurementResult('supplier', $this->projection('supplier'))); $processor = new ProcurementProcessor($bus, $this->actor(), $requests); $output = $processor->process($input, new Post(name: ProcurementOperations::CREATE_SUPPLIER), ['organizationId' => self::ORGANIZATION]); diff --git a/tests/Unit/Shared/Infrastructure/Image/ImageInputValidationAdapterTest.php b/tests/Unit/Shared/Infrastructure/Image/ImageInputValidationAdapterTest.php new file mode 100644 index 000000000..6acf388b0 --- /dev/null +++ b/tests/Unit/Shared/Infrastructure/Image/ImageInputValidationAdapterTest.php @@ -0,0 +1,84 @@ +expectException(InvalidImageInputException::class); + $this->expectExceptionMessage($message); + + new ImageInputValidationAdapter()->validate($contents); + } + + /** + * Method invalidSources + * + * Exercises byte, positive-dimension, individual-axis and combined-area guards. + * + * @access public + * + * @return iterable the rejected source cases + */ + public static function invalidSources(): iterable + { + yield 'empty' => ['', 'Image size must be between']; + yield 'too many bytes' => [str_repeat('x', ImageInputValidationPort::MAX_BYTES + 1), 'Image size must be between']; + yield 'malformed' => ['not an image', 'Invalid image content.']; + yield 'zero width' => [ImageFixtures::pngHeader(0, 1), 'Image width and height']; + yield 'zero height' => [ImageFixtures::pngHeader(1, 0), 'Image width and height']; + yield 'wide' => [ImageFixtures::pngHeader(ImageInputValidationPort::MAX_DIMENSION + 1, 1), 'Image width and height']; + yield 'tall' => [ImageFixtures::pngHeader(1, ImageInputValidationPort::MAX_DIMENSION + 1), 'Image width and height']; + yield 'too many pixels' => [ImageFixtures::pngHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + yield 'GIF first-frame pixels exceed screen budget' => [ImageFixtures::gifFirstFrameHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + yield 'GIF first-frame axis exceeds screen budget' => [ImageFixtures::gifFirstFrameHeader(4097, 1), 'Image width and height']; + yield 'GIF first frame exceeds screen' => [ImageFixtures::gifFirstFrameHeader(32, 32), 'GIF first frame is outside its logical screen.']; + yield 'GIF zero frame width' => [ImageFixtures::gifFirstFrameHeader(0, 1), 'Image width and height']; + yield 'GIF missing first descriptor' => ["GIF89a\x01\x00\x01\x00\x00\x00\x00;", 'Invalid GIF image content.']; + yield 'GIF truncated extension block' => ["GIF89a\x01\x00\x01\x00\x00\x00\x00\x21\xFE\xFFx", 'Invalid GIF image content.']; + } + + #[Test] + public function testAcceptsTheExactPixelBoundaryWithoutDecoding(): void + { + new ImageInputValidationAdapter()->validate(ImageFixtures::pngHeader(2048, 2048)); + + $this->addToAssertionCount(1); + } + + #[Test] + public function testAcceptsTheExactAxisAndByteBoundaries(): void + { + $contents = ImageFixtures::pngHeader(ImageInputValidationPort::MAX_DIMENSION, 1); + $contents .= str_repeat("\0", ImageInputValidationPort::MAX_BYTES - strlen($contents)); + + new ImageInputValidationAdapter()->validate($contents); + + $this->addToAssertionCount(1); + } + // #endregion +} diff --git a/tests/Unit/User/Infrastructure/Image/AvatarResizerTest.php b/tests/Unit/User/Infrastructure/Image/AvatarResizerTest.php index 25867dded..f7d7e60f3 100644 --- a/tests/Unit/User/Infrastructure/Image/AvatarResizerTest.php +++ b/tests/Unit/User/Infrastructure/Image/AvatarResizerTest.php @@ -4,15 +4,19 @@ namespace Tests\Unit\User\Infrastructure\Image; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shared\Application\Contract\Image\InvalidImageInputException; use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Infrastructure\Image\ImageInputValidationAdapter; +use Tests\Support\Image\ImageFixtures; use User\Infrastructure\Image\AvatarResizer; use function array_map; use function base64_decode; use function count; +use function getimagesizefromstring; use function in_array; use function sprintf; @@ -45,7 +49,7 @@ public function testResizeWritesAllVariants(): void self::isString(), ); - $resizer = new AvatarResizer($storage); + $resizer = new AvatarResizer($storage, new ImageInputValidationAdapter()); // Minimal valid 1x1 PNG (smallest valid PNG binary) $png = base64_decode( @@ -70,7 +74,7 @@ public function testDeleteRemovesExistingVariants(): void $storage->expects(self::exactly(count(AvatarResizer::SIZES))) ->method('delete'); - $resizer = new AvatarResizer($storage); + $resizer = new AvatarResizer($storage, new ImageInputValidationAdapter()); $resizer->delete('user-1'); } @@ -84,7 +88,7 @@ public function testDeleteSkipsMissingVariants(): void $storage->expects(self::never())->method('delete'); - $resizer = new AvatarResizer($storage); + $resizer = new AvatarResizer($storage, new ImageInputValidationAdapter()); $resizer->delete('user-1'); } @@ -100,7 +104,7 @@ public function testDeleteSkipsPartiallyMissingVariants(): void $storage->expects(self::exactly(2))->method('delete'); - $resizer = new AvatarResizer($storage); + $resizer = new AvatarResizer($storage, new ImageInputValidationAdapter()); $resizer->delete('user-1'); } @@ -113,5 +117,85 @@ public function testSizesConstantContainsFourEntries(): void self::assertContains(64, AvatarResizer::SIZES); self::assertContains(32, AvatarResizer::SIZES); } + + #[Test] + public function testRejectsOversizedGeometryBeforeAnyStorageMutation(): void + { + $storage = $this->createMock(FileStoragePort::class); + $storage->expects(self::never())->method('write'); + $storage->expects(self::never())->method('delete'); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage('Image exceeds the 4194304 pixel limit.'); + + new AvatarResizer($storage, new ImageInputValidationAdapter())->resize('user-1', ImageFixtures::pngHeader(2048, 2049)); + } + + #[Test] + #[DataProvider('undecodableSources')] + public function testRejectsAnUndecodableImageBeforeAnyStorageMutation(string $contents): void + { + $storage = $this->createMock(FileStoragePort::class); + $storage->expects(self::never())->method('write'); + $storage->expects(self::never())->method('delete'); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage('Unable to decode the uploaded image.'); + + new AvatarResizer($storage, new ImageInputValidationAdapter())->resize('user-1', $contents); + } + + #[Test] + #[DataProvider('acceptedSources')] + public function testAcceptedSourcesProduceStaticWebpVariantsAtEverySize(string $contents): void + { + $storage = $this->createMock(FileStoragePort::class); + $storage->expects(self::exactly(count(AvatarResizer::SIZES))) + ->method('write') + ->willReturnCallback(static function (string $path, string $contents): void { + $dimensions = getimagesizefromstring($contents); + self::assertNotFalse($dimensions); + self::assertSame('image/webp', $dimensions['mime']); + self::assertSame($dimensions[0], $dimensions[1]); + self::assertContains($dimensions[0], AvatarResizer::SIZES); + self::assertSame(sprintf('avatars/user-1/%d.webp', $dimensions[0]), $path); + self::assertStringNotContainsString('ANIM', $contents); + self::assertStringNotContainsString('ANMF', $contents); + }); + + new AvatarResizer($storage, new ImageInputValidationAdapter())->resize('user-1', $contents); + } + + /** + * Method undecodableSources + * + * Exercises sources with bounded headers that native decoding must reject. + * + * @access public + * + * @return iterable the malformed image sources + */ + public static function undecodableSources(): iterable + { + yield 'truncated PNG pixels' => [ImageFixtures::pngHeader(1, 1)]; + yield 'WebP lossy canvas mismatch' => [ImageFixtures::webpWithCanvas(false, 1)]; + yield 'WebP lossless canvas mismatch' => [ImageFixtures::webpWithCanvas(true, 1)]; + } + + /** + * Method acceptedSources + * + * Exercises first-frame GIF conversion and consistent extended WebP sources. + * + * @access public + * + * @return iterable the valid image sources + */ + public static function acceptedSources(): iterable + { + yield 'animated GIF' => [ImageFixtures::animatedGif()]; + yield 'WebP lossy matching canvas' => [ImageFixtures::webpWithCanvas(false, 32)]; + yield 'WebP lossless matching canvas' => [ImageFixtures::webpWithCanvas(true, 32)]; + } // #endregion } diff --git a/tests/Unit/User/Presentation/Api/Processor/User/UploadUserAvatarProcessorTest.php b/tests/Unit/User/Presentation/Api/Processor/User/UploadUserAvatarProcessorTest.php index fda5d4745..c3b46e963 100644 --- a/tests/Unit/User/Presentation/Api/Processor/User/UploadUserAvatarProcessorTest.php +++ b/tests/Unit/User/Presentation/Api/Processor/User/UploadUserAvatarProcessorTest.php @@ -6,12 +6,16 @@ use ApiPlatform\Metadata\Post; use DateTimeImmutable; -use PHPUnit\Framework\Attributes\{CoversClass, Test}; +use PHPUnit\Framework\Attributes\{CoversClass, DataProvider, Test}; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shared\Application\Contract\Image\InvalidImageInputException; use Shared\Application\Port\Inbound\{CommandBusPort, QueryBusPort}; +use Shared\Application\Port\Outbound\FileStoragePort; +use Shared\Infrastructure\Image\ImageInputValidationAdapter; use Symfony\Component\HttpFoundation\{File\UploadedFile, Request, RequestStack}; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Tests\Support\Image\ImageFixtures; use User\Application\Contract\User\UserView; use User\Application\UseCase\Command\User\UpdateUser\UpdateUserCommand; use User\Application\UseCase\Query\User\GetUser\{GetUserQuery, GetUserResult}; @@ -149,7 +153,7 @@ public function testProcessDispatchesCommandAndReturnsOutput(): void /** @var AvatarResizer&MockObject $resizer */ $resizer = $this->createMock(AvatarResizer::class); - $resizer->expects(self::once())->method('delete')->with('user-1'); + $resizer->expects(self::never())->method('delete'); $resizer->expects(self::once())->method('resize')->with('user-1', self::isString()); /** @var CommandBusPort&MockObject $commandBus */ @@ -210,7 +214,6 @@ public function testProcessReturnsNullWhenQueryReturnsNoUser(): void ->willReturn(new GetUserResult(user: null)); $resizer = $this->createStub(AvatarResizer::class); - $resizer->method('delete'); $resizer->method('resize'); $processor = new UploadUserAvatarProcessor( @@ -346,6 +349,57 @@ public function getMimeType(): string } } + #[Test] + #[DataProvider('rejectedImageHeaders')] + public function testRejectedImagesPreserveTheExistingAvatarAndUserState(string $contents, string $message): void + { + $tmpFile = $this->createTempFile($contents, 'image/png'); + $request = new Request(); + $request->files->set('avatar', new UploadedFile($tmpFile, 'avatar.png', 'image/png', test: true)); + $requestStack = new RequestStack(); + $requestStack->push($request); + + $storage = $this->createMock(FileStoragePort::class); + $storage->expects(self::never())->method('delete'); + $storage->expects(self::never())->method('write'); + $commandBus = $this->createMock(CommandBusPort::class); + $commandBus->expects(self::never())->method('dispatch'); + $queryBus = $this->createMock(QueryBusPort::class); + $queryBus->expects(self::never())->method('ask'); + + $processor = new UploadUserAvatarProcessor( + requestStack: $requestStack, + avatarResizer: new AvatarResizer($storage, new ImageInputValidationAdapter()), + commandBus: $commandBus, + queryBus: $queryBus, + ); + + $this->expectException(InvalidImageInputException::class); + $this->expectExceptionMessage($message); + + try { + $processor->process(null, new Post(), ['id' => 'user-1']); + } finally { + @unlink($tmpFile); + } + } + + /** + * Method rejectedImageHeaders + * + * Covers geometry rejection before decode and header-valid truncated pixels. + * + * @access public + * + * @return iterable the rejected source cases + */ + public static function rejectedImageHeaders(): iterable + { + yield 'oversized geometry' => [ImageFixtures::pngHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + yield 'malformed pixels' => [ImageFixtures::pngHeader(1, 1), 'Unable to decode the uploaded image.']; + yield 'GIF first-frame geometry' => [ImageFixtures::gifFirstFrameHeader(2048, 2049), 'Image exceeds the 4194304 pixel limit.']; + } + // #region Helpers private function makeProcessor(): UploadUserAvatarProcessor {