Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
1 change: 1 addition & 0 deletions backend/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
###> symfony/framework-bundle ###
APP_SECRET=8a3935fa9a2fc896256979f4bd2780fc
###< symfony/framework-bundle ###
SMS_PROVIDER=fake
1 change: 1 addition & 0 deletions backend/.env.test
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SMS_PROVIDER=fake
9 changes: 9 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 1 addition & 4 deletions backend/config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion backend/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)%'
Expand All @@ -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'
2 changes: 2 additions & 0 deletions backend/src/Application/SmsGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

interface SmsGateway
{
public function getProviderName(): string;

/** @return array{providerMessageId: ?string, status: string} */
public function send(string $to, string $body): array;
}
32 changes: 32 additions & 0 deletions backend/src/Controller/DebugFakeSmsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Infrastructure\Sms\FakeSmsStore;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;

final class DebugFakeSmsController
{
public function __construct(
private readonly FakeSmsStore $store,
private readonly string $appEnv,
) {
}

#[Route('/debug/fake-sms', name: 'app_debug_fake_sms', methods: ['GET'])]
public function __invoke(): JsonResponse
{
if ('prod' === $this->appEnv) {
throw new NotFoundHttpException();
}

return new JsonResponse([
'provider' => 'fake',
'messages' => $this->store->all(),
]);
}
}
39 changes: 39 additions & 0 deletions backend/src/Infrastructure/Sms/DelegatingSmsGateway.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Sms;

use App\Application\SmsGateway;
use InvalidArgumentException;

use function sprintf;

final class DelegatingSmsGateway implements SmsGateway
{
public function __construct(
private readonly string $provider,
private readonly TwilioSmsGateway $twilioSmsGateway,
private readonly FakeSmsGateway $fakeSmsGateway,
) {
}

public function getProviderName(): string
{
return $this->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)),
};
}
}
34 changes: 34 additions & 0 deletions backend/src/Infrastructure/Sms/FakeSmsGateway.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Sms;

use App\Application\SmsGateway;

use function sprintf;

use Symfony\Component\Uid\Uuid;

final class FakeSmsGateway implements SmsGateway
{
public function __construct(private readonly FakeSmsStore $store)
{
}

public function getProviderName(): string
{
return 'fake';
}

public function send(string $to, string $body): array
{
$providerMessageId = sprintf('fake-%s', Uuid::v7()->toRfc4122());
$this->store->append($providerMessageId, $to, $body);

return [
'providerMessageId' => $providerMessageId,
'status' => 'sent',
];
}
}
112 changes: 112 additions & 0 deletions backend/src/Infrastructure/Sms/FakeSmsStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace App\Infrastructure\Sms;

use const DATE_ATOM;

use DateTimeImmutable;

use function dirname;
use function explode;

use const FILE_APPEND;

use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function is_array;
use function is_dir;
use function is_string;
use function json_decode;
use function json_encode;

use const JSON_THROW_ON_ERROR;

use function mkdir;
use function rtrim;
use function sprintf;
use function trim;

final class FakeSmsStore
{
public function __construct(
private readonly string $projectDir,
private readonly string $shareDir,
) {
}

/**
* @return array<int, array{providerMessageId: string, to: string, body: string, createdAt: string}>
*/
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, '/'),
);
}
}
5 changes: 5 additions & 0 deletions backend/src/Infrastructure/Sms/TwilioSmsGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/MessageHandler/SendFallAlertMessageHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
35 changes: 35 additions & 0 deletions backend/tests/Integration/Api/DebugFakeSmsControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Tests\Integration\Api;

use App\Infrastructure\Sms\FakeSmsGateway;

use function json_decode;

use const JSON_THROW_ON_ERROR;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class DebugFakeSmsControllerTest extends WebTestCase
{
public function testFakeSmsInboxEndpointReturnsStoredMessages(): void
{
$client = static::createClient();
$gateway = self::getContainer()->get(FakeSmsGateway::class);
$gateway->send('+33612345678', 'Hello fake SMS');

$client->request('GET', '/debug/fake-sms');

self::assertResponseIsSuccessful();

/** @var array{provider?: string, messages?: list<array{to?: string, body?: string}>} $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);
}
}
Loading
Loading