Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""PostgreSQL-backed integration tests."""

View File

@@ -0,0 +1,174 @@
"""Isolated PostgreSQL and Flask fixtures for integration tests."""
import os
from pathlib import Path
import re
import uuid
import psycopg2
from psycopg2 import sql
import pytest
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parents[2]
load_dotenv(PROJECT_ROOT / ".env", override=False)
TEST_DATABASE_PREFIX = "llm_bot_integration_"
SERVICE_KEY = "integration-service-key-0123456789abcdef"
ALLOWED_DISCORD_ID = "integration-discord-user"
def _databaseConfig(databaseName=None):
return {
"host": os.environ.get("DB_HOST", "localhost"),
"port": int(os.environ.get("DB_PORT", 5432)),
"dbname": databaseName or os.environ.get("DB_NAME", "app"),
"user": os.environ.get("DB_USER", "app"),
"password": os.environ.get("DB_PASS", ""),
"connect_timeout": 3,
}
def _databaseUnavailable(message):
if os.environ.get("CI", "").lower() == "true":
pytest.fail(message)
pytest.skip(message)
@pytest.fixture(scope="session")
def postgresServer():
"""Skip once locally, but fail CI when its required PostgreSQL is absent."""
sourceDatabase = os.environ.get("DB_NAME", "app")
try:
connection = psycopg2.connect(**_databaseConfig(sourceDatabase))
except psycopg2.OperationalError as error:
_databaseUnavailable(
f"PostgreSQL integration database is unavailable: {error}"
)
with connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT rolsuper OR rolcreatedb AS can_create_database
FROM pg_roles
WHERE rolname = CURRENT_USER
"""
)
canCreateDatabase = cursor.fetchone()[0]
connection.close()
if not canCreateDatabase:
_databaseUnavailable(
"PostgreSQL integration user requires CREATEDB for isolated tests"
)
return sourceDatabase
@pytest.fixture
def isolatedDatabase(monkeypatch, postgresServer):
"""Create a disposable database without altering the configured database."""
databaseName = f"{TEST_DATABASE_PREFIX}{uuid.uuid4().hex}"
assert re.fullmatch(r"llm_bot_integration_[0-9a-f]{32}", databaseName)
adminConnection = psycopg2.connect(**_databaseConfig(postgresServer))
adminConnection.autocommit = True
try:
with adminConnection.cursor() as cursor:
cursor.execute(
sql.SQL("CREATE DATABASE {}").format(sql.Identifier(databaseName))
)
except psycopg2.Error as error:
adminConnection.close()
_databaseUnavailable(
f"PostgreSQL user cannot create an isolated database: {error}"
)
monkeypatch.setenv("DB_NAME", databaseName)
try:
yield databaseName
finally:
# Every production helper closes its connection, but terminate any failed-test
# leftovers before dropping only the uniquely named test database.
with adminConnection.cursor() as cursor:
cursor.execute(
"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = %s AND pid != pg_backend_pid()
""",
(databaseName,),
)
cursor.execute(
sql.SQL("DROP DATABASE {}").format(sql.Identifier(databaseName))
)
adminConnection.close()
@pytest.fixture
def migratedDatabase(isolatedDatabase):
from core.migrations import upgrade
applied = upgrade()
assert applied
return isolatedDatabase
@pytest.fixture
def app(migratedDatabase, monkeypatch):
monkeypatch.setenv("JWT_SECRET", "integration-only-jwt-secret")
monkeypatch.setenv("BOT_API_KEY", SERVICE_KEY)
monkeypatch.setenv(
"BOT_API_KEY_SCOPES",
"discord:session,outbox:claim,outbox:deliver",
)
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "allowlist")
monkeypatch.setenv("DISCORD_ALLOWLIST", ALLOWED_DISCORD_ID)
monkeypatch.setenv("DEFAULT_TIMEZONE", "UTC")
from api.main import createApp
flaskApp = createApp()
flaskApp.config.update(TESTING=True)
return flaskApp
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def createAuthenticatedUser(client):
created = 0
def create(username=None, password="correct-horse-battery-staple"):
nonlocal created
created += 1
username = username or f"integration-user-{created}-{uuid.uuid4().hex}"
registration = client.post(
"/api/register",
json={"username": username, "password": password, "timezone": "UTC"},
)
assert registration.status_code == 201, registration.get_json()
login = client.post(
"/api/login", json={"username": username, "password": password}
)
assert login.status_code == 200, login.get_json()
token = login.get_json()["token"]
uuidResponse = client.get(
f"/api/getUserUUID/{username}",
headers={"Authorization": f"Bearer {token}"},
)
assert uuidResponse.status_code == 200, uuidResponse.get_json()
return {
"username": username,
"password": password,
"token": token,
"user_uuid": uuidResponse.get_json(),
}
return create

View File

@@ -0,0 +1,264 @@
"""Flask API integration tests for authentication and route boundaries."""
import uuid
SERVICE_KEY = "integration-service-key-0123456789abcdef"
ALLOWED_DISCORD_ID = "integration-discord-user"
def _bearer(token):
return {"Authorization": f"Bearer {token}"}
def test_health_and_error_routes(client):
assert client.get("/health/live").get_json() == {"status": "ok"}
assert client.get("/health/ready").get_json() == {"status": "ready"}
assert client.get("/health").get_json() == {"status": "ready"}
missing = client.get("/api/route-that-does-not-exist")
assert missing.status_code == 404
assert missing.get_json() == {"error": "not found"}
wrongMethod = client.get("/api/register")
assert wrongMethod.status_code == 405
assert wrongMethod.get_json() == {"error": "method not allowed"}
invalidJson = client.post(
"/api/register", data="[]", content_type="application/json"
)
assert invalidJson.status_code == 400
assert invalidJson.get_json() == {"error": "JSON object required"}
def test_password_registration_keeps_values_parameterized_and_protected(client):
from core import postgres
injectedUsername = "alice'; DROP TABLE users; --"
injectedUUID = str(uuid.uuid4())
response = client.post(
"/api/register",
json={
"username": injectedUsername,
"password": "long-enough-password",
"timezone": "America/Chicago",
"id": injectedUUID,
"password_hashed": "attacker-controlled",
"created_at": "1900-01-01T00:00:00Z",
"unexpected_admin": True,
},
)
assert response.status_code == 201, response.get_json()
assert postgres.table_exists("users")
record = postgres.select_one("users", {"username": injectedUsername})
assert record is not None
assert str(record["id"]) != injectedUUID
assert bytes(record["password_hashed"]) != b"attacker-controlled"
assert record["timezone"] == "America/Chicago"
assert record["created_at"].year > 1900
login = client.post(
"/api/login",
json={"username": injectedUsername, "password": "long-enough-password"},
)
assert login.status_code == 200
assert login.get_json()["token"]
def test_jwt_user_key_creation_authentication_and_revocation(
client, createAuthenticatedUser
):
from core import postgres
user = createAuthenticatedUser("api-key-owner")
jwtHeaders = _bearer(user["token"])
profile = client.get(f"/api/user/{user['user_uuid']}", headers=jwtHeaders)
assert profile.status_code == 200
assert profile.get_json()["username"] == user["username"]
assert "password_hashed" not in profile.get_json()
update = client.put(
f"/api/user/{user['user_uuid']}",
headers=jwtHeaders,
json={"timezone": "America/Chicago", "username": "cannot-change"},
)
assert update.status_code == 200
assert client.get(
"/api/user/me/timezone", headers=jwtHeaders
).get_json() == {"timezone": "America/Chicago"}
created = client.post(
"/api/keys", headers=jwtHeaders, json={"name": "integration key"}
)
assert created.status_code == 201, created.get_json()
keyRecord = created.get_json()
secret = keyRecord["key"]
assert secret.startswith("llmbot_user_")
assert "key_hash" not in keyRecord
storedKey = postgres.select_one("api_keys", {"id": keyRecord["id"]})
assert storedKey["key_hash"] != secret
assert secret not in {str(value) for value in storedKey.values()}
listed = client.get("/api/keys", headers=jwtHeaders)
assert listed.status_code == 200
listedKey = listed.get_json()["keys"][0]
assert listedKey["id"] == keyRecord["id"]
assert "key" not in listedKey
assert "key_hash" not in listedKey
apiKeyHeaders = _bearer(secret)
timezone = client.get("/api/user/me/timezone", headers=apiKeyHeaders)
assert timezone.status_code == 200
assert timezone.get_json() == {"timezone": "America/Chicago"}
assert postgres.select_one("api_keys", {"id": keyRecord["id"]})[
"last_used_at"
] is not None
cannotManageKeys = client.post(
"/api/keys", headers=apiKeyHeaders, json={"name": "nested key"}
)
assert cannotManageKeys.status_code == 401
revoked = client.delete(f"/api/keys/{keyRecord['id']}", headers=jwtHeaders)
assert revoked.status_code == 200
assert postgres.select_one("api_keys", {"id": keyRecord["id"]})[
"revoked_at"
] is not None
assert client.get("/api/user/me/timezone", headers=apiKeyHeaders).status_code == 401
def test_discord_allowlist_and_service_key_boundaries(client, createAuthenticatedUser):
serviceHeaders = _bearer(SERVICE_KEY)
assert client.post(
"/api/auth/discord/session",
json={"discord_id": ALLOWED_DISCORD_ID},
).status_code == 401
assert client.post(
"/api/auth/discord/session",
headers=_bearer("not-the-configured-service-key-0123456789"),
json={"discord_id": ALLOWED_DISCORD_ID},
).status_code == 401
denied = client.post(
"/api/auth/discord/session",
headers=serviceHeaders,
json={"discord_id": "not-allowlisted", "display_name": "No Access"},
)
assert denied.status_code == 403
accepted = client.post(
"/api/auth/discord/session",
headers=serviceHeaders,
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "First Name"},
)
assert accepted.status_code == 200, accepted.get_json()
discordSession = accepted.get_json()
assert discordSession["timezone"] == "UTC"
assert discordSession["token"]
repeated = client.post(
"/api/auth/discord/session",
headers=serviceHeaders,
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "New Name"},
)
assert repeated.status_code == 200
assert repeated.get_json()["user_uuid"] == discordSession["user_uuid"]
profile = client.get(
f"/api/user/{discordSession['user_uuid']}",
headers=_bearer(discordSession["token"]),
)
assert profile.status_code == 200
assert profile.get_json()["username"] is None
passwordUser = createAuthenticatedUser("service-boundary-user")
assert client.post(
"/api/auth/discord/session",
headers=_bearer(passwordUser["token"]),
json={"discord_id": ALLOWED_DISCORD_ID},
).status_code == 401
assert client.get(
"/api/user/me/timezone", headers=serviceHeaders
).status_code == 401
def test_service_outbox_claim_retry_and_delivery_routes(client):
from core import outbox, postgres
serviceHeaders = _bearer(SERVICE_KEY)
session = client.post(
"/api/auth/discord/session",
headers=serviceHeaders,
json={"discord_id": ALLOWED_DISCORD_ID, "display_name": "Recipient"},
).get_json()
queued = outbox.enqueue_message(
session["user_uuid"],
"discord_dm",
{"content": "integration delivery"},
idempotency_key=f"api-outbox-{uuid.uuid4()}",
)
claimed = client.post(
"/api/internal/outbox/claim",
headers=serviceHeaders,
json={"worker_id": "api-worker-one", "limit": 1},
)
assert claimed.status_code == 200
message = claimed.get_json()["messages"][0]
assert message["id"] == str(queued["id"])
assert message["provider_user_id"] == ALLOWED_DISCORD_ID
assert message["content"] == "integration delivery"
assert message["worker_id"] == "api-worker-one"
missingWorker = client.post(
f"/api/internal/outbox/{message['id']}/result",
headers=serviceHeaders,
json={"status": "sent"},
)
assert missingWorker.status_code == 409
wrongWorker = client.post(
f"/api/internal/outbox/{message['id']}/result",
headers=serviceHeaders,
json={"status": "sent", "worker_id": "not-the-lease-owner"},
)
assert wrongWorker.status_code == 409
stillLeased = outbox.get_message(message["id"])
assert stillLeased["status"] == "delivering"
assert stillLeased["leased_by"] == "api-worker-one"
assert stillLeased["delivered_at"] is None
retried = client.post(
f"/api/internal/outbox/{message['id']}/result",
headers=serviceHeaders,
json={
"status": "retry",
"error": "temporary failure",
"worker_id": "api-worker-one",
},
)
assert retried.status_code == 200
assert retried.get_json()["message"]["status"] == "pending"
postgres.execute(
"UPDATE outbound_messages SET available_at = NOW() WHERE id = %(id)s",
{"id": message["id"]},
)
claimedAgain = client.post(
"/api/internal/outbox/claim",
headers=serviceHeaders,
json={"worker_id": "api-worker-two", "limit": 1},
)
assert claimedAgain.status_code == 200
assert claimedAgain.get_json()["messages"][0]["attempts"] == 2
delivered = client.post(
f"/api/internal/outbox/{message['id']}/result",
headers=serviceHeaders,
json={"status": "sent", "worker_id": "api-worker-two"},
)
assert delivered.status_code == 200
assert delivered.get_json()["message"]["status"] == "delivered"

View File

@@ -0,0 +1,122 @@
"""Concurrent job and outbox lease behavior backed by PostgreSQL."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
import threading
import uuid
def _claimConcurrently(claim, firstWorker, secondWorker):
barrier = threading.Barrier(2)
def run(workerID):
barrier.wait(timeout=10)
return workerID, claim(workerID)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(run, firstWorker),
executor.submit(run, secondWorker),
]
return dict(future.result(timeout=20) for future in futures)
def test_job_claims_are_disjoint_and_support_retry_and_cancel(
migratedDatabase
):
from core import jobs, users
users.registerUser("job-owner", "job-owner-password")
userUUID = users.getUserUUID("job-owner")
due = datetime.now(timezone.utc) - timedelta(minutes=1)
created = [
jobs.create_job(
"integration.work",
{"sequence": index},
due,
user_uuid=userUUID,
idempotency_key=f"job-{uuid.uuid4()}",
)
for index in range(10)
]
claims = _claimConcurrently(
lambda worker: jobs.claim_due_jobs(worker, limit=5, lease_seconds=60),
"job-worker-one",
"job-worker-two",
)
firstIDs = {str(item["id"]) for item in claims["job-worker-one"]}
secondIDs = {str(item["id"]) for item in claims["job-worker-two"]}
assert firstIDs.isdisjoint(secondIDs)
assert firstIDs | secondIDs == {str(item["id"]) for item in created}
assert jobs.claim_due_jobs("job-worker-three", limit=10) == []
retriedID = str(claims["job-worker-one"][0]["id"])
assert jobs.fail_job(retriedID, "wrong-worker", "must not update") is None
retried = jobs.fail_job(
retriedID, "job-worker-one", "temporary job failure", retry_seconds=1
)
assert retried["status"] == "pending"
assert retried["attempts"] == 1
assert retried["last_error"] == "temporary job failure"
assert retried["run_at"] > datetime.now(timezone.utc)
cancelledID = str(claims["job-worker-two"][0]["id"])
assert jobs.cancel_job(cancelledID, user_uuid=uuid.uuid4()) is None
cancelled = jobs.cancel_job(cancelledID, user_uuid=userUUID)
assert cancelled["status"] == "cancelled"
assert cancelled["leased_by"] is None
def test_outbox_claims_are_disjoint_and_support_retry_and_cancel(
migratedDatabase
):
from core import outbox, users
users.registerUser("outbox-owner", "outbox-owner-password")
userUUID = users.getUserUUID("outbox-owner")
due = datetime.now(timezone.utc) - timedelta(minutes=1)
created = [
outbox.enqueue_message(
userUUID,
"discord_dm",
{"content": f"message {index}"},
idempotency_key=f"outbox-{uuid.uuid4()}",
available_at=due,
)
for index in range(10)
]
claims = _claimConcurrently(
lambda worker: outbox.claim_messages(
worker, channel="discord_dm", limit=5, lease_seconds=60
),
"outbox-worker-one",
"outbox-worker-two",
)
firstIDs = {str(item["id"]) for item in claims["outbox-worker-one"]}
secondIDs = {str(item["id"]) for item in claims["outbox-worker-two"]}
assert firstIDs.isdisjoint(secondIDs)
assert firstIDs | secondIDs == {str(item["id"]) for item in created}
assert outbox.claim_messages("outbox-worker-three", limit=10) == []
retriedID = str(claims["outbox-worker-one"][0]["id"])
assert outbox.retry_message(
retriedID, "wrong-worker", "must not update"
) is None
retried = outbox.retry_message(
retriedID,
"outbox-worker-one",
"temporary delivery failure",
retry_seconds=1,
)
assert retried["status"] == "pending"
assert retried["attempts"] == 1
assert retried["last_error"] == "temporary delivery failure"
assert retried["available_at"] > datetime.now(timezone.utc)
cancelledID = str(claims["outbox-worker-two"][0]["id"])
assert outbox.cancel_message(cancelledID, user_uuid=uuid.uuid4()) is None
cancelled = outbox.cancel_message(cancelledID, user_uuid=userUUID)
assert cancelled["status"] == "cancelled"
assert cancelled["leased_by"] is None

