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
13 changes: 12 additions & 1 deletion GSM-module/GSM-fastapi/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1

FROM python:3.13-slim
FROM python:3.13-slim AS base

RUN pip install --no-cache-dir uv

Expand All @@ -12,6 +12,17 @@ ENV PATH="/opt/venv/bin:$PATH"

COPY . .

FROM base AS emulator

EXPOSE 8001 8002

CMD ["python", "main.py"]

FROM base AS production

COPY api.py app_version.py config.py database.py main.py protocol.py serial_worker.py sms_handler.py ./
COPY models ./models

EXPOSE 8001

CMD ["python", "main.py"]
362 changes: 362 additions & 0 deletions GSM-module/GSM-fastapi/mock_modem.py

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions GSM-module/GSM-fastapi/run-with-mock-modem.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/sh
set -eu

port_file="$(mktemp)"
cleanup() {
if [ -n "${gateway_pid:-}" ]; then
kill "$gateway_pid" 2>/dev/null || true
fi
if [ -n "${modem_pid:-}" ]; then
kill "$modem_pid" 2>/dev/null || true
fi
rm -f "$port_file"
}
trap cleanup EXIT INT TERM

python -u mock_modem.py --port-file "$port_file" \
--web-host "${VIRTUAL_PHONE_HOST:-127.0.0.1}" \
--web-port "${VIRTUAL_PHONE_PORT:-8002}" &
modem_pid=$!

while [ ! -s "$port_file" ]; do
if ! kill -0 "$modem_pid" 2>/dev/null; then
wait "$modem_pid"
fi
sleep 0.1
done

export SERIAL_PORT="$(cat "$port_file")"
python main.py &
gateway_pid=$!
wait "$gateway_pid"
188 changes: 188 additions & 0 deletions GSM-module/GSM-fastapi/tests/test_mock_modem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import json
import os
import re
import select
import signal
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen

import pytest
import serial

from mock_modem import (
INBOUND_BODY_MAX_BYTES,
VirtualModem,
normalize_body,
parse_send_sms,
start_http_server,
valid_phone_number,
)


requires_pty = pytest.mark.skipif(
os.name != "posix", reason="PTY modem integration tests require POSIX"
)

PROJECT_ROOT = Path(__file__).resolve().parents[1]


class OutputReader:
def __init__(self, stream):
self.stream = stream
self.buffer = b""

def readline(self, timeout: float = 2.0) -> str:
deadline = time.monotonic() + timeout
while b"\n" not in self.buffer:
remaining = deadline - time.monotonic()
assert remaining > 0, "timed out waiting for emulator output"
ready, _, _ = select.select([self.stream], [], [], remaining)
assert ready, "timed out waiting for emulator output"
self.buffer += os.read(self.stream.fileno(), 4096)
line, self.buffer = self.buffer.split(b"\n", 1)
return line.decode("utf-8")


class ModemProcess:
def __init__(self, port_file=None):
self.port_file = port_file

