Build reusable bot framework
This commit is contained in:
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""PostgreSQL-backed integration tests."""
|
||||
174
tests/integration/conftest.py
Normal file
174
tests/integration/conftest.py
Normal 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
|
||||
264
tests/integration/test_api.py
Normal file
264
tests/integration/test_api.py
Normal 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"
|
||||
122
tests/integration/test_leases.py
Normal file
122
tests/integration/test_leases.py
Normal 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
|
||||
70
tests/integration/test_migrations.py
Normal file
70
tests/integration/test_migrations.py
Normal 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()
|
||||
107
tests/integration/test_reminders.py
Normal file
107
tests/integration/test_reminders.py
Normal 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
|
||||
Reference in New Issue
Block a user