diff --git a/backend/.env b/backend/.env index f2aa2ce..6a92cd9 100644 --- a/backend/.env +++ b/backend/.env @@ -51,6 +51,7 @@ REDIS_URL=redis://127.0.0.1:6380 CONTACT_ENCRYPTION_KEY=change-me-encryption CONTACT_HASH_KEY=change-me-hash APP_DEFAULT_COUNTRY_CODE=+33 +SMS_PROVIDER=fake SMS_PROVIDER_TWILIO_ACCOUNT_SID= SMS_PROVIDER_TWILIO_AUTH_TOKEN= SMS_PROVIDER_TWILIO_FROM= diff --git a/backend/.env.dev b/backend/.env.dev index 9058a1c..03466c8 100644 --- a/backend/.env.dev +++ b/backend/.env.dev @@ -2,3 +2,4 @@ ###> symfony/framework-bundle ### APP_SECRET=8a3935fa9a2fc896256979f4bd2780fc ###< symfony/framework-bundle ### +SMS_PROVIDER=fake diff --git a/backend/.env.test b/backend/.env.test index 64bd111..c19339b 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -1,3 +1,4 @@ # define your env variables for the test env here KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' +SMS_PROVIDER=fake diff --git a/backend/README.md b/backend/README.md index 9067315..75ae278 100644 --- a/backend/README.md +++ b/backend/README.md @@ -231,6 +231,15 @@ Key local URLs: - API docs: `http://localhost:8002/docs` - Device/API endpoints: `http://localhost:8002/api/v1/...` +- Fake SMS inbox in `dev`/`test`: `http://localhost:8002/debug/fake-sms` + +### Fake SMS in development + +- `SMS_PROVIDER=fake` is the default in `dev` and `test`. +- The fake gateway writes messages to `backend/var/share/fake_sms_inbox.jsonl`. +- You can inspect recent fake sends through `GET /debug/fake-sms`. +- Production must override `SMS_PROVIDER=twilio` and provide real Twilio + credentials. ## Operational Notes diff --git a/backend/config/packages/doctrine.yaml b/backend/config/packages/doctrine.yaml index bec08a2..b96c178 100644 --- a/backend/config/packages/doctrine.yaml +++ b/backend/config/packages/doctrine.yaml @@ -6,8 +6,7 @@ doctrine: profiling_collect_backtrace: '%kernel.debug%' use_savepoints: true orm: - auto_generate_proxy_classes: true - enable_lazy_ghost_objects: true + enable_native_lazy_objects: true report_fields_where_declared: true validate_xml_mapping: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware @@ -33,8 +32,6 @@ when@test: when@prod: doctrine: orm: - auto_generate_proxy_classes: false - proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' query_cache_driver: type: pool pool: doctrine.system_cache_pool diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 0ee0c47..c88daef 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -7,9 +7,11 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + app.share_dir: '%env(string:APP_SHARE_DIR)%' app.default_country_code: '%env(string:APP_DEFAULT_COUNTRY_CODE)%' app.contact_encryption_key: '%env(string:CONTACT_ENCRYPTION_KEY)%' app.contact_hash_key: '%env(string:CONTACT_HASH_KEY)%' + app.sms_provider: '%env(string:SMS_PROVIDER)%' app.twilio.account_sid: '%env(string:SMS_PROVIDER_TWILIO_ACCOUNT_SID)%' app.twilio.auth_token: '%env(string:SMS_PROVIDER_TWILIO_AUTH_TOKEN)%' app.twilio.from: '%env(string:SMS_PROVIDER_TWILIO_FROM)%' @@ -34,10 +36,23 @@ services: $encryptionSecret: '%app.contact_encryption_key%' $hashSecret: '%app.contact_hash_key%' + App\Infrastructure\Sms\FakeSmsStore: + arguments: + $projectDir: '%kernel.project_dir%' + $shareDir: '%app.share_dir%' + App\Infrastructure\Sms\TwilioSmsGateway: arguments: $accountSid: '%app.twilio.account_sid%' $authToken: '%app.twilio.auth_token%' $from: '%app.twilio.from%' - App\Application\SmsGateway: '@App\Infrastructure\Sms\TwilioSmsGateway' + App\Infrastructure\Sms\DelegatingSmsGateway: + arguments: + $provider: '%app.sms_provider%' + + App\Controller\DebugFakeSmsController: + arguments: + $appEnv: '%kernel.environment%' + + App\Application\SmsGateway: '@App\Infrastructure\Sms\DelegatingSmsGateway' diff --git a/backend/src/Application/SmsGateway.php b/backend/src/Application/SmsGateway.php index a2ff05a..6f30641 100644 --- a/backend/src/Application/SmsGateway.php +++ b/backend/src/Application/SmsGateway.php @@ -6,6 +6,8 @@ interface SmsGateway { + public function getProviderName(): string; + /** @return array{providerMessageId: ?string, status: string} */ public function send(string $to, string $body): array; } diff --git a/backend/src/Controller/DebugFakeSmsController.php b/backend/src/Controller/DebugFakeSmsController.php new file mode 100644 index 0000000..36b301e --- /dev/null +++ b/backend/src/Controller/DebugFakeSmsController.php @@ -0,0 +1,32 @@ +appEnv) { + throw new NotFoundHttpException(); + } + + return new JsonResponse([ + 'provider' => 'fake', + 'messages' => $this->store->all(), + ]); + } +} diff --git a/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php b/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php new file mode 100644 index 0000000..dae8b96 --- /dev/null +++ b/backend/src/Infrastructure/Sms/DelegatingSmsGateway.php @@ -0,0 +1,39 @@ +inner()->getProviderName(); + } + + public function send(string $to, string $body): array + { + return $this->inner()->send($to, $body); + } + + private function inner(): SmsGateway + { + return match ($this->provider) { + 'twilio' => $this->twilioSmsGateway, + 'fake' => $this->fakeSmsGateway, + default => throw new InvalidArgumentException(sprintf('Unsupported SMS provider "%s".', $this->provider)), + }; + } +} diff --git a/backend/src/Infrastructure/Sms/FakeSmsGateway.php b/backend/src/Infrastructure/Sms/FakeSmsGateway.php new file mode 100644 index 0000000..c5bcea4 --- /dev/null +++ b/backend/src/Infrastructure/Sms/FakeSmsGateway.php @@ -0,0 +1,34 @@ +toRfc4122()); + $this->store->append($providerMessageId, $to, $body); + + return [ + 'providerMessageId' => $providerMessageId, + 'status' => 'sent', + ]; + } +} diff --git a/backend/src/Infrastructure/Sms/FakeSmsStore.php b/backend/src/Infrastructure/Sms/FakeSmsStore.php new file mode 100644 index 0000000..5c78bcb --- /dev/null +++ b/backend/src/Infrastructure/Sms/FakeSmsStore.php @@ -0,0 +1,112 @@ + + */ + public function all(): array + { + $path = $this->path(); + + if (!file_exists($path)) { + return []; + } + + $entries = []; + $contents = file_get_contents($path); + + if (false === $contents || '' === $contents) { + return []; + } + + foreach (explode("\n", trim($contents)) as $line) { + if ('' === $line) { + continue; + } + + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + + if ( + is_array($decoded) + && isset($decoded['providerMessageId'], $decoded['to'], $decoded['body'], $decoded['createdAt']) + && is_string($decoded['providerMessageId']) + && is_string($decoded['to']) + && is_string($decoded['body']) + && is_string($decoded['createdAt']) + ) { + /* @var array{providerMessageId: string, to: string, body: string, createdAt: string} $decoded */ + $entries[] = $decoded; + } + } + + return $entries; + } + + public function append(string $providerMessageId, string $to, string $body): void + { + $path = $this->path(); + $directory = dirname($path); + + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + } + + $entry = [ + 'providerMessageId' => $providerMessageId, + 'to' => $to, + 'body' => $body, + 'createdAt' => (new DateTimeImmutable())->format(DATE_ATOM), + ]; + + file_put_contents( + $path, + sprintf("%s\n", json_encode($entry, JSON_THROW_ON_ERROR)), + FILE_APPEND, + ); + } + + private function path(): string + { + return sprintf( + '%s/%s/fake_sms_inbox.jsonl', + $this->projectDir, + rtrim($this->shareDir, '/'), + ); + } +} diff --git a/backend/src/Infrastructure/Sms/TwilioSmsGateway.php b/backend/src/Infrastructure/Sms/TwilioSmsGateway.php index e23151d..1ac5765 100644 --- a/backend/src/Infrastructure/Sms/TwilioSmsGateway.php +++ b/backend/src/Infrastructure/Sms/TwilioSmsGateway.php @@ -17,6 +17,11 @@ public function __construct( ) { } + public function getProviderName(): string + { + return 'twilio'; + } + public function send(string $to, string $body): array { if ('' === $this->accountSid || '' === $this->authToken || '' === $this->from) { diff --git a/backend/src/MessageHandler/SendFallAlertMessageHandler.php b/backend/src/MessageHandler/SendFallAlertMessageHandler.php index dd55156..347bbdd 100644 --- a/backend/src/MessageHandler/SendFallAlertMessageHandler.php +++ b/backend/src/MessageHandler/SendFallAlertMessageHandler.php @@ -51,8 +51,9 @@ public function __invoke(SendFallAlertMessage $message): void } $sentCount = 0; + $provider = $this->smsGateway->getProviderName(); foreach ($contacts as $contact) { - $attempt = new SmsAttempt($alert, $contact, 'twilio'); + $attempt = new SmsAttempt($alert, $contact, $provider); $alert->addSmsAttempt($attempt); $this->entityManager->persist($attempt); diff --git a/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php b/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php new file mode 100644 index 0000000..3231254 --- /dev/null +++ b/backend/tests/Integration/Api/DebugFakeSmsControllerTest.php @@ -0,0 +1,35 @@ +get(FakeSmsGateway::class); + $gateway->send('+33612345678', 'Hello fake SMS'); + + $client->request('GET', '/debug/fake-sms'); + + self::assertResponseIsSuccessful(); + + /** @var array{provider?: string, messages?: list} $payload */ + $payload = json_decode($client->getResponse()->getContent() ?: '', true, 512, JSON_THROW_ON_ERROR); + + self::assertSame('fake', $payload['provider'] ?? null); + self::assertCount(1, $payload['messages'] ?? []); + self::assertSame('+33612345678', $payload['messages'][0]['to'] ?? null); + self::assertSame('Hello fake SMS', $payload['messages'][0]['body'] ?? null); + } +} diff --git a/backend/tests/Unit/Infrastructure/FakeSmsGatewayTest.php b/backend/tests/Unit/Infrastructure/FakeSmsGatewayTest.php new file mode 100644 index 0000000..c3652b8 --- /dev/null +++ b/backend/tests/Unit/Infrastructure/FakeSmsGatewayTest.php @@ -0,0 +1,35 @@ +send('+33612345678', 'Test message'); + + self::assertSame('fake', $gateway->getProviderName()); + self::assertSame('sent', $result['status']); + self::assertStringStartsWith('fake-', (string) $result['providerMessageId']); + + $entries = $store->all(); + + self::assertCount(1, $entries); + self::assertSame('+33612345678', $entries[0]['to']); + self::assertSame('Test message', $entries[0]['body']); + self::assertSame($result['providerMessageId'], $entries[0]['providerMessageId']); + } +}