View File

@@ -0,0 +1,70 @@
"""Migration integration coverage against a genuinely fresh database."""
import pytest
def test_fresh_upgrade_is_complete_idempotent_and_reported(isolatedDatabase, capsys):
from core import postgres
from core.migrations import (
MigrationError,
discover_migrations,
migration_status,
upgrade,
)
from core.migrations.__main__ import main
discovered = discover_migrations()
assert discovered
applied = upgrade()
assert [(item.namespace, item.version) for item in applied] == [
(item.namespace, item.version) for item in discovered
]
expectedTables = {
"schema_migrations",
"users",
"notifications",
"scheduled_jobs",
"outbound_messages",
"provider_identities",
"api_keys",
"reminders",
}
rows = postgres.execute(
"""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
"""
)
assert expectedTables.issubset({row["table_name"] for row in rows})
assert upgrade() == []
status = migration_status()
assert len(status) == len(discovered)
assert {record["state"] for record in status} == {"applied"}
assert all(record["applied_at"] is not None for record in status)
assert main(["status"]) == 0
output = capsys.readouterr().out
assert "applied" in output
assert "core:1" in output
assert "reminders:1" in output
postgres.execute(
"""
UPDATE schema_migrations
SET checksum = %(checksum)s
WHERE namespace = 'core' AND version = 1
""",
{"checksum": "0" * 64},
)
changed = migration_status()
coreBaseline = next(
row for row in changed if row["namespace"] == "core" and row["version"] == 1
)
assert coreBaseline["state"] == "changed"
assert main(["status"]) == 1
with pytest.raises(MigrationError, match="checksum changed"):
upgrade()

View File

@@ -0,0 +1,107 @@
"""Reminder API ownership and transactional cancellation coverage."""
from datetime import datetime, timedelta, timezone
import uuid
def _bearer(token):
return {"Authorization": f"Bearer {token}"}
def test_reminders_are_owned_and_cancel_related_work(
client, createAuthenticatedUser
):
from core import outbox, postgres
owner = createAuthenticatedUser("reminder-owner")
stranger = createAuthenticatedUser("reminder-stranger")
runAt = datetime.now(timezone.utc) + timedelta(days=1)
created = client.post(
"/api/reminders",
headers=_bearer(owner["token"]),
json={
"message": "renew the certificate",
"run_at": runAt.isoformat(),
"timezone": "America/Chicago",
"recurrence": {"frequency": "weekly", "interval": 1},
},
)
assert created.status_code == 201, created.get_json()
reminder = created.get_json()
ownerList = client.get(
"/api/reminders", headers=_bearer(owner["token"])
).get_json()["reminders"]
assert [item["id"] for item in ownerList] == [reminder["id"]]
assert client.get(
"/api/reminders", headers=_bearer(stranger["token"])
).get_json() == {"reminders": []}
queued = outbox.enqueue_message(
owner["user_uuid"],
"discord_dm",
{"content": "not delivered yet", "reminder_id": reminder["id"]},
idempotency_key=f"pending-reminder-{uuid.uuid4()}",
)
strangerCancel = client.delete(
f"/api/reminders/{reminder['id']}",
headers=_bearer(stranger["token"]),
)
assert strangerCancel.status_code == 404
storedReminder = postgres.select_one("reminders", {"id": reminder["id"]})
assert storedReminder["status"] == "active"
assert outbox.get_message(queued["id"])["status"] == "pending"
ownerCancel = client.delete(
f"/api/reminders/{reminder['id']}", headers=_bearer(owner["token"])
)
assert ownerCancel.status_code == 200
assert ownerCancel.get_json()["status"] == "cancelled"
job = postgres.execute(
"""
SELECT * FROM scheduled_jobs
WHERE payload->>'reminder_id' = %(reminder_id)s
""",
{"reminder_id": reminder["id"]},
)[0]
assert job["user_uuid"] == uuid.UUID(owner["user_uuid"])
assert job["status"] == "cancelled"
assert outbox.get_message(queued["id"])["status"] == "cancelled"
assert client.delete(
f"/api/reminders/{reminder['id']}", headers=_bearer(owner["token"])
).status_code == 404
def test_reminder_routes_validate_time_and_authentication(
client, createAuthenticatedUser
):
user = createAuthenticatedUser("reminder-validation")
headers = _bearer(user["token"])
assert client.get("/api/reminders").status_code == 401
past = client.post(
"/api/reminders",
headers=headers,
json={
"message": "too late",
"run_at": (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(),
"timezone": "UTC",
},
)
assert past.status_code == 400
assert "future" in past.get_json()["error"]
badTimezone = client.post(
"/api/reminders",
headers=headers,
json={
"message": "bad timezone",
"run_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"timezone": "UTC'; DROP TABLE reminders; --",
},
)
assert badTimezone.status_code == 400

77
tests/unit/conftest.py Normal file
View File

@@ -0,0 +1,77 @@
"""Small import fallbacks for running focused tests without optional services."""
import sys
import types
try:
import psycopg2 # noqa: F401
except ModuleNotFoundError:
psycopg2 = types.ModuleType("psycopg2")
extras = types.ModuleType("psycopg2.extras")
errors = types.ModuleType("psycopg2.errors")
class Json:
def __init__(self, adapted):
self.adapted = adapted
class RealDictCursor:
pass
class UniqueViolation(Exception):
pass
def connect(**_kwargs):
raise AssertionError("Tests must mock PostgreSQL connections")
def execute_values(*_args, **_kwargs):
raise AssertionError("Tests must mock bulk PostgreSQL writes")
extras.Json = Json
extras.RealDictCursor = RealDictCursor
extras.execute_values = execute_values
errors.UniqueViolation = UniqueViolation
psycopg2.connect = connect
psycopg2.extras = extras
psycopg2.errors = errors
sys.modules["psycopg2"] = psycopg2
sys.modules["psycopg2.extras"] = extras
sys.modules["psycopg2.errors"] = errors
try:
import bcrypt # noqa: F401
except ModuleNotFoundError:
bcrypt = types.ModuleType("bcrypt")
def unavailable(*_args, **_kwargs):
raise AssertionError("Tests exercising bcrypt require project dependencies")
bcrypt.gensalt = unavailable
bcrypt.hashpw = unavailable
bcrypt.checkpw = unavailable
sys.modules["bcrypt"] = bcrypt
try:
import jwt # noqa: F401
except ModuleNotFoundError:
jwt = types.ModuleType("jwt")
exceptions = types.ModuleType("jwt.exceptions")
class ExpiredSignatureError(Exception):
pass
class InvalidTokenError(Exception):
pass
def unavailableJwt(*_args, **_kwargs):
raise AssertionError("Tests exercising JWT encoding require project dependencies")
jwt.encode = unavailableJwt
jwt.decode = unavailableJwt
exceptions.ExpiredSignatureError = ExpiredSignatureError
exceptions.InvalidTokenError = InvalidTokenError
jwt.exceptions = exceptions
sys.modules["jwt"] = jwt
sys.modules["jwt.exceptions"] = exceptions

310
tests/unit/test_adapters.py Normal file
View File

@@ -0,0 +1,310 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call
import httpx
import pytest
from bot import api_client as apiClientModule
from bot.context import CommandContext
from scheduler import daemon
def _response(status, payload=None, jsonError=False):
response = MagicMock(status_code=status)
if jsonError:
response.json.side_effect = ValueError("not JSON")
else:
response.json.return_value = payload
return response
@pytest.fixture
def apiClient(monkeypatch):
transport = SimpleNamespace(
post=AsyncMock(),
request=AsyncMock(),
aclose=AsyncMock(),
)
clientFactory = MagicMock(return_value=transport)
monkeypatch.setattr(apiClientModule.httpx, "AsyncClient", clientFactory)
monkeypatch.setenv("API_URL", "http://api.test/")
monkeypatch.setenv("BOT_API_KEY", "service-secret")
monkeypatch.setenv("DEFAULT_TIMEZONE", "UTC")
client = apiClientModule.ApiClient("123", "Test User")
clientFactory.assert_called_once_with(timeout=10.0)
return client, transport
@pytest.mark.asyncio
async def test_api_client_authenticate_stores_session(apiClient):
client, transport = apiClient
transport.post.return_value = _response(
200,
{
"token": "user-token",
"user_uuid": "user-1",
"timezone": "America/Chicago",
},
)
result, status = await client.authenticate()
assert status == 200
assert result["user_uuid"] == "user-1"
assert client.token == "user-token"
assert client.user_uuid == "user-1"
assert client.timezone == "America/Chicago"
transport.post.assert_awaited_once_with(
"http://api.test/api/auth/discord/session",
headers={"Authorization": "Bearer service-secret"},
json={"discord_id": "123", "display_name": "Test User"},
)
@pytest.mark.asyncio
async def test_api_client_authentication_error_stops_user_request(apiClient):
client, transport = apiClient
transport.post.side_effect = httpx.ConnectError("offline")
result, status = await client.authenticate()
assert (result, status) == ({"error": "API unavailable"}, 503)
assert client.token is None
result, status = await client.request("get", "/api/reminders")
assert (result, status) == ({"error": "authentication failed"}, 503)
transport.request.assert_not_awaited()
@pytest.mark.asyncio
async def test_api_client_request_sends_user_token_and_payload(apiClient):
client, transport = apiClient
client.token = "user-token"
transport.request.return_value = _response(201, {"id": "item-1"})
result, status = await client.request(
"post",
"/api/items",
{"name": "example"},
params={"source": "test"},
)
assert (result, status) == ({"id": "item-1"}, 201)
transport.request.assert_awaited_once_with(
"POST",
"http://api.test/api/items",
headers={"Authorization": "Bearer user-token"},
params={"source": "test"},
json={"name": "example"},
)
@pytest.mark.asyncio
async def test_api_client_request_returns_safe_transport_error(apiClient):
client, transport = apiClient
client.token = "user-token"
transport.request.side_effect = httpx.ReadTimeout("timed out")
assert await client.request("get", "/api/items") == (
{"error": "API unavailable"},
503,
)
@pytest.mark.asyncio
async def test_api_client_reauthenticates_once_after_401(apiClient):
client, transport = apiClient
client.token = "expired-token"
transport.request.side_effect = [
_response(401, {"error": "unauthorized"}),
_response(200, {"items": [1]}),
]
transport.post.return_value = _response(
200,
{"token": "fresh-token", "user_uuid": "user-1", "timezone": "UTC"},
)
result, status = await client.request("get", "/api/items", params={"page": 2})
assert (result, status) == ({"items": [1]}, 200)
assert client.token == "fresh-token"
assert transport.request.await_args_list == [
call(
"GET",
"http://api.test/api/items",
headers={"Authorization": "Bearer expired-token"},
params={"page": 2},
),
call(
"GET",
"http://api.test/api/items",
headers={"Authorization": "Bearer fresh-token"},
params={"page": 2},
),
]
transport.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_api_client_returns_failed_refresh_result_after_401(apiClient):
client, transport = apiClient
client.token = "expired-token"
transport.request.return_value = _response(401, {"error": "unauthorized"})
transport.post.return_value = _response(403, {"error": "not enrolled"})
assert await client.request("get", "/api/items") == (
{"error": "not enrolled"},
403,
)
transport.request.assert_awaited_once()
@pytest.mark.asyncio
async def test_api_client_service_request_uses_service_key(apiClient):
client, transport = apiClient
transport.request.return_value = _response(200, {"messages": []})
result, status = await client.service_request(
"post",
"/api/internal/outbox/claim",
{"worker_id": "worker-1"},
)
assert (result, status) == ({"messages": []}, 200)
transport.request.assert_awaited_once_with(
"POST",
"http://api.test/api/internal/outbox/claim",
headers={"Authorization": "Bearer service-secret"},
params=None,
json={"worker_id": "worker-1"},
)
@pytest.mark.asyncio
async def test_api_client_handles_non_json_response_and_closes(apiClient):
client, transport = apiClient
transport.request.return_value = _response(502, jsonError=True)
assert await client.service_request("get", "/bad-response") == ({}, 502)
await client.close()
transport.aclose.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_command_context_exposes_platform_neutral_values_and_replies():
channel = SimpleNamespace(send=AsyncMock(return_value="sent-message"))
message = SimpleNamespace(author=SimpleNamespace(id=987), channel=channel)
api = SimpleNamespace(user_uuid="user-1", timezone="America/Chicago")
context = CommandContext(message, api)
assert context.api is api
assert context.user_uuid == "user-1"
assert context.discord_user_id == "987"
assert context.timezone == "America/Chicago"
assert await context.reply("hello") == "sent-message"
channel.send.assert_awaited_once_with("hello")
def test_scheduler_fails_job_with_unknown_type(monkeypatch):
getHandler = MagicMock(return_value=None)
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=getHandler),
)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
daemon.runJob({"id": "job-1", "job_type": "missing"})
getHandler.assert_called_once_with("missing")
failJob.assert_called_once_with(
"job-1",
daemon.WORKER_ID,
"unknown job type: missing",
)
def test_scheduler_runs_handler_and_completes_running_job(monkeypatch):
job = {"id": "job-1", "job_type": "example"}
handler = MagicMock(return_value=None)
completeJob = MagicMock()
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(daemon.jobs, "get_job", MagicMock(return_value={"status": "running"}))
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
daemon.runJob(job)
handler.assert_called_once_with(job, daemon.WORKER_ID)
completeJob.assert_called_once_with("job-1", daemon.WORKER_ID)
failJob.assert_not_called()
def test_scheduler_awaits_handler_without_double_completion(monkeypatch):
job = {"id": "job-1", "job_type": "async-example"}
handler = AsyncMock(return_value=None)
completeJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(
daemon.jobs,
"get_job",
MagicMock(return_value={"status": "completed"}),
)
monkeypatch.setattr(daemon.jobs, "complete_job", completeJob)
daemon.runJob(job)
handler.assert_awaited_once_with(job, daemon.WORKER_ID)
completeJob.assert_not_called()
def test_scheduler_records_handler_failure(monkeypatch):
job = {"id": "job-1", "job_type": "broken"}
failure = RuntimeError("handler failed")
handler = MagicMock(side_effect=failure)
failJob = MagicMock()
monkeypatch.setattr(
daemon,
"module_registry",
SimpleNamespace(get_job_handler=MagicMock(return_value=handler)),
)
monkeypatch.setattr(daemon.jobs, "fail_job", failJob)
monkeypatch.setattr(daemon.logger, "exception", MagicMock())
daemon.runJob(job)
failJob.assert_called_once_with("job-1", daemon.WORKER_ID, failure)
def test_scheduler_poll_claims_configured_batch_and_runs_each_job(monkeypatch):
claimed = [
{"id": "job-1", "job_type": "one"},
{"id": "job-2", "job_type": "two"},
]
claimJobs = MagicMock(return_value=claimed)
runJob = MagicMock()
monkeypatch.setattr(daemon.jobs, "claim_due_jobs", claimJobs)
monkeypatch.setattr(daemon, "runJob", runJob)
assert daemon.pollJobs() == 2
claimJobs.assert_called_once_with(
daemon.WORKER_ID,
limit=daemon.JOB_BATCH_SIZE,
lease_seconds=daemon.JOB_LEASE_SECONDS,
)
assert runJob.call_args_list == [call(claimed[0]), call(claimed[1])]