def __enter__(self):
command = [sys.executable, "-u", "mock_modem.py"]
if self.port_file:
command.extend(["--port-file", str(self.port_file)])
self.process = subprocess.Popen(
command,
cwd=PROJECT_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.output = OutputReader(self.process.stdout)
first_line = self.output.readline()
self.path = re.fullmatch(r"Virtual modem port: (/dev/pts/\d+)", first_line).group(1)
assert self.output.readline() == (
f"Run the gateway with: SERIAL_PORT={self.path} python main.py"
)
return self

def readline(self, timeout: float = 2.0) -> str:
return self.output.readline(timeout)

def __exit__(self, *_):
self.process.send_signal(signal.SIGINT)
self.process.wait(timeout=2)
assert self.process.returncode == 0
self.process.stdout.close()
self.process.stderr.close()


def _open_modem(path: str) -> serial.Serial:
port = serial.Serial(path, 9600, timeout=1)
assert port.readline() == b"GSM_READY\n"
assert port.readline() == b"NETWORK_OK\n"
return port


def test_parse_send_sms_preserves_pipes():
assert parse_send_sms("SEND_SMS|+639171234567|one|two|three") == (
"+639171234567", "one|two|three"
)


def test_phone_validation_and_inbound_normalization_match_firmware_frames():
assert valid_phone_number("+639171234567")
assert not valid_phone_number("09171234567")
assert normalize_body("one|two\nthree", inbound=True) == "one/two three"
assert normalize_body("x" * (INBOUND_BODY_MAX_BYTES + 1), inbound=True) is None


def test_virtual_modem_stores_successful_messages_and_reset_clears_them():
modem = VirtualModem()
assert modem.receive_outbound("+639171234567", "hello") == (True, "")
assert modem.messages("+639171234567")[0]["direction"] == "received"
modem.reset()
assert modem.messages("+639171234567") == []


def test_virtual_modem_state_transitions_emit_gateway_events():
modem = VirtualModem()
modem.attach(99)
_, error, frames = modem.update({"network_connected": False})
assert error is None
assert frames == [b"NETWORK_LOST\n"]
_, error, frames = modem.update({"network_connected": True})
assert error is None
assert frames == [b"GSM_READY\n", b"NETWORK_OK\n"]
_, error, frames = modem.update({"sim_present": False})
assert error is None
assert frames == [b"SIM_MISSING\n"]


def test_virtual_phone_http_reports_validation_errors_and_modem_state():
modem = VirtualModem()
server = start_http_server(modem, "127.0.0.1", 0)
base_url = f"http://127.0.0.1:{server.server_port}"
try:
with urlopen(f"{base_url}/api/modem") as response:
assert json.load(response)["gsm_ready"] is False
request = Request(
f"{base_url}/api/messages",
data=b'{"phone_number":"not-a-number","body":"hi"}',
method="POST",
headers={"Content-Type": "application/json"},
)
with pytest.raises(HTTPError) as error:
urlopen(request)
assert error.value.code == 400
finally:
server.shutdown()
server.server_close()


@pytest.mark.parametrize("line", ["OTHER|+63|body", "SEND_SMS|+63", "SEND_SMS||body"])
def test_parse_send_sms_rejects_malformed_commands(line):
assert parse_send_sms(line) is None


@requires_pty
def test_emulator_writes_port_file_before_printing_path(tmp_path):
port_file = tmp_path / "modem-port"
with ModemProcess(port_file) as modem:
assert port_file.read_text() == modem.path


@requires_pty
def test_emulator_confirms_fragmented_and_batched_commands():
with ModemProcess() as modem:
with _open_modem(modem.path) as port:
port.write(b"SEND_SMS|+639171234567|fragment")
port.write(b"ed body\nSEND_SMS|+639188888888|second|body\n")
port.flush()

assert port.readline() == b"SMS_SENT|+639171234567\n"
assert port.readline() == b"SMS_SENT|+639188888888\n"
assert modem.readline() == "SMS request to +639171234567: fragmented body"
assert modem.readline() == "SMS request to +639188888888: second|body"


@requires_pty
def test_emulator_reannounces_after_reconnect_and_ignores_malformed_input():
with ModemProcess() as modem:
with _open_modem(modem.path) as port:
port.write(b"NOT_A_COMMAND\n")
port.flush()
assert modem.readline() == "Ignored modem command: 'NOT_A_COMMAND'"
assert port.read(1) == b""

port.write(b"SEND_SMS|+639171234567|still works\n")
port.flush()
assert port.readline() == b"SMS_SENT|+639171234567\n"
assert modem.readline() == "SMS request to +639171234567: still works"

time.sleep(0.25)
with _open_modem(modem.path) as port:
assert port.read(1) == b""
15 changes: 15 additions & 0 deletions docker-compose.gsm-emulator.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Opt-in local development overlay. The emulator and gateway must run in the
# same container because a PTY path belongs to one Linux device namespace.
#
# Usage: docker/up.sh -f docker-compose.yml -f docker-compose.gsm-emulator.yml up --build -d
services:
gsm-fastapi:
build:
context: ./GSM-module/GSM-fastapi
target: emulator
command: ["./run-with-mock-modem.sh"]
environment:
VIRTUAL_PHONE_HOST: 0.0.0.0
VIRTUAL_PHONE_PORT: 8002
ports:
- "127.0.0.1:${VIRTUAL_PHONE_PORT:-8002}:8002"
7 changes: 5 additions & 2 deletions docker-compose.gsm-hardware.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Opt-in overlay: passes the GSM modem through to gsm-fastapi. Not
# auto-loaded (unlike docker-compose.override.yml) — only merge this in on
# a machine that actually has the modem attached at /dev/ttyACM0, otherwise
# a machine that actually has the modem attached, otherwise
# `docker compose up` aborts and leaves nginx/admin stuck in "Created".
#
# Usage: docker/up.sh -f docker-compose.yml -f docker-compose.gsm-hardware.yml up -d
# docker/up.sh loads GSM-module/GSM-fastapi/.env for SERIAL_PORT substitution.
services:
gsm-fastapi:
environment:
SERIAL_PORT: ${SERIAL_PORT:-/dev/ttyACM0}
devices:
- "/dev/ttyACM0:/dev/ttyACM0"
- "${SERIAL_PORT:-/dev/ttyACM0}:${SERIAL_PORT:-/dev/ttyACM0}"
24 changes: 23 additions & 1 deletion docker/up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ set -eu
# case unless you export CERT_SAN in the shell first.
#
# Passes --env-file server/.env explicitly: docker-compose.yml's
# ${MYSQL_*} substitutions are read from this file. Passing --env-file at
# ${MYSQL_*} substitutions are read from this file. When the optional GSM
# hardware overlay is selected, its SERIAL_PORT substitution is read from the
# gateway's .env too, so the device mapping matches the serial worker. Passing --env-file at
# all disables Compose's default auto-load of a root-level .env, so we
# also pass repo-root .env (port overrides, see .env.example) when present
# — --env-file can be repeated, later ones win on overlapping keys, and
Expand Down Expand Up @@ -45,4 +47,24 @@ if [ -f .env ]; then
ENV_FILE_ARGS="--env-file .env $ENV_FILE_ARGS"
fi

case " $* " in
*" docker-compose.gsm-hardware.yml "*)
if [ ! -f GSM-module/GSM-fastapi/.env ]; then
echo "docker/up.sh: GSM-module/GSM-fastapi/.env is required for the hardware overlay" >&2
exit 1
fi
serial_port="$(sed -n 's/^[[:space:]]*SERIAL_PORT[[:space:]]*=[[:space:]]*//p' GSM-module/GSM-fastapi/.env | tail -n 1)"
serial_port="${serial_port#\"}"
serial_port="${serial_port#\'}"
case "$serial_port" in
/dev/pts/*)
echo "docker/up.sh: SERIAL_PORT=$serial_port is a host PTY and cannot be passed through with the hardware overlay" >&2
echo "docker/up.sh: use docker-compose.gsm-emulator.yml for PTY testing, or set SERIAL_PORT to a host /dev/ttyACM* or /dev/ttyUSB* device" >&2
exit 1
;;
esac
ENV_FILE_ARGS="$ENV_FILE_ARGS --env-file GSM-module/GSM-fastapi/.env"
;;
esac

exec docker compose $ENV_FILE_ARGS "$@"
33 changes: 32 additions & 1 deletion docs/features/sms-gateway/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pnpm run testAll
| `tests/test_database_reconciliation.py` | Idempotent startup recovery of orphaned pending log rows |
| `tests/test_incoming_sms.py` | Sender rejection reason codes and inbound log status updates |
| `tests/test_lifespan.py` | Reconciliation ordering before serial worker startup |
| `tests/test_mock_modem.py` | Virtual-phone validation, firmware-compatible normalization, modem state transitions, HTTP responses, PTY framing, reconnects, and subprocess cleanup |
| `server/app/tests/test_gsm_proxy.py` | Main-server shared-secret header, status preservation, and timeout headroom for chat, verification, resend, and first-contact requests |
| `mobile-app/sapot-mobile-app/features/shared/core/errors/__tests__/gsm-error.test.ts` | Typed `QUEUE_FULL` parsing and user-visible error messages |
| `mobile-app/sapot-mobile-app/features/chat/components/__tests__/message-list.test.tsx` | Manual resend rejection and `not_sent` restoration |
Expand Down Expand Up @@ -83,7 +84,37 @@ The saturation test starts 21 blocking send requests, representing 20 waiting re

This covers the thread-pool exhaustion described by issue #252. A test with only a rejecting fake would verify the response shape but would not prove that the rejection handler can still obtain a worker thread.

## Manual modem smoke test
## Software-only PTY smoke test

Use this Linux host workflow to validate the real `SerialWorker` and outbound FastAPI path without
an Arduino or carrier account. It still needs development `DB_PATH` and `GSM_SECRET` values because
the emulator replaces only the serial device.

1. In one terminal, run `python mock_modem.py` from `GSM-module/GSM-fastapi/` and copy its printed `/dev/pts/<n>` path. The virtual phone is available at `http://127.0.0.1:8002`.
2. In another terminal, start the gateway with `SERIAL_PORT=/dev/pts/<n> python main.py`.
3. Confirm `curl http://127.0.0.1:8001/health` reports `connected: true` and `gsm_ready: true`.
4. Send an authenticated request:

```bash
curl -X POST http://127.0.0.1:8001/sms/send \
-H 'Content-Type: application/json' \
-H 'X-GSM-Secret: <value from GSM_SECRET>' \
-d '{"number":"+639171234567","body":"SAPOT PTY smoke test"}'
```

5. Confirm the API reports success and the selected virtual-phone inbox shows the message from SAPOT Gateway.
6. Reply from that inbox and confirm the gateway processes it through the normal inbound session and callback path.
7. Set the virtual-phone network or SIM control to unavailable, confirm the gateway health degrades, then restore it and confirm it becomes ready again.
8. Restart only the gateway, using the same PTY path, and confirm it becomes ready again.

The emulator can also return `NO_PROMPT` or withhold a confirmation (`TIMEOUT`) from its browser controls.
It cannot validate USB access, real SIM state, signal, carrier acceptance, or physical-phone delivery.

For Compose-based testing, start the stack with
`docker-compose.gsm-emulator.yml`. The overlay runs the emulator inside the gateway container because
a host-created PTY is not visible to that container.

## Real-modem smoke test

Run this only on a host with the configured Arduino and SIM:

Expand Down
Loading
Loading