View File

@@ -0,0 +1,592 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, call
import flask
import pytest
from api import main as apiMain
from api import security
def _bearer(token="token"):
return {"Authorization": f"Bearer {token}"}
def _userPrincipal(userUUID="user-1", authentication="jwt"):
return {
"type": "user",
"authentication": authentication,
"user_uuid": userUUID,
"scopes": [],
}
def _servicePrincipal(*scopes):
return {
"type": "service",
"authentication": "api_key",
"service_name": "test-service",
"scopes": list(scopes),
}
@pytest.fixture
def securityClient():
app = flask.Flask("security-test")
app.config["TESTING"] = True
@app.route("/json", methods=["POST"])
def parseJson():
return flask.jsonify({"parsed": security.jsonObject()})
@app.route("/user")
@security.requireUser()
def userRoute():
return flask.jsonify(
{
"user_uuid": flask.g.user_uuid,
"principal_type": flask.g.principal["type"],
}
)
@app.route("/login-user")
@security.requireUser(requireLogin=True)
def loginUserRoute():
return flask.jsonify({"ok": True})
@app.route("/service")
@security.requireService("jobs:claim")
def serviceRoute():
return flask.jsonify({"service": flask.g.principal["service_name"]})
return app.test_client()
@pytest.fixture
def apiClient(monkeypatch):
registry = SimpleNamespace(route_registrars=[])
monkeypatch.setattr(apiMain, "discover_modules", MagicMock(return_value=registry))
app = apiMain.createApp()
app.config.update(TESTING=True, SERVICE_KEY_READY=True)
return app.test_client()
def test_json_object_accepts_only_json_objects(securityClient):
assert securityClient.post("/json", json={"value": 1}).get_json() == {
"parsed": {"value": 1}
}
assert securityClient.post("/json", json=[1, 2]).get_json() == {"parsed": None}
assert securityClient.post(
"/json",
data="not-json",
content_type="application/json",
).get_json() == {"parsed": None}
def test_require_user_sets_context_and_forwards_login_requirement(
monkeypatch, securityClient
):
principal = _userPrincipal()
authenticate = MagicMock(return_value=principal)
isUser = MagicMock(return_value=True)
monkeypatch.setattr(security.auth, "authenticateBearerToken", authenticate)
monkeypatch.setattr(security.auth, "isUserPrincipal", isUser)
response = securityClient.get("/user", headers=_bearer("user-token"))
loginResponse = securityClient.get(
"/login-user",
headers=_bearer("user-token"),
)
assert response.status_code == 200
assert response.get_json() == {
"user_uuid": "user-1",
"principal_type": "user",
}
assert loginResponse.status_code == 200
assert authenticate.call_args_list == [
call("Bearer user-token", allowService=False),
call("Bearer user-token", allowService=False),
]
assert isUser.call_args_list == [
call(principal, requireLogin=False),
call(principal, requireLogin=True),
]
def test_require_user_rejects_invalid_principal(monkeypatch, securityClient):
monkeypatch.setattr(
security.auth,
"authenticateBearerToken",
MagicMock(return_value=None),
)
monkeypatch.setattr(
security.auth,
"isUserPrincipal",
MagicMock(return_value=False),
)
response = securityClient.get("/user")
assert response.status_code == 401
assert response.get_json() == {"error": "unauthorized"}
def test_require_service_enforces_scope_and_sets_principal(
monkeypatch, securityClient
):
principal = _servicePrincipal("jobs:claim")
authenticate = MagicMock(side_effect=[principal, None])
hasScope = MagicMock(side_effect=[True, False])
monkeypatch.setattr(security.auth, "authenticateBearerToken", authenticate)
monkeypatch.setattr(security.auth, "hasServiceScope", hasScope)
accepted = securityClient.get("/service", headers=_bearer("service-key"))
rejected = securityClient.get("/service", headers=_bearer("wrong-key"))
assert accepted.status_code == 200
assert accepted.get_json() == {"service": "test-service"}
assert rejected.status_code == 401
assert rejected.get_json() == {"error": "unauthorized"}
assert authenticate.call_args_list == [
call(
"Bearer service-key",
requiredScopes=["jobs:claim"],
allowUser=False,
),
call(
"Bearer wrong-key",
requiredScopes=["jobs:claim"],
allowUser=False,
),
]
assert hasScope.call_args_list == [
call(principal, "jobs:claim"),
call(None, "jobs:claim"),
]
def test_registration_and_login_success_and_errors(monkeypatch, apiClient):
registerUser = MagicMock(
side_effect=[True, False, ValueError("invalid registration")]
)
getToken = MagicMock(side_effect=["login-token", False])
monkeypatch.setattr(apiMain.users, "registerUser", registerUser)
monkeypatch.setattr(apiMain.auth, "getLoginToken", getToken)
registered = apiClient.post(
"/api/register",
json={"username": "alice", "password": "password123", "timezone": "UTC"},
)
duplicate = apiClient.post(
"/api/register",
json={"username": "alice", "password": "password123"},
)
invalid = apiClient.post(
"/api/register",
json={"username": "", "password": "password123"},
)
invalidJson = apiClient.post("/api/register", json=["not", "an", "object"])
loggedIn = apiClient.post(
"/api/login",
json={"username": "alice", "password": "password123"},
)
denied = apiClient.post(
"/api/login",
json={"username": "alice", "password": "wrong-password"},
)
assert registered.status_code == 201
assert registered.get_json() == {"success": True}
assert duplicate.status_code == 409
assert duplicate.get_json() == {"error": "username taken"}
assert invalid.status_code == 400
assert invalid.get_json() == {"error": "invalid registration"}
assert invalidJson.status_code == 400
assert loggedIn.get_json() == {"token": "login-token"}
assert denied.status_code == 401
assert denied.get_json() == {"error": "invalid credentials"}
def test_discord_session_success_and_enrollment_errors(monkeypatch, apiClient):
principal = _servicePrincipal("discord:session")
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=principal),
)
getOrCreate = MagicMock(
side_effect=[ValueError("invalid Discord ID"), None, {"id": "user-1"}]
)
monkeypatch.setattr(apiMain.identity, "getOrCreateDiscordUser", getOrCreate)
monkeypatch.setattr(
apiMain.auth,
"createLoginToken",
MagicMock(return_value="discord-token"),
)
missing = apiClient.post(
"/api/auth/discord/session",
headers=_bearer("service-key"),
json={},
)
invalid = apiClient.post(
"/api/auth/discord/session",
headers=_bearer("service-key"),
json={"discord_id": "bad"},
)
denied = apiClient.post(
"/api/auth/discord/session",
headers=_bearer("service-key"),
json={"discord_id": "456"},
)
accepted = apiClient.post(
"/api/auth/discord/session",
headers=_bearer("service-key"),
json={"discord_id": "123", "display_name": "Alice"},
)
assert missing.status_code == 400
assert invalid.get_json() == {"error": "invalid Discord ID"}
assert denied.status_code == 403
assert accepted.status_code == 200
assert accepted.get_json() == {
"token": "discord-token",
"user_uuid": "user-1",
"timezone": "UTC",
}
apiMain.auth.createLoginToken.assert_called_once_with(
"user-1",
name="Alice",
extraClaims={"provider": "discord"},
)
def test_api_key_routes_use_authenticated_owner(monkeypatch, apiClient):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_userPrincipal()),
)
listKeys = MagicMock(return_value=[{"id": "key-1", "name": "CLI"}])
createKey = MagicMock(
side_effect=[
{"id": "key-2", "name": "new", "key": "secret"},
ValueError("invalid expiry"),
]
)
revokeKey = MagicMock(side_effect=[False, True])
monkeypatch.setattr(apiMain.apiKeys, "listUserApiKeys", listKeys)
monkeypatch.setattr(apiMain.apiKeys, "createUserApiKey", createKey)
monkeypatch.setattr(apiMain.apiKeys, "revokeUserApiKey", revokeKey)
listed = apiClient.get("/api/keys", headers=_bearer())
created = apiClient.post(
"/api/keys",
headers=_bearer(),
json={"name": "new", "expires_at": "2099-01-01T00:00:00Z"},
)
invalid = apiClient.post(
"/api/keys",
headers=_bearer(),
json={"name": "bad"},
)
missing = apiClient.delete("/api/keys/missing", headers=_bearer())
revoked = apiClient.delete("/api/keys/key-1", headers=_bearer())
assert listed.get_json() == {"keys": [{"id": "key-1", "name": "CLI"}]}
assert created.status_code == 201
assert created.get_json()["key"] == "secret"
assert invalid.status_code == 400
assert invalid.get_json() == {"error": "invalid expiry"}
assert missing.status_code == 404
assert revoked.get_json() == {"success": True}
listKeys.assert_called_once_with("user-1")
createKey.assert_has_calls(
[
call("user-1", "new", expiresAt="2099-01-01T00:00:00Z"),
call("user-1", "bad", expiresAt=None),
]
)
def test_user_profile_and_username_routes_enforce_ownership(monkeypatch, apiClient):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_userPrincipal()),
)
getUser = MagicMock(
side_effect=[
{
"id": "user-1",
"username": "alice",
"password_hashed": b"secret-hash",
},
None,
]
)
getUserUUID = MagicMock(side_effect=["user-1", "user-2", False])
monkeypatch.setattr(apiMain.users, "getUser", getUser)
monkeypatch.setattr(apiMain.users, "getUserUUID", getUserUUID)
foreign = apiClient.get("/api/user/user-2", headers=_bearer())
own = apiClient.get("/api/user/user-1", headers=_bearer())
missing = apiClient.get("/api/user/user-1", headers=_bearer())
username = apiClient.get("/api/getUserUUID/alice", headers=_bearer())
foreignUsername = apiClient.get("/api/getUserUUID/bob", headers=_bearer())
missingUsername = apiClient.get("/api/getUserUUID/missing", headers=_bearer())
assert foreign.status_code == 403
assert getUser.call_count == 2
assert own.status_code == 200
assert own.get_json() == {"id": "user-1", "username": "alice"}
assert missing.status_code == 404
assert username.get_json() == "user-1"
assert foreignUsername.status_code == 403
assert missingUsername.status_code == 404
def test_user_update_and_delete_success_and_errors(monkeypatch, apiClient):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_userPrincipal()),
)
updateUser = MagicMock(side_effect=[ValueError("bad timezone"), False, True])
unregisterUser = MagicMock(side_effect=[False, True])
monkeypatch.setattr(apiMain.users, "updateUser", updateUser)
monkeypatch.setattr(apiMain.auth, "unregisterUser", unregisterUser)
assert apiClient.put(
"/api/user/user-2", headers=_bearer(), json={"timezone": "UTC"}
).status_code == 403
assert apiClient.put(
"/api/user/user-1", headers=_bearer(), json=[]
).status_code == 400
invalid = apiClient.put(
"/api/user/user-1", headers=_bearer(), json={"timezone": "Invalid"}
)
empty = apiClient.put(
"/api/user/user-1", headers=_bearer(), json={"username": "ignored"}
)
updated = apiClient.put(
"/api/user/user-1", headers=_bearer(), json={"timezone": "UTC"}
)
missingPassword = apiClient.delete(
"/api/user/user-1", headers=_bearer(), json={}
)
wrongPassword = apiClient.delete(
"/api/user/user-1", headers=_bearer(), json={"password": "wrong"}
)
deleted = apiClient.delete(
"/api/user/user-1", headers=_bearer(), json={"password": "correct"}
)
assert invalid.get_json() == {"error": "bad timezone"}
assert empty.get_json() == {"error": "no valid fields to update"}
assert updated.get_json() == {"success": True}
assert missingPassword.status_code == 400
assert wrongPassword.status_code == 401
assert deleted.get_json() == {"success": True}
def test_timezone_routes_read_validate_and_update(monkeypatch, apiClient):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_userPrincipal()),
)
normalize = MagicMock(
side_effect=["America/Chicago", ValueError("invalid timezone")]
)
updateUser = MagicMock(return_value=True)
monkeypatch.setattr(
apiMain.users,
"getUserTimezone",
MagicMock(return_value="UTC"),
)
monkeypatch.setattr(apiMain.users, "normalizeTimezone", normalize)
monkeypatch.setattr(apiMain.users, "updateUser", updateUser)
current = apiClient.get("/api/user/me/timezone", headers=_bearer())
updated = apiClient.put(
"/api/user/me/timezone",
headers=_bearer(),
json={"timezone": "America/Chicago"},
)
invalid = apiClient.put(
"/api/user/me/timezone",
headers=_bearer(),
json={"timezone": "Invalid"},
)
assert current.get_json() == {"timezone": "UTC"}
assert updated.get_json() == {"timezone": "America/Chicago"}
assert invalid.status_code == 400
assert invalid.get_json() == {"error": "invalid timezone"}
updateUser.assert_called_once_with(
"user-1",
{"timezone": "America/Chicago"},
)
def test_outbox_claim_filters_missing_discord_identities(monkeypatch, apiClient):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_servicePrincipal("outbox:claim")),
)
claimMessages = MagicMock(
side_effect=[
ValueError("invalid limit"),
[
{
"id": "message-1",
"user_uuid": "user-1",
"payload": {"content": "hello"},
"attempts": 1,
},
{
"id": "message-2",
"user_uuid": "user-2",
"payload": {},
"attempts": 2,
},
],
]
)
identities = MagicMock(
side_effect=[
[{"provider": "discord", "provider_user_id": "discord-1"}],
[],
]
)
retryMessage = MagicMock()
monkeypatch.setattr(apiMain.outbox, "claim_messages", claimMessages)
monkeypatch.setattr(apiMain.identity, "listUserIdentities", identities)
monkeypatch.setattr(apiMain.outbox, "retry_message", retryMessage)
missingWorker = apiClient.post(
"/api/internal/outbox/claim",
headers=_bearer(),
json={},
)
invalid = apiClient.post(
"/api/internal/outbox/claim",
headers=_bearer(),
json={"worker_id": "worker-1", "limit": 0},
)
claimed = apiClient.post(
"/api/internal/outbox/claim",
headers=_bearer(),
json={"worker_id": "worker-1", "channel": "discord_dm", "limit": 2},
)
assert missingWorker.status_code == 400
assert invalid.get_json() == {"error": "invalid limit"}
assert claimed.get_json() == {
"messages": [
{
"id": "message-1",
"provider_user_id": "discord-1",
"content": "hello",
"attempts": 1,
"worker_id": "worker-1",
}
]
}
retryMessage.assert_called_once_with(
"message-2",
"worker-1",
"user has no Discord identity",
)
def test_outbox_result_enforces_lease_owner_and_records_results(
monkeypatch, apiClient
):
monkeypatch.setattr(
apiMain.auth,
"authenticateBearerToken",
MagicMock(return_value=_servicePrincipal("outbox:deliver")),
)
leased = {"id": "message-1", "status": "delivering", "leased_by": "worker-1"}
getMessage = MagicMock(side_effect=[None, leased, leased, leased])
deliveredRecord = {"id": "message-1", "status": "delivered"}
retryRecord = {"id": "message-1", "status": "pending"}
markDelivered = MagicMock(return_value=deliveredRecord)
retryMessage = MagicMock(return_value=retryRecord)
monkeypatch.setattr(apiMain.outbox, "get_message", getMessage)
monkeypatch.setattr(apiMain.outbox, "mark_delivered", markDelivered)
monkeypatch.setattr(apiMain.outbox, "retry_message", retryMessage)
invalid = apiClient.post(
"/api/internal/outbox/message-1/result",
headers=_bearer(),
json={"status": "unknown"},
)
missing = apiClient.post(
"/api/internal/outbox/message-1/result",
headers=_bearer(),
json={"status": "sent", "worker_id": "worker-1"},
)
wrongWorker = apiClient.post(
"/api/internal/outbox/message-1/result",
headers=_bearer(),
json={"status": "sent", "worker_id": "worker-2"},
)
delivered = apiClient.post(
"/api/internal/outbox/message-1/result",
headers=_bearer(),
json={
"status": "sent",
"worker_id": "worker-1",
"external_message_id": "x" * 300,
},
)
retried = apiClient.post(
"/api/internal/outbox/message-1/result",
headers=_bearer(),
json={
"status": "retry",
"worker_id": "worker-1",
"error": "temporary failure",
},
)
assert invalid.status_code == 400
assert missing.status_code == 404
assert wrongWorker.status_code == 409
assert delivered.get_json()["message"] == deliveredRecord
assert retried.get_json()["message"] == retryRecord
markDelivered.assert_called_once_with(
"message-1",
"worker-1",
external_message_id="x" * 255,
)
retryMessage.assert_called_once_with(
"message-1",
"worker-1",
"temporary failure",
)
def test_health_and_standard_error_responses(monkeypatch, apiClient):
execute = MagicMock(side_effect=[[{"ready": 1}], RuntimeError("offline")])
monkeypatch.setattr(apiMain.postgres, "execute", execute)
live = apiClient.get("/health/live")
ready = apiClient.get("/health/ready")
unavailable = apiClient.get("/health")
missing = apiClient.get("/missing")
wrongMethod = apiClient.get("/api/register")
assert live.get_json() == {"status": "ok"}
assert ready.get_json() == {"status": "ready"}
assert unavailable.status_code == 503
assert unavailable.get_json() == {"status": "not ready"}
assert missing.status_code == 404
assert missing.get_json() == {"error": "not found"}
assert wrongMethod.status_code == 405
assert wrongMethod.get_json() == {"error": "method not allowed"}

View File

@@ -0,0 +1,262 @@
"""Network-free tests for Discord adapter routing and delivery behavior."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from bot import bot as botModule
class TypingContext:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
class FakeChannel:
def __init__(self):
self.sent = []
async def send(self, content):
self.sent.append(content)
return content
def typing(self):
return TypingContext()
def _message(content="hello", authorID=123, channel=None):
author = SimpleNamespace(
id=authorID,
display_name="Alice",
__str__=lambda self: "Alice",
)
return SimpleNamespace(
content=content,
author=author,
channel=channel or FakeChannel(),
)
@pytest.fixture(autouse=True)
def cleanBotCaches():
botModule.api_clients.clear()
botModule.message_history.clear()
botModule.user_locks.clear()
yield
botModule.api_clients.clear()
botModule.message_history.clear()
botModule.user_locks.clear()
def test_api_clients_are_cached_per_stable_discord_id(monkeypatch):
created = []
def clientFactory(discordID, displayName):
client = SimpleNamespace(discord_id=str(discordID), display_name=displayName)
created.append(client)
return client
monkeypatch.setattr(botModule, "ApiClient", clientFactory)
first = botModule.getApiClient(_message(authorID=123))
second = botModule.getApiClient(_message(authorID=123))
third = botModule.getApiClient(_message(authorID=456))
assert first is second and third is not first
assert len(created) == 2 and first.display_name == "Alice"
@pytest.mark.asyncio
async def test_help_message_uses_registered_module_help(monkeypatch):
message = _message("help")
monkeypatch.setattr(
botModule.module_registry,
"help_lines",
MagicMock(return_value=["- remind me", "- list reminders"]),
)
await botModule.sendHelpMessage(message)
assert "remind me" in message.channel.sent[0]
assert "Just talk naturally" in message.channel.sent[0]
@pytest.mark.asyncio
async def test_authentication_reuses_token_and_maps_api_failures(monkeypatch):
message = _message()
existing = SimpleNamespace(token="already-authenticated")
monkeypatch.setattr(botModule, "getApiClient", lambda _message: existing)
assert await botModule.authenticateMessage(message) is existing
for status, expected in [
(403, "not enabled"),
(503, "still starting"),
(401, "couldn't start"),
]:
message = _message()
client = SimpleNamespace(
token=None,
authenticate=AsyncMock(return_value=({"error": "denied"}, status)),
)
monkeypatch.setattr(botModule, "getApiClient", lambda _message, value=client: value)
assert await botModule.authenticateMessage(message) is None
assert expected in message.channel.sent[0]
accepted = SimpleNamespace(
token=None,
authenticate=AsyncMock(return_value=({"token": "new"}, 200)),
)
monkeypatch.setattr(botModule, "getApiClient", lambda _message: accepted)
assert await botModule.authenticateMessage(_message()) is accepted
@pytest.mark.asyncio
async def test_command_routing_handles_help_parser_errors_and_unknowns(monkeypatch):
apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one")
helpMessage = _message("help")
helpCall = AsyncMock()
monkeypatch.setattr(botModule, "sendHelpMessage", helpCall)
await botModule.routeCommand(helpMessage, apiClient)
helpCall.assert_awaited_once_with(helpMessage)
parse = AsyncMock(
side_effect=[
{"needs_clarification": "When should I do that?"},
{"error": "provider unavailable"},
{"interaction_type": "missing"},
]
)
monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse)
monkeypatch.setattr(botModule.module_registry, "get_command", lambda _name: None)
clarification = _message("do the thing")
await botModule.routeCommand(clarification, apiClient)
assert clarification.channel.sent == ["When should I do that?"]
failed = _message("do the other thing")
await botModule.routeCommand(failed, apiClient)
assert "provider unavailable" in failed.channel.sent[0]
unknown = _message("unknown feature")
await botModule.routeCommand(unknown, apiClient)
assert unknown.channel.sent == ["Unknown command type: missing"]
assert len(botModule.message_history[123]) == 3
@pytest.mark.asyncio
async def test_command_handler_receives_context_and_failure_is_isolated(monkeypatch):
apiClient = SimpleNamespace(timezone="UTC", user_uuid="user-one")
parse = AsyncMock(return_value={"interaction_type": "sample", "value": 1})
handler = AsyncMock()
monkeypatch.setattr(botModule.ai_parser, "parse_command_async", parse)
monkeypatch.setattr(
botModule.module_registry,
"get_command",
lambda _name: {"handler": handler},
)
message = _message("run sample")
await botModule.routeCommand(message, apiClient)
context, parsed = handler.await_args.args
assert context.user_uuid == "user-one" and parsed["value"] == 1
handler.side_effect = RuntimeError("module failed")
message = _message("run sample again")
await botModule.routeCommand(message, apiClient)
assert "failed unexpectedly" in message.channel.sent[0]
@pytest.mark.asyncio
async def test_on_message_filters_and_serializes_dm_work(monkeypatch):
class FakeDMChannel(FakeChannel):
pass
monkeypatch.setattr(botModule.discord, "DMChannel", FakeDMChannel)
botUser = SimpleNamespace(id=999)
monkeypatch.setattr(botModule, "client", SimpleNamespace(user=botUser))
authenticate = AsyncMock(return_value=SimpleNamespace())
route = AsyncMock()
monkeypatch.setattr(botModule, "authenticateMessage", authenticate)
monkeypatch.setattr(botModule, "routeCommand", route)
ownMessage = _message(channel=FakeDMChannel())
ownMessage.author = botUser
await botModule.on_message(ownMessage)
await botModule.on_message(_message(channel=FakeChannel()))
authenticate.assert_not_awaited()
dm = _message(channel=FakeDMChannel())
await botModule.on_message(dm)
authenticate.assert_awaited_once_with(dm)
route.assert_awaited_once()
assert dm.author.id in botModule.user_locks
@pytest.mark.asyncio
async def test_outbound_delivery_reports_success_and_retry(monkeypatch):
sent = SimpleNamespace(id=987)
discordUser = SimpleNamespace(send=AsyncMock(return_value=sent))
client = SimpleNamespace(fetch_user=AsyncMock(return_value=discordUser))
service = SimpleNamespace(service_request=AsyncMock(return_value=({}, 200)))
monkeypatch.setattr(botModule, "client", client)
monkeypatch.setattr(botModule, "service_client", service)
outbound = {
"id": "message-one",
"provider_user_id": "123",
"content": "hello",
"worker_id": "worker-one",
}
await botModule.deliverOutboundMessage(outbound)
payload = service.service_request.await_args.args[2]
assert payload == {
"status": "sent",
"external_message_id": "987",
"worker_id": "worker-one",
}
client.fetch_user.side_effect = RuntimeError("Discord offline")
service.service_request.reset_mock()
await botModule.deliverOutboundMessage(outbound)
payload = service.service_request.await_args.args[2]
assert payload["status"] == "retry"
assert payload["worker_id"] == "worker-one"
assert "Discord offline" in payload["error"]
@pytest.mark.asyncio
async def test_ready_starts_outbox_loop_once(monkeypatch):
loop = SimpleNamespace(is_running=MagicMock(side_effect=[False, True]), start=MagicMock())
monkeypatch.setattr(botModule, "outboxLoop", loop)
monkeypatch.setattr(botModule, "client", SimpleNamespace(user="bot-user"))
await botModule.on_ready()
await botModule.on_ready()
loop.start.assert_called_once()
@pytest.mark.asyncio
async def test_outbox_poll_handles_api_failure_and_delivers_batch(monkeypatch):
outbound = {"id": "message-one"}
service = SimpleNamespace(
service_request=AsyncMock(
side_effect=[
({"error": "offline"}, 503),
({"messages": [outbound]}, 200),
]
)
)
deliver = AsyncMock()
monkeypatch.setattr(botModule, "service_client", service)
monkeypatch.setattr(botModule, "deliverOutboundMessage", deliver)
await botModule.outboxLoop.coro()
deliver.assert_not_awaited()
await botModule.outboxLoop.coro()
deliver.assert_awaited_once_with(outbound)
request = service.service_request.await_args.args
assert request[0:2] == ("post", "/api/internal/outbox/claim")
assert request[2]["worker_id"] == botModule.OUTBOX_WORKER_ID
@pytest.mark.asyncio
async def test_outbox_before_loop_waits_for_discord_ready(monkeypatch):
wait = AsyncMock()
monkeypatch.setattr(botModule, "client", SimpleNamespace(wait_until_ready=wait))
await botModule.beforeOutboxLoop()
wait.assert_awaited_once()

View File

@@ -0,0 +1,290 @@
"""Focused unit coverage for durable job and outbound-message state changes."""
from datetime import datetime, timezone
import pytest
from core import jobs, outbox
NOW = datetime.now(timezone.utc)
MESSAGE_ID = "00000000-0000-0000-0000-000000000001"
class RecordingCursor:
def __init__(self, one=None, many=None):
self.one = list(one or [])
self.many = list(many or [])
self.executed = []
def execute(self, query, params=None):
self.executed.append((" ".join(query.split()), params))
def fetchone(self):
return self.one.pop(0) if self.one else None
def fetchall(self):
return self.many
@pytest.mark.parametrize("queue", [jobs, outbox])
def test_shared_timestamp_and_positive_validation(queue):
assert queue._timestamp("2026-01-02T03:04:05Z") == datetime(
2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc
)
assert queue._timestamp(datetime(2026, 1, 2, 3, 4, 5)).tzinfo == timezone.utc
with pytest.raises(ValueError, match="datetime"):
queue._timestamp(123)
with pytest.raises(ValueError, match="whole number"):
queue._positive(None, "limit")
with pytest.raises(ValueError, match="at least 1"):
queue._positive(0, "limit")
with pytest.raises(ValueError, match="at most 2"):
queue._positive(3, "limit", 2)
def test_create_get_and_list_jobs_with_parameterized_filters():
created = {"id": "job-one", "status": "pending"}
cursor = RecordingCursor(one=[created])
result = jobs.create_job(
" sample.work ",
{"value": 1},
NOW,
user_uuid="user-one",
max_attempts="4",
idempotency_key="unique-work",
job_id="job-one",
cursor=cursor,
)
assert result == created
params = cursor.executed[0][1]
assert params["job_type"] == "sample.work"
assert params["max_attempts"] == 4
assert params["payload"].adapted == {"value": 1}
with pytest.raises(ValueError, match="job_type"):
jobs.create_job(" ", {}, NOW, cursor=cursor)
cursor = RecordingCursor(one=[created])
assert jobs.get_job("job-one", cursor=cursor) == created
cursor = RecordingCursor(many=[created])
assert jobs.list_jobs(
user_uuid="user-one",
status="pending",
job_type="sample.work",
limit=3,
cursor=cursor,
) == [created]
query, params = cursor.executed[0]
assert "user_uuid = %s" in query and "job_type = %s" in query
assert params == ["user-one", "pending", "sample.work", 3]
def test_job_claim_renew_complete_retry_and_cancel_paths():
claimed = [{"id": "job-one", "status": "running"}]
cursor = RecordingCursor(many=claimed)
assert jobs.claim_due_jobs(
"worker-one",
limit=2,
lease_seconds=60,
job_types="sample.work",
cursor=cursor,
) == claimed
assert len(cursor.executed) == 2
assert cursor.executed[1][1]["job_types"] == ["sample.work"]
assert jobs.claim_due_jobs("worker", job_types=[], cursor=cursor) == []
with pytest.raises(ValueError, match="worker_id"):
jobs.claim_due_jobs("", cursor=cursor)
updated = {"id": "job-one", "status": "running"}
cursor = RecordingCursor(one=[updated, {**updated, "status": "completed"}])
assert jobs.renew_job_lease("job-one", "worker-one", 30, cursor=cursor) == updated
assert jobs.complete_job("job-one", "worker-one", cursor=cursor)[
"status"
] == "completed"
cursor = RecordingCursor(
one=[
{"attempts": 2, "max_attempts": 3},
{"id": "job-one", "status": "pending"},
]
)
retried = jobs.fail_job(
"job-one", "worker-one", "temporary", retry_seconds=10, cursor=cursor
)
assert retried["status"] == "pending"
assert cursor.executed[1][1]["delay"] == 20
assert cursor.executed[1][1]["exhausted"] is False
cursor = RecordingCursor(
one=[
{"attempts": 3, "max_attempts": 3},
{"id": "job-one", "status": "failed"},
]
)
assert jobs.fail_job("job-one", "worker-one", "fatal", cursor=cursor)[
"status"
] == "failed"
assert cursor.executed[1][1]["exhausted"] is True
assert jobs.fail_job(
"missing", "worker-one", "ignored", cursor=RecordingCursor()
) is None
cursor = RecordingCursor(one=[{"id": "job-one", "status": "cancelled"}])
assert jobs.cancel_job("job-one", user_uuid="user-one", cursor=cursor)[
"status"
] == "cancelled"
cursor = RecordingCursor(many=[{"id": "job-two"}])
assert jobs.cancel_jobs(job_type="sample.work", cursor=cursor) == [
{"id": "job-two"}
]
with pytest.raises(ValueError, match="filter"):
jobs.cancel_jobs(cursor=cursor)
def test_enqueue_get_and_list_messages_with_parameterized_filters():
created = {"id": "message-one", "status": "pending"}
cursor = RecordingCursor(one=[created])
result = outbox.enqueue_message(
"user-one",
" discord_dm ",
{"content": "hello"},
"unique-message",
available_at=NOW,
max_attempts=4,
message_id="message-one",
cursor=cursor,
)
assert result == created
params = cursor.executed[0][1]
assert params["channel"] == "discord_dm"
assert params["payload"].adapted == {"content": "hello"}
invalidValues = [
(None, "discord_dm", {}, "key", "user_uuid"),
("user", " ", {}, "key", "channel"),
("user", "discord_dm", {}, None, "idempotency_key"),
("user", "discord_dm", None, "key", "payload"),
]
for userUUID, channel, payload, key, error in invalidValues:
with pytest.raises(ValueError, match=error):
outbox.enqueue_message(
userUUID, channel, payload, key, available_at=NOW, cursor=cursor
)
cursor = RecordingCursor(one=[created])
assert outbox.get_message(MESSAGE_ID, cursor=cursor) == created
assert outbox.get_message("not-a-uuid", cursor=cursor) is None
cursor = RecordingCursor(many=[created])
assert outbox.list_messages(
user_uuid="user-one",
status="pending",
channel="discord_dm",
limit=2,
cursor=cursor,
) == [created]
def test_outbox_claim_renew_delivery_retry_and_cancel_paths():
claimed = [{"id": "message-one", "status": "delivering"}]
cursor = RecordingCursor(many=claimed)
assert outbox.claim_messages(
"worker-one",
channel="discord_dm",
limit=2,
lease_seconds=60,
cursor=cursor,
) == claimed
assert len(cursor.executed) == 2
with pytest.raises(ValueError, match="worker_id"):
outbox.claim_messages(None, cursor=cursor)
cursor = RecordingCursor(
one=[
{"id": "message-one", "status": "delivering"},
{"id": "message-one", "status": "delivered"},
]
)
assert outbox.renew_message_lease(
"message-one", "worker-one", 60, cursor=cursor
)["status"] == "delivering"
delivered = outbox.mark_delivered(
"message-one",
"worker-one",
external_message_id="discord-123",
cursor=cursor,
)
assert delivered["status"] == "delivered"
assert cursor.executed[1][1][0] == "discord-123"
cursor = RecordingCursor(
one=[
{"attempts": 1, "max_attempts": 3},
{"id": "message-one", "status": "pending"},
]
)
retried = outbox.retry_message(
"message-one", "worker-one", "temporary", retry_seconds=15, cursor=cursor
)
assert retried["status"] == "pending"
assert cursor.executed[1][1]["delay"] == 15
cursor = RecordingCursor(
one=[
{"attempts": 3, "max_attempts": 3},
{"id": "message-one", "status": "failed"},
]
)
assert outbox.retry_message(
"message-one", "worker-one", "fatal", cursor=cursor
)["status"] == "failed"
assert outbox.retry_message(
"missing", "worker-one", "ignored", cursor=RecordingCursor()
) is None
cursor = RecordingCursor(one=[{"id": "message-one", "status": "cancelled"}])
assert outbox.cancel_message(
"message-one", user_uuid="user-one", cursor=cursor
)["status"] == "cancelled"
cursor = RecordingCursor(many=[{"id": "message-two"}])
assert outbox.cancel_messages(channel="discord_dm", cursor=cursor) == [
{"id": "message-two"}
]
with pytest.raises(ValueError, match="filter"):
outbox.cancel_messages(cursor=cursor)
def test_retry_backoff_is_capped():
cursor = RecordingCursor(
one=[
{"attempts": 20, "max_attempts": 30},
{"id": "job", "status": "pending"},
]
)
jobs.fail_job(
"job",
"worker",
"retry",
retry_seconds=30,
max_retry_seconds=90,
cursor=cursor,
)
assert cursor.executed[1][1]["delay"] == 90
cursor = RecordingCursor(
one=[
{"attempts": 20, "max_attempts": 30},
{"id": "message", "status": "pending"},
]
)
outbox.retry_message(
"message",
"worker",
"retry",
retry_seconds=30,
max_retry_seconds=90,
cursor=cursor,
)
assert cursor.executed[1][1]["delay"] == 90

View File

@@ -0,0 +1,170 @@
"""Unit tests for migration discovery and history decisions."""
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from core import migrations
@pytest.fixture
def migrationRoot():
with TemporaryDirectory(prefix=".migration-test-", dir=Path.cwd()) as directory:
yield Path(directory)
def _writeMigration(root, relativePath, content="SELECT 1;"):
path = root / relativePath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
class FakeCursor:
def __init__(self, applied=None, historyTable=True):
self.applied = list(applied or [])
self.historyTable = historyTable
self.executed = []
self._one = None
self._all = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, query, params=None):
normalized = " ".join(query.split())
self.executed.append((normalized, params))
if "SELECT to_regclass" in normalized:
self._one = {
"table_name": "schema_migrations" if self.historyTable else None
}
elif normalized.startswith("SELECT namespace, version, checksum"):
self._all = self.applied
def fetchone(self):
return self._one
def fetchall(self):
return self._all
class FakeConnection:
def __init__(self, cursor):
self.activeCursor = cursor
def cursor(self, **_kwargs):
return self.activeCursor
def _connectionFor(cursor):
@contextmanager
def fakeConnection():
yield FakeConnection(cursor)
return fakeConnection
def test_discovery_orders_core_before_feature_namespaces(migrationRoot):
_writeMigration(migrationRoot, "config/migrations/0002_second.sql", "SELECT 2;")
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
_writeMigration(migrationRoot, "modules/zeta/migrations/0001_zeta.sql")
_writeMigration(migrationRoot, "modules/alpha/migrations/0002_alpha.sql")
found = migrations.discover_migrations(migrationRoot)
assert [(item.namespace, item.version) for item in found] == [
("core", 1),
("core", 2),
("alpha", 2),
("zeta", 1),
]
assert len(found[0].checksum) == 64
assert found[0].path == Path(
migrationRoot, "config/migrations/0001_first.sql"
)
def test_discovery_rejects_bad_names_and_duplicate_versions(migrationRoot):
_writeMigration(migrationRoot, "config/migrations/not-numbered.sql")
with pytest.raises(migrations.MigrationError, match="Invalid migration filename"):
migrations.discover_migrations(migrationRoot)
Path(migrationRoot, "config/migrations/not-numbered.sql").unlink()
_writeMigration(migrationRoot, "config/migrations/0001_first.sql")
_writeMigration(migrationRoot, "config/migrations/0001_duplicate.sql")
with pytest.raises(migrations.MigrationError, match="Duplicate migration"):
migrations.discover_migrations(migrationRoot)
def test_upgrade_applies_pending_and_skips_matching_history(
migrationRoot, monkeypatch
):
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 42;")
migration = migrations.discover_migrations(migrationRoot)[0]
cursor = FakeCursor()
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
assert migrations.upgrade(migrationRoot) == [migration]
queries = [query for query, _params in cursor.executed]
assert any("pg_advisory_xact_lock" in query for query in queries)
assert "SELECT 42;" in queries
assert any(query.startswith("INSERT INTO schema_migrations") for query in queries)
cursor = FakeCursor(
applied=[
{
"namespace": "core",
"version": 1,
"checksum": migration.checksum,
"applied_at": None,
}
]
)
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
assert migrations.upgrade(migrationRoot) == []
assert "SELECT 42;" not in [query for query, _params in cursor.executed]
def test_upgrade_rejects_changed_applied_migration(migrationRoot, monkeypatch):
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
cursor = FakeCursor(
applied=[
{
"namespace": "core",
"version": 1,
"checksum": "0" * 64,
"applied_at": None,
}
]
)
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
with pytest.raises(migrations.MigrationError, match="checksum changed"):
migrations.upgrade(migrationRoot)
def test_status_handles_new_database_and_missing_source(migrationRoot, monkeypatch):
_writeMigration(migrationRoot, "config/migrations/0001_first.sql", "SELECT 1;")
cursor = FakeCursor(historyTable=False)
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
assert migrations.migration_status(migrationRoot)[0]["state"] == "pending"
cursor = FakeCursor(
applied=[
{
"namespace": "removed_feature",
"version": 3,
"checksum": "a" * 64,
"applied_at": "earlier",
}
]
)
monkeypatch.setattr(migrations.postgres, "get_connection", _connectionFor(cursor))
status = migrations.migration_status(migrationRoot)
assert [item["state"] for item in status] == ["pending", "missing"]
assert status[1]["namespace"] == "removed_feature"

View File

@@ -0,0 +1,223 @@
from unittest.mock import MagicMock, call
import pytest
from core import notifications
def test_get_notification_settings_returns_record_or_false(monkeypatch):
selectOne = MagicMock(
side_effect=[{"user_uuid": "user-1", "ntfy_enabled": True}, None]
)
monkeypatch.setattr(notifications.postgres, "select_one", selectOne)
assert notifications.getNotificationSettings("user-1") == {
"user_uuid": "user-1",
"ntfy_enabled": True,
}
assert notifications.getNotificationSettings("user-2") is False
assert selectOne.call_args_list == [
call("notifications", {"user_uuid": "user-1"}),
call("notifications", {"user_uuid": "user-2"}),
]
def test_notification_settings_filter_fields_and_update_existing(monkeypatch):
update = MagicMock()
insert = MagicMock()
monkeypatch.setattr(
notifications.postgres,
"select_one",
MagicMock(return_value={"id": "notification-1"}),
)
monkeypatch.setattr(notifications.postgres, "update", update)
monkeypatch.setattr(notifications.postgres, "insert", insert)
result = notifications.setNotificationSettings(
"user-1",
{
"ntfy_topic": "team-alerts",
"ntfy_enabled": True,
"user_uuid": "another-user",
"created_at": "not-allowed",
},
)
assert result is True
update.assert_called_once_with(
"notifications",
{"ntfy_topic": "team-alerts", "ntfy_enabled": True},
{"user_uuid": "user-1"},
)
insert.assert_not_called()
def test_notification_settings_insert_new_record(monkeypatch):
insert = MagicMock()
monkeypatch.setattr(
notifications.postgres,
"select_one",
MagicMock(return_value=None),
)
monkeypatch.setattr(notifications.postgres, "insert", insert)
monkeypatch.setattr(notifications.uuid, "uuid4", lambda: "notification-1")
result = notifications.setNotificationSettings(
"user-1",
{"discord_enabled": False, "ntfy_topic": "personal"},
)
assert result is True
insert.assert_called_once_with(
"notifications",
{
"discord_enabled": False,
"ntfy_topic": "personal",
"id": "notification-1",
"user_uuid": "user-1",
},
)
@pytest.mark.parametrize(
"settings",
[None, [], "invalid", {}, {"created_at": "not-allowed"}],
)
def test_notification_settings_reject_invalid_or_empty_updates(monkeypatch, settings):
selectOne = MagicMock()
monkeypatch.setattr(notifications.postgres, "select_one", selectOne)
assert notifications.setNotificationSettings("user-1", settings) is False
selectOne.assert_not_called()
@pytest.mark.parametrize(
"webhook",
[
"https://discord.com/api/webhooks/123/token",
"https://canary.discord.com/api/webhooks/123/token",
"https://ptb.discord.com/api/webhooks/123/token",
],
)
def test_discord_webhook_validation_accepts_official_https_urls(webhook):
assert notifications._validateDiscordWebhook(webhook) == webhook
@pytest.mark.parametrize(
("webhook", "error"),
[
(
"http://discord.com/api/webhooks/123/token",
"official HTTPS Discord host",
),
(
"https://discord.com.evil.example/api/webhooks/123/token",
"official HTTPS Discord host",
),
("https://discord.com/channels/123", "Invalid Discord webhook path"),
],
)
def test_discord_webhook_validation_rejects_unsafe_urls(webhook, error):
with pytest.raises(ValueError, match=error):
notifications._validateDiscordWebhook(webhook)
def test_discord_webhook_delivery_posts_content(monkeypatch):
post = MagicMock(return_value=MagicMock(status_code=204))
monkeypatch.setattr(notifications.requests, "post", post)
webhook = "https://discord.com/api/webhooks/123/token"
assert notifications.discord.send(webhook, 42) is True
post.assert_called_once_with(
webhook,
json={"content": "42"},
timeout=notifications.REQUEST_TIMEOUT,
)
@pytest.mark.parametrize("failure", [429, notifications.requests.ConnectionError("offline")])
def test_discord_webhook_delivery_reports_failures(monkeypatch, failure):
post = MagicMock()
if isinstance(failure, int):
post.return_value = MagicMock(status_code=failure)
else:
post.side_effect = failure
monkeypatch.setattr(notifications.requests, "post", post)
assert notifications.discord.send(
"https://discord.com/api/webhooks/123/token",
"hello",
) is False
def test_ntfy_encodes_topic_and_sends_bearer_token(monkeypatch):
post = MagicMock(return_value=MagicMock(status_code=201))
monkeypatch.setattr(notifications.requests, "post", post)
monkeypatch.setenv("NTFY_BASE_URL", "https://notify.example/base/")
monkeypatch.setenv("NTFY_TOKEN", "ntfy-secret")
assert notifications.ntfy.send(" alerts/team #1 ", 42) is True
post.assert_called_once_with(
"https://notify.example/base/alerts%2Fteam%20%231",
data=b"42",
headers={"Authorization": "Bearer ntfy-secret"},
timeout=notifications.REQUEST_TIMEOUT,
)
@pytest.mark.parametrize("failure", [500, notifications.requests.Timeout("slow")])
def test_ntfy_reports_http_and_transport_failures(monkeypatch, failure):
post = MagicMock()
if isinstance(failure, int):
post.return_value = MagicMock(status_code=failure)
else:
post.side_effect = failure
monkeypatch.setattr(notifications.requests, "post", post)
monkeypatch.delenv("NTFY_TOKEN", raising=False)
assert notifications.ntfy.send("alerts", "hello") is False
def test_ntfy_rejects_empty_topic_without_request(monkeypatch):
post = MagicMock()
monkeypatch.setattr(notifications.requests, "post", post)
assert notifications.ntfy.send(" ", "hello") is False
assert notifications.ntfy.send(None, "hello") is False
post.assert_not_called()
def test_channel_aggregation_tries_each_enabled_channel(monkeypatch):
discordSend = MagicMock(return_value=False)
ntfySend = MagicMock(return_value=True)
monkeypatch.setattr(notifications.discord, "send", discordSend)
monkeypatch.setattr(notifications.ntfy, "send", ntfySend)
result = notifications._sendToEnabledChannels(
{
"discord_enabled": True,
"discord_webhook": "https://discord.com/api/webhooks/123/token",
"ntfy_enabled": True,
"ntfy_topic": "alerts",
},
"hello",
)
assert result is True
discordSend.assert_called_once_with(
"https://discord.com/api/webhooks/123/token",
"hello",
)
ntfySend.assert_called_once_with("alerts", "hello")
@pytest.mark.parametrize("settings", [None, {}, {"discord_enabled": False}])
def test_channel_aggregation_skips_unconfigured_channels(monkeypatch, settings):
discordSend = MagicMock()
ntfySend = MagicMock()
monkeypatch.setattr(notifications.discord, "send", discordSend)
monkeypatch.setattr(notifications.ntfy, "send", ntfySend)
assert notifications._sendToEnabledChannels(settings, "hello") is False
discordSend.assert_not_called()
ntfySend.assert_not_called()

275
tests/unit/test_parser.py Normal file
View File

@@ -0,0 +1,275 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from ai import parser
from core.registry import FrameworkRegistry
def _response(content=None, reasoning=None, choices=True):
message = SimpleNamespace(content=content, reasoning=reasoning)
return SimpleNamespace(choices=[SimpleNamespace(message=message)] if choices else [])
def test_template_rendering_preserves_literal_json_braces():
template = (
'Schema: {"action":"create","payload":{"items":[1, 2]}}\n'
"Input: {user_input}\nUnknown: {leave_this_alone}"
)
rendered = parser._render_template(template, {"user_input": "remind me"})
assert '{"action":"create","payload":{"items":[1, 2]}}' in rendered
assert "Input: remind me" in rendered
assert "Unknown: {leave_this_alone}" in rendered
def test_json_extraction_handles_fenced_text_and_nested_values():
response = """```json
Here is the structured result:
{"action":"create","payload":{"items":[{"id":1},{"id":2}]}}
```"""
assert parser._extract_json_from_text(response) == {
"action": "create",
"payload": {"items": [{"id": 1}, {"id": 2}]},
}
def test_parse_retries_invalid_json_and_validator_failures(monkeypatch):
responses = iter(
[
"not JSON",
'{"valid":false}',
'```json\n{"valid":true,"nested":{"value":7}}\n```',
]
)
prompts = []
def fake_llm(_system_prompt, user_prompt):
prompts.append(user_prompt)
return next(responses)
prompt = {
"system": "Return an object",
"user_template": (
'Literal schema: {"valid":true}\nUser message: {user_input}'
),
}
validator = lambda value: [] if value.get("valid") else ["valid must be true"]
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 3)
monkeypatch.setattr(parser, "_call_llm", fake_llm)
result = parser.parse(
"test input",
"focused",
prompt_override=prompt,
validator=validator,
)
assert result == {"valid": True, "nested": {"value": 7}}
assert len(prompts) == 3
assert 'Literal schema: {"valid":true}' in prompts[0]
assert "Response was not valid JSON" in prompts[1]
assert "valid must be true" in prompts[2]
@pytest.mark.asyncio
async def test_command_parser_routes_then_uses_focused_module_prompt(monkeypatch):
target = FrameworkRegistry()
target.begin_module("reminders", "modules.reminders")
target.register_command(
"reminder",
lambda _context, _parsed: None,
{
"system": "Focused reminder parser",
"user_template": (
'Timezone: {timezone}\nMessage: {user_input}\n'
'Schema: {"action":"create"}'
),
},
validator=lambda value: (
[] if value.get("action") == "create" else ["invalid action"]
),
description="Create and manage reminders",
)
target.finish_module()
llm = AsyncMock(
side_effect=[
'{"interaction_type":"reminder","confidence":0.99}',
(
'```json\n{"action":"create","message":"call home",'
'"payload":{"source":"dm"}}\n```'
),
]
)
monkeypatch.setattr(parser, "_call_llm_async", llm)
result = await parser.parse_command_async(
"remind me to call home",
target,
history=[("hello", {"interaction_type": "reminder"})],
timezone_name="America/Chicago",
)
assert result == {
"action": "create",
"message": "call home",
"payload": {"source": "dm"},
"interaction_type": "reminder",
}
assert llm.await_count == 2
route_system, route_user = llm.await_args_list[0].args
focused_system, focused_user = llm.await_args_list[1].args
assert route_system == parser.AI_CONFIG["prompts"]["command_parser"]["system"]
assert "- reminder: Create and manage reminders" in route_user
assert focused_system == "Focused reminder parser"
assert "Timezone: America/Chicago" in focused_user
assert 'Schema: {"action":"create"}' in focused_user
def test_clients_are_created_lazily_and_cached(monkeypatch):
syncClient = SimpleNamespace()
asyncClient = SimpleNamespace()
syncFactory = MagicMock(return_value=syncClient)
asyncFactory = MagicMock(return_value=asyncClient)
monkeypatch.setattr(parser, "OpenAI", syncFactory)
monkeypatch.setattr(parser, "AsyncOpenAI", asyncFactory)
monkeypatch.setattr(parser, "_sync_client", None)
monkeypatch.setattr(parser, "_async_client", None)
monkeypatch.setenv("OPENROUTER_API_KEY", "provider-key")
assert parser._get_client() is syncClient
assert parser._get_client() is syncClient
assert parser._get_client(async_client=True) is asyncClient
assert parser._get_client(async_client=True) is asyncClient
assert syncFactory.call_count == 1
assert asyncFactory.call_count == 1
def test_response_text_and_request_arguments(monkeypatch):
assert parser._response_text(_response(" result ")) == "result"
assert parser._response_text(_response(reasoning=" reason ")) == "reason"
assert parser._response_text(_response(choices=False)) is None
monkeypatch.setitem(parser.AI_CONFIG, "json_mode", True)
arguments = parser._request_args("system", "user")
assert arguments["messages"] == [
{"role": "system", "content": "system"},
{"role": "user", "content": "user"},
]
assert arguments["response_format"] == {"type": "json_object"}
def test_sync_llm_call_returns_content_and_absorbs_provider_failure(monkeypatch):
completions = MagicMock()
completions.create.side_effect = [
_response('{"ok":true}'),
RuntimeError("provider unavailable"),
]
client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
monkeypatch.setattr(parser, "_get_client", lambda **_kwargs: client)
assert parser._call_llm("system", "user") == '{"ok":true}'
assert parser._call_llm("system", "user") is None
@pytest.mark.asyncio
async def test_async_llm_call_returns_content_and_absorbs_failure(monkeypatch):
create = AsyncMock(
side_effect=[_response('{"ok":true}'), RuntimeError("offline")]
)
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create))
)
monkeypatch.setattr(parser, "_get_client", lambda **_kwargs: client)
assert await parser._call_llm_async("system", "user") == '{"ok":true}'
assert await parser._call_llm_async("system", "user") is None
def test_parse_reports_unknown_unavailable_and_exhausted_results(monkeypatch):
assert parser.parse("hello", "missing") == {
"error": "Unknown interaction type: missing"
}
prompt = {"system": "system", "user_template": "{user_input}"}
monkeypatch.setattr(parser, "_call_llm", lambda *_args: None)
assert parser.parse("hello", "test", prompt_override=prompt) == {
"error": "AI service unavailable",
"user_input": "hello",
}
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 2)
monkeypatch.setattr(parser, "_call_llm", lambda *_args: "not json")
exhausted = parser.parse("hello", "test", prompt_override=prompt)
assert exhausted["error"] == "Failed to parse after 2 attempts"
assert exhausted["validation_errors"] == ["Response was not valid JSON"]
@pytest.mark.asyncio
async def test_async_parse_unknown_unavailable_and_exhausted(monkeypatch):
assert await parser.parse_async("hello", "missing") == {
"error": "Unknown interaction type: missing"
}
prompt = {"system": "system", "user_template": "{user_input}"}
call = AsyncMock(return_value=None)
monkeypatch.setattr(parser, "_call_llm_async", call)
assert await parser.parse_async("hello", "test", prompt_override=prompt) == {
"error": "AI service unavailable",
"user_input": "hello",
}
monkeypatch.setitem(parser.AI_CONFIG["validation"], "max_retries", 1)
call.return_value = "[]"
exhausted = await parser.parse_async(
"hello", "test", prompt_override=prompt
)
assert exhausted["validation_errors"] == [
"Response must be a JSON object"
]
@pytest.mark.asyncio
async def test_command_router_handles_early_and_ambiguous_results(monkeypatch):
target = FrameworkRegistry()
target.begin_module("one", "modules.one")
target.register_command(
"one",
lambda *_args: None,
{"system": "system", "user_template": "{user_input}"},
)
target.finish_module()
call = AsyncMock(return_value={"error": "offline"})
monkeypatch.setattr(parser, "parse_async", call)
assert await parser.parse_command_async("hello", target) == {
"error": "offline"
}
call.return_value = {
"needs_clarification": "which one?",
"confidence": 0.95,
}
assert await parser.parse_command_async("hello", target) == {
"needs_clarification": "which one?",
"confidence": 0.95,
}
call.return_value = {"interaction_type": "one", "confidence": 0.2}
ambiguous = await parser.parse_command_async("hello", target)
assert "needs_clarification" in ambiguous
call.return_value = {"interaction_type": "missing", "confidence": 1.0}
assert await parser.parse_command_async("hello", target) == {
"error": "Unknown command type: missing"
}
def test_registered_validator_is_used(monkeypatch):
prompt = {"system": "system", "user_template": "{user_input}"}
parser.register_validator("registered", lambda value: [] if value["ok"] else ["bad"])
monkeypatch.setattr(parser, "_call_llm", lambda *_args: '{"ok":true}')
assert parser.parse("hello", "registered", prompt_override=prompt) == {"ok": True}

View File

@@ -0,0 +1,78 @@
from contextlib import contextmanager
from unittest.mock import MagicMock
import pytest
from core import postgres
def test_safe_identifier_quotes_names_and_rejects_sql_expressions():
assert postgres._safe_id("scheduled_jobs") == '"scheduled_jobs"'
for unsafe in ["jobs.id", "jobs; DROP TABLE jobs", "two words", "", 7, None]:
with pytest.raises(ValueError, match="Invalid SQL identifier"):
postgres._safe_id(unsafe)
def test_order_clause_allows_only_identifiers_and_directions():
assert postgres._order_clause(
["created_at desc", ("id", "ASC")]
) == '"created_at" DESC, "id" ASC'
unsafe_values = [
"created_at DESC NULLS LAST",
"created_at;drop DESC",
[("created_at", "SIDEWAYS")],
[("created_at", "ASC", "extra")],
]
for value in unsafe_values:
with pytest.raises(ValueError):
postgres._order_clause(value)
def test_select_builds_parameterized_where_and_safe_order(monkeypatch):
cursor = MagicMock()
cursor.fetchall.return_value = [{"id": "job-1"}]
@contextmanager
def fake_cursor():
yield cursor
monkeypatch.setattr(postgres, "get_cursor", fake_cursor)
rows = postgres.select(
"scheduled_jobs",
where={"user_uuid": "user-1", "status": ("IN", ["pending", "running"])},
order_by=[("run_at", "ASC"), ("id", "DESC")],
limit=10,
)
assert rows == [{"id": "job-1"}]
query, params = cursor.execute.call_args.args
assert 'FROM "scheduled_jobs"' in query
assert '"user_uuid" = %(user_uuid_0)s' in query
assert '"status" IN (%(status_1_0)s, %(status_1_1)s)' in query
assert 'ORDER BY "run_at" ASC, "id" DESC' in query
assert "LIMIT %(query_limit)s" in query
assert params == {
"user_uuid_0": "user-1",
"status_1_0": "pending",
"status_1_1": "running",
"query_limit": 10,
}
def test_empty_update_and_delete_conditions_fail_before_opening_cursor(monkeypatch):
cursor_factory = MagicMock(
side_effect=AssertionError("a database cursor must not be opened")
)
monkeypatch.setattr(postgres, "get_cursor", cursor_factory)
with pytest.raises(ValueError, match="update data cannot be empty"):
postgres.update("users", {}, {"id": "user-1"})
with pytest.raises(ValueError, match="non-empty where"):
postgres.update("users", {"timezone": "UTC"}, {})
with pytest.raises(ValueError, match="non-empty where"):
postgres.delete("users", {})
cursor_factory.assert_not_called()

156
tests/unit/test_registry.py Normal file
View File

@@ -0,0 +1,156 @@
from types import SimpleNamespace
import pytest
import core.registry as registry_module
from core.registry import FrameworkRegistry
PROMPT = {"system": "Return JSON", "user_template": "Message: {user_input}"}
@pytest.fixture(autouse=True)
def clean_global_registry():
registry_module.reset_registry()
yield
registry_module.reset_registry()
def test_discovery_is_alphabetical_ignores_private_packages_and_runs_once(monkeypatch):
loaded = []
def feature(name):
def register(target):
loaded.append(name)
target.describe(f"{name} feature")
target.register_command(
name,
lambda _context, _parsed: None,
PROMPT,
description=f"Handle {name}",
help_text=[f"use {name}"],
)
return SimpleNamespace(register=register)
fake_modules = {
"modules.alpha": feature("alpha"),
"modules.zeta": feature("zeta"),
}
def fake_import(name):
if name == "modules":
return SimpleNamespace(__path__=["unused"])
return fake_modules[name]
discovered = [
SimpleNamespace(name="zeta", ispkg=True),
SimpleNamespace(name="_private", ispkg=True),
SimpleNamespace(name="single_file", ispkg=False),
SimpleNamespace(name="alpha", ispkg=True),
]
monkeypatch.setattr(registry_module.importlib, "import_module", fake_import)
monkeypatch.setattr(
registry_module.pkgutil, "iter_modules", lambda _path: discovered
)
result = registry_module.discover_modules()
assert loaded == ["alpha", "zeta"]
assert list(result.modules) == ["alpha", "zeta"]
assert result.list_commands() == ["alpha", "zeta"]
assert registry_module.discover_modules() is result
assert loaded == ["alpha", "zeta"]
def test_duplicate_names_and_malformed_registrations_are_rejected():
target = FrameworkRegistry()
handler = lambda _context, _parsed: None
target.begin_module("first", "modules.first")
target.register_command("shared", handler, PROMPT)
target.register_job("shared.job", handler)
target.finish_module()
with pytest.raises(ValueError, match="Duplicate module name: first"):
target.begin_module("first", "modules.again")
target.begin_module("second", "modules.second")
with pytest.raises(ValueError, match="Duplicate command type: shared"):
target.register_command("shared", handler, PROMPT)
with pytest.raises(ValueError, match="Duplicate job type: shared.job"):
target.register_job("shared.job", handler)
with pytest.raises(TypeError, match="must be callable"):
target.register_command("not_callable", None, PROMPT)
with pytest.raises(TypeError, match="Validator .* must be callable"):
target.register_command("bad_validator", handler, PROMPT, validator="bad")
with pytest.raises(ValueError, match="system and user_template"):
target.register_command("bad_prompt", handler, {"system": "only one"})
target.finish_module()
with pytest.raises(RuntimeError, match="inside a module register"):
target.describe("orphan metadata")
def test_help_and_router_context_are_generated_from_sorted_metadata():
target = FrameworkRegistry()
handler = lambda _context, _parsed: None
target.begin_module("examples", "modules.examples")
target.register_command(
"zeta",
handler,
PROMPT,
description="Last command",
)
target.register_command(
"alpha",
handler,
PROMPT,
description="First command",
help_text=["say alpha", "ask alpha for help"],
)
target.finish_module()
assert target.router_context() == (
"- alpha: First command\n- zeta: Last command"
)
assert target.help_lines() == [
"- say alpha",
"- ask alpha for help",
"- zeta: Last command",
]
def test_failed_forced_discovery_clears_partial_state_and_can_retry(monkeypatch):
broken = SimpleNamespace(register=lambda target: target.describe("partial"))
monkeypatch.setattr(
registry_module.importlib,
"import_module",
lambda name: (
SimpleNamespace(__path__=["unused"]) if name == "modules" else broken
),
)
monkeypatch.setattr(
registry_module.pkgutil,
"iter_modules",
lambda _path: [SimpleNamespace(name="broken", ispkg=True)],
)
registry_module.discover_modules()
assert registry_module.registry.modules == {
"broken": {
"name": "broken",
"package": "modules.broken",
"description": "partial",
}
}
del broken.register
with pytest.raises(RuntimeError, match="must expose register"):
registry_module.discover_modules(force=True)
assert registry_module.registry.modules == {}
broken.register = lambda target: target.describe("recovered")
assert registry_module.discover_modules().modules["broken"][
"description"
] == "recovered"

View File

@@ -0,0 +1,220 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from modules.reminders.commands import _errorMessage, handleReminder, validateReminder
from modules.reminders.service import _nextRun, normalizeRecurrence
@pytest.mark.parametrize(
"command",
[
{"action": "list"},
{"action": "cancel", "reminder_id": "reminder-1"},
{"action": "set_timezone", "timezone": "America/Chicago"},
{
"action": "create",
"message": "call home",
"run_at": "2999-03-08T09:00:00-05:00",
"recurrence": {"frequency": "weekly", "interval": 2},
},
{"needs_clarification": "What time should I use?"},
],
)
def test_reminder_validator_accepts_supported_commands(command):
assert validateReminder(command) == []
def test_reminder_validator_reports_all_invalid_create_fields():
errors = validateReminder(
{
"action": "create",
"message": " ",
"run_at": "2026-03-08T09:00:00",
"recurrence": {"frequency": "hourly"},
}
)
assert "create requires a reminder message" in errors
assert "run_at must include a timezone offset" in errors
assert "recurrence frequency must be daily or weekly" in errors
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("once", None),
("daily", {"frequency": "daily", "interval": 1}),
(
{"frequency": "WEEKLY", "interval": "3"},
{"frequency": "weekly", "interval": 3},
),
],
)
def test_recurrence_normalization(value, expected):
assert normalizeRecurrence(value) == expected
@pytest.mark.parametrize(
"value",
[
"hourly",
{"frequency": "daily", "interval": 0},
{"frequency": "weekly", "interval": 366},
{"frequency": "daily", "interval": "many"},
],
)
def test_recurrence_normalization_rejects_invalid_values(value):
with pytest.raises(ValueError):
normalizeRecurrence(value)
@pytest.mark.parametrize(
("scheduled_for", "expected"),
[
# America/Chicago enters daylight time on March 8, 2026.
(
datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc),
datetime(2026, 3, 8, 14, 0, tzinfo=timezone.utc),
),
# It returns to standard time on November 1, 2026.
(
datetime(2026, 10, 31, 14, 0, tzinfo=timezone.utc),
datetime(2026, 11, 1, 15, 0, tzinfo=timezone.utc),
),
],
)
def test_next_run_preserves_local_wall_clock_across_dst(scheduled_for, expected):
result = _nextRun(
scheduled_for,
{"frequency": "daily", "interval": 1},
"America/Chicago",
now=scheduled_for,
)
assert result == expected
assert result.astimezone(__import__("zoneinfo").ZoneInfo("America/Chicago")).hour == 9
def test_next_run_skips_missed_intervals_after_downtime():
scheduled_for = datetime(2026, 3, 7, 15, 0, tzinfo=timezone.utc)
result = _nextRun(
scheduled_for,
{"frequency": "daily", "interval": 1},
"America/Chicago",
now=datetime(2026, 3, 9, 14, 1, tzinfo=timezone.utc),
)
assert result == datetime(2026, 3, 10, 14, 0, tzinfo=timezone.utc)
def _context(response, status):
api = SimpleNamespace(
request=AsyncMock(return_value=(response, status)),
timezone="UTC",
)
return SimpleNamespace(api=api, timezone="UTC", reply=AsyncMock())
@pytest.mark.asyncio
async def test_create_handler_reports_success_and_api_error():
context = _context(
{
"message": "call home",
"next_run_at": "2099-01-01T12:00:00+00:00",
"recurrence": {"frequency": "daily"},
},
201,
)
parsed = {
"action": "create",
"message": "call home",
"run_at": "2099-01-01T12:00:00Z",
"recurrence": {"frequency": "daily"},
}
await handleReminder(context, parsed)
assert "recurring" in context.reply.await_args.args[0]
context.api.request.assert_awaited_once_with(
"post",
"/api/reminders",
{
"message": "call home",
"run_at": "2099-01-01T12:00:00Z",
"recurrence": {"frequency": "daily"},
},
)
context = _context({"error": "database unavailable"}, 503)
await handleReminder(context, parsed)
assert "database unavailable" in context.reply.await_args.args[0]
@pytest.mark.asyncio
async def test_list_handler_formats_results_and_empty_state():
context = _context(
{
"reminders": [
{
"id": "reminder-one",
"next_run_at": "2099-01-01T12:00:00+00:00",
"message": "call home",
}
]
},
200,
)
await handleReminder(context, {"action": "list"})
reply = context.reply.await_args.args[0]
assert "Active reminders" in reply and "reminder-one" in reply
context = _context({"reminders": []}, 200)
await handleReminder(context, {"action": "list"})
assert context.reply.await_args.args[0] == "You have no active reminders."
context = _context({"error": "offline"}, 503)
await handleReminder(context, {"action": "list"})
assert "offline" in context.reply.await_args.args[0]
@pytest.mark.asyncio
async def test_cancel_and_timezone_handlers_update_context():
context = _context({"message": "call home"}, 200)
await handleReminder(
context,
{"action": "cancel", "reminder_id": "reminder-one"},
)
assert context.reply.await_args.args[0] == "Cancelled reminder: call home"
context.api.request.assert_awaited_once_with(
"delete", "/api/reminders/reminder-one"
)
context = _context({"error": "not found"}, 404)
await handleReminder(
context,
{"action": "cancel", "reminder_id": "missing"},
)
assert "not found" in context.reply.await_args.args[0]
context = _context({"timezone": "America/Chicago"}, 200)
await handleReminder(
context,
{"action": "set_timezone", "timezone": "America/Chicago"},
)
assert context.timezone == "America/Chicago"
assert context.api.timezone == "America/Chicago"
context = _context({}, 400)
await handleReminder(
context,
{"action": "set_timezone", "timezone": "bad"},
)
assert "couldn't update" in context.reply.await_args.args[0]
def test_error_message_handles_structured_and_unstructured_results():
assert _errorMessage({"error": "detail"}, "fallback") == "fallback detail"
assert _errorMessage("not an object", "fallback") == "fallback"

View File

@@ -0,0 +1,382 @@
"""Unit tests for user, token, API-key, and provider identity boundaries."""
from contextlib import contextmanager
import datetime
import json
from unittest.mock import MagicMock
import uuid
import pytest
from psycopg2.errors import UniqueViolation
from core import api_keys as apiKeys
from core import auth, identity, users
USER_ID = "00000000-0000-0000-0000-000000000101"
KEY_ID = "00000000-0000-0000-0000-000000000201"
class FakeCursor:
def __init__(self, rows=None):
self.rows = list(rows or [])
self.executed = []
def execute(self, query, params=None):
self.executed.append((" ".join(query.split()), params))
def fetchone(self):
return self.rows.pop(0) if self.rows else None
def _cursorContext(cursor):
@contextmanager
def context():
yield cursor
return context
def test_user_timezone_and_password_validation(monkeypatch):
assert users.isValidTimezone("America/Chicago")
assert not users.isValidTimezone("not/a-zone")
assert users.normalizeTimezone(" UTC ") == "UTC"
with pytest.raises(ValueError, match="IANA"):
users.normalizeTimezone(None)
monkeypatch.setenv("DEFAULT_TIMEZONE", "America/Chicago")
assert users.getDefaultTimezone() == "America/Chicago"
assert users.validatePassword("long-enough")[0]
assert not users.validatePassword("short")[0]
assert not users.validatePassword("é" * 40)[0]
assert users.validatePassword(None) == (False, ["password"])
def test_user_lookup_helpers_and_registration_boundaries(monkeypatch):
select = MagicMock(
side_effect=[
{"id": USER_ID, "username": "alice", "timezone": "UTC"},
{"id": USER_ID},
None,
]
)
monkeypatch.setattr(users.postgres, "select_one", select)
monkeypatch.setattr(users.postgres, "exists", MagicMock(side_effect=[False, True]))
assert users.getUser(USER_ID)["username"] == "alice"
assert users.getUserUUID("alice") == USER_ID
assert users.getUserUUID("missing") is False
assert users.isUsernameAvailable("alice")
assert users.doesUserUUIDExist(USER_ID)
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
monkeypatch.setattr(users.bcrypt, "hashpw", lambda value, _salt: b"hash:" + value)
create = MagicMock()
monkeypatch.setattr(users, "createUser", create)
assert users.registerUser(
" alice ",
"password123",
{"timezone": "UTC", "id": "attacker", "admin": True},
)
created = create.call_args.args[0]
assert created["username"] == "alice"
assert created["password_hashed"] == b"hash:password123"
assert created["timezone"] == "UTC"
assert created["id"] != "attacker" and "admin" not in created
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: False)
assert users.registerUser("alice", "password123") is False
monkeypatch.setattr(users, "isUsernameAvailable", lambda _name: True)
create.side_effect = UniqueViolation()
assert users.registerUser("alice", "password123") is False
@pytest.mark.parametrize("username", [None, " ", "x" * 256])
def test_registration_rejects_invalid_usernames(username):
with pytest.raises(ValueError, match="username"):
users.registerUser(username, "password123")
def test_user_creation_updates_password_and_delete(monkeypatch):
monkeypatch.setattr(users, "getUser", lambda _user: {"id": USER_ID})
update = MagicMock(return_value=[{"id": USER_ID}])
delete = MagicMock(return_value=[{"id": USER_ID}])
insert = MagicMock(return_value={"id": USER_ID})
monkeypatch.setattr(users.postgres, "update", update)
monkeypatch.setattr(users.postgres, "delete", delete)
monkeypatch.setattr(users.postgres, "insert", insert)
assert users.updateUser(USER_ID, {"timezone": "UTC", "username": "ignored"})
update.assert_called_with("users", {"timezone": "UTC"}, {"id": USER_ID})
assert not users.updateUser(USER_ID, {"username": "ignored"})
monkeypatch.setattr(users.bcrypt, "gensalt", lambda: b"salt")
monkeypatch.setattr(users.bcrypt, "hashpw", lambda _value, _salt: b"new-hash")
assert users.changePassword(USER_ID, "new-password")
assert users.deleteUser(USER_ID)
valid = {
"id": USER_ID,
"username": "alice",
"password_hashed": b"hash",
"timezone": "UTC",
"unexpected": True,
}
assert users.createUser(valid) == {"id": USER_ID}
assert "unexpected" not in insert.call_args.args[1]
assert users.validateUser(valid)[0]
assert not users.validateUser({"id": "bad", "timezone": "bad"})[0]
def test_login_token_creation_decoding_and_protected_claims(monkeypatch):
monkeypatch.setenv("JWT_SECRET", "test-secret")
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
monkeypatch.setattr(users, "getUserFirstName", lambda _value: "Alice")
encode = MagicMock(return_value="encoded-token")
monkeypatch.setattr(auth.jwt, "encode", encode)
assert auth.createLoginToken(
USER_ID,
expiresIn=60,
extraClaims={"provider": "discord", "sub": "attacker"},
) == "encoded-token"
payload = encode.call_args.args[0]
assert payload["sub"] == USER_ID and payload["provider"] == "discord"
assert payload["exp"] > payload["iat"]
with pytest.raises(ValueError, match="positive"):
auth.createLoginToken(USER_ID, expiresIn=0)
with pytest.raises(ValueError, match="extra claims"):
auth.createLoginToken(USER_ID, extraClaims="bad")
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": USER_ID})
assert auth.decodeLoginToken("token")["sub"] == USER_ID
monkeypatch.setattr(auth.jwt, "decode", lambda *_args, **_kwargs: {"sub": 123})
assert auth.decodeLoginToken("token") is None
monkeypatch.delenv("JWT_SECRET")
assert auth.decodeLoginToken("token") is None
with pytest.raises(RuntimeError, match="JWT_SECRET"):
auth.createLoginToken(USER_ID)
def test_password_login_and_account_removal(monkeypatch):
monkeypatch.setattr(users, "getUserUUID", lambda name: USER_ID if name == "alice" else False)
monkeypatch.setattr(auth, "getUserpasswordHash", lambda _value: b"stored")
monkeypatch.setattr(auth.bcrypt, "checkpw", lambda password, hashed: password == b"password123")
monkeypatch.setattr(auth, "createLoginToken", lambda _value: "login-token")
assert auth.getLoginToken("alice", "password123") == "login-token"
assert auth.getLoginToken("alice", "wrong-password") is False
assert auth.getLoginToken(None, "password123") is False
monkeypatch.setattr(users, "deleteUser", MagicMock(return_value=True))
assert auth.unregisterUser(USER_ID, "password123")
assert not auth.unregisterUser(USER_ID, "wrong-password")
def test_bearer_authentication_and_principal_helpers(monkeypatch):
assert auth.getBearerToken("Bearer token") == "token"
assert auth.getBearerToken("basic token") is None
assert auth.getBearerToken(None) is None
monkeypatch.setattr(auth, "decodeLoginToken", lambda _token: {"sub": USER_ID})
principal = auth.authenticateBearerToken("Bearer a.b.c")
assert principal["authentication"] == "jwt"
assert auth.isUserPrincipal(principal, USER_ID, requireLogin=True)
assert auth.authenticateBearerToken(
"Bearer a.b.c", requiredScopes=["service:scope"]
) is None
service = {
"type": "service",
"scopes": ["jobs:claim"],
"authentication": "api_key",
}
monkeypatch.setattr(apiKeys, "authenticateApiKey", lambda *_args, **_kwargs: service)
assert auth.authenticateBearerToken("Bearer service-key") == service
assert auth.authenticateBearerToken("Bearer service-key", allowService=False) is None
assert auth.hasServiceScope(service, "jobs:claim")
assert not auth.hasServiceScope(principal, "jobs:claim")
def test_api_key_normalization_public_shape_and_ids(monkeypatch):
assert apiKeys._normalizeScopes("one, two one") == ["one", "two"]
assert apiKeys._normalizeScopes(None) == []
with pytest.raises(ValueError, match="scopes"):
apiKeys._normalizeScopes(123)
with pytest.raises(ValueError, match="future"):
apiKeys._normalizeExpiry("2000-01-01T00:00:00Z")
record = {"id": KEY_ID, "key_hash": "secret", "scopes": '["one"]'}
assert apiKeys._publicKey(record, includeSecret="raw") == {
"id": KEY_ID,
"scopes": ["one"],
"key": "raw",
}
assert apiKeys._keyID(KEY_ID) == KEY_ID
assert apiKeys._keyID("bad") is None
monkeypatch.setattr(apiKeys.postgres, "select_one", MagicMock(return_value=record))
assert apiKeys.getApiKey("bad") is None
assert apiKeys.getApiKey(KEY_ID)["id"] == KEY_ID
def test_api_key_creation_and_owner_rules(monkeypatch):
monkeypatch.setattr(users, "doesUserUUIDExist", lambda value: value == USER_ID)
secret = "x" * 32
stored = {
"id": KEY_ID,
"name": "cli",
"key_type": "user",
"user_uuid": USER_ID,
"key_hash": apiKeys._hashToken(secret),
"scopes": [],
}
cursor = FakeCursor([stored])
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
created = apiKeys.createApiKey(
"cli", "user", userUUID=USER_ID, secret=secret
)
assert created["key"] == secret and "key_hash" not in created
assert cursor.executed[0][1]["key_prefix"] == secret[:20]
with pytest.raises(ValueError, match="user does not exist"):
apiKeys.createUserApiKey(str(uuid.uuid4()), "missing")
with pytest.raises(ValueError, match="cannot have a service"):
apiKeys.createApiKey(
"bad", "user", userUUID=USER_ID, serviceName="service"
)
with pytest.raises(ValueError, match="service name"):
apiKeys.createApiKey("bad", "service", scopes=[])
with pytest.raises(ValueError, match="cannot have a user"):
apiKeys.createApiKey(
"bad", "service", userUUID=USER_ID, serviceName="service"
)
def test_api_key_authentication_scope_expiry_and_revoke(monkeypatch):
secret = "service-secret-that-is-at-least-32-characters"
record = {
"id": KEY_ID,
"key_type": "service",
"user_uuid": None,
"service_name": "worker",
"key_hash": apiKeys._hashToken(secret),
"scopes": ["jobs:claim"],
"expires_at": None,
}
select = MagicMock(return_value=[record])
update = MagicMock()
monkeypatch.setattr(apiKeys.postgres, "select", select)
monkeypatch.setattr(apiKeys.postgres, "update", update)
principal = apiKeys.authenticateApiKey(secret, ["jobs:claim"])
assert principal["service_name"] == "worker"
assert update.called
assert apiKeys.authenticateApiKey("short") is None
assert apiKeys.authenticateApiKey(secret, ["jobs:deliver"]) is None
expired = dict(record, expires_at=apiKeys._utcNow() - datetime.timedelta(seconds=1))
select.return_value = [expired]
assert apiKeys.authenticateApiKey(secret) is None
select.return_value = []
assert apiKeys.authenticateApiKey(secret) is None
execute = MagicMock(return_value=[{"id": KEY_ID}])
monkeypatch.setattr(apiKeys.postgres, "execute", execute)
assert apiKeys.revokeApiKey(KEY_ID, serviceName="worker")
assert not apiKeys.revokeApiKey("invalid")
def test_service_key_bootstrap_is_idempotent_configuration(monkeypatch):
monkeypatch.delenv("BOT_API_KEY", raising=False)
assert apiKeys.bootstrapServiceApiKey() is None
secret = "configured-service-key-at-least-32-characters"
monkeypatch.setenv("BOT_API_KEY", secret)
monkeypatch.setenv("BOT_API_KEY_SCOPES", "discord:session, outbox:claim")
record = {
"id": KEY_ID,
"key_hash": apiKeys._hashToken(secret),
"scopes": json.dumps(["discord:session", "outbox:claim"]),
}
cursor = FakeCursor([record])
monkeypatch.setattr(apiKeys.postgres, "get_cursor", _cursorContext(cursor))
result = apiKeys.bootstrapServiceApiKey()
assert result["scopes"] == ["discord:session", "outbox:claim"]
assert len(cursor.executed) == 2
def test_identity_normalization_enrollment_and_lookup(monkeypatch):
assert identity._normalizeProvider(" Discord ") == "discord"
assert identity._normalizeProviderUserID(123) == "123"
assert identity._normalizeDisplayName(" Alice ") == "Alice"
with pytest.raises(ValueError, match="provider"):
identity._normalizeProvider("bad provider")
with pytest.raises(ValueError, match="provider user ID"):
identity._normalizeProviderUserID("")
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "allowlist")
monkeypatch.setenv("DISCORD_ALLOWLIST", "123, 456\n789")
assert identity.getDiscordAllowlist() == {"123", "456", "789"}
assert identity.isDiscordEnrollmentAllowed("456")
assert not identity.isDiscordEnrollmentAllowed("000")
monkeypatch.setenv("DISCORD_ENROLLMENT_MODE", "open")
assert identity.isDiscordEnrollmentAllowed("000")
monkeypatch.setattr(
identity.postgres,
"select_one",
MagicMock(return_value={"user_uuid": USER_ID}),
)
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
assert identity.getDiscordUser("123") == {"id": USER_ID}
def test_link_provider_identity_existing_and_new_paths(monkeypatch):
monkeypatch.setattr(users, "doesUserUUIDExist", lambda _value: True)
existing = {
"id": "identity-one",
"user_uuid": USER_ID,
"display_name": "Old",
}
monkeypatch.setattr(identity, "getProviderIdentity", MagicMock(return_value=existing))
update = MagicMock(return_value=[dict(existing, display_name="New")])
monkeypatch.setattr(identity.postgres, "update", update)
assert identity.linkProviderIdentity(
USER_ID, "discord", "123", displayName="New"
)["display_name"] == "New"
identity.getProviderIdentity.return_value = dict(existing, user_uuid=str(uuid.uuid4()))
with pytest.raises(ValueError, match="another user"):
identity.linkProviderIdentity(USER_ID, "discord", "123")
identity.getProviderIdentity.return_value = None
inserted = {"id": "identity-two", "user_uuid": USER_ID}
monkeypatch.setattr(identity.postgres, "insert", MagicMock(return_value=inserted))
assert identity.linkDiscordIdentity(USER_ID, "456") == inserted
monkeypatch.setattr(identity.postgres, "delete", MagicMock(return_value=[inserted]))
assert identity.unlinkProviderIdentity(USER_ID, "discord", "456")
def test_get_or_create_discord_user_existing_denied_and_created(monkeypatch):
monkeypatch.setattr(users, "getDefaultTimezone", lambda: "UTC")
existingIdentity = {
"id": "identity-one",
"user_uuid": USER_ID,
"display_name": "Old",
}
monkeypatch.setattr(
identity, "getProviderIdentity", MagicMock(return_value=existingIdentity)
)
monkeypatch.setattr(users, "getUser", MagicMock(return_value={"id": USER_ID}))
update = MagicMock()
monkeypatch.setattr(identity.postgres, "update", update)
assert identity.getOrCreateDiscordUser("123", "New") == {"id": USER_ID}
assert update.called
identity.getProviderIdentity.return_value = None
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: False)
assert identity.getOrCreateDiscordUser("999") is None
monkeypatch.setattr(identity, "isDiscordEnrollmentAllowed", lambda _value: True)
cursor = FakeCursor([None, {"id": USER_ID, "timezone": "UTC"}])
monkeypatch.setattr(identity.postgres, "get_cursor", _cursorContext(cursor))
created = identity.getOrCreateDiscordUser("999", "New")
assert created["id"] == USER_ID
assert len(cursor.executed) == 4