refactor(backend): update API routes and service layer
- Update agent router/service/repository with new endpoints - Update auth routes with phone-based authentication - Update users service with new phone lookup - Update schedule_items with new schemas - Update message schemas with visibility support - Update settings with new automation scheduler config - Update CLI with new commands - Update tests to match new API contracts
This commit is contained in:
@@ -8,13 +8,9 @@ from fastapi import HTTPException
|
||||
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
from v1.auth.schemas import (
|
||||
PasswordResetConfirmRequest,
|
||||
PasswordResetRequest,
|
||||
SessionCreateRequest,
|
||||
OtpSendRequest,
|
||||
PhoneSessionCreateRequest,
|
||||
SessionRefreshRequest,
|
||||
VerificationCreateRequest,
|
||||
VerificationVerifyRequest,
|
||||
VerificationResendRequest,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,314 +31,83 @@ class TestSupabaseAuthGateway:
|
||||
return SupabaseAuthGateway(), mock_client, mock_admin_client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_calls_email_with_string(
|
||||
async def test_send_otp_sets_should_create_user(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
mock_reset_email = MagicMock()
|
||||
mock_client.auth.reset_password_email = mock_reset_email
|
||||
mock_sign_in_with_otp = MagicMock()
|
||||
mock_client.auth.sign_in_with_otp = mock_sign_in_with_otp
|
||||
|
||||
request = PasswordResetRequest(email="test@example.com")
|
||||
await sut.request_password_reset(request)
|
||||
await sut.send_otp(OtpSendRequest(phone="+8613812345678"))
|
||||
|
||||
mock_reset_email.assert_called_once_with("test@example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_verification_maps_timeout_error_to_503(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_client.auth.sign_up = MagicMock(
|
||||
side_effect=AuthError("request_timeout", None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.create_verification(
|
||||
VerificationCreateRequest(
|
||||
username="tester",
|
||||
email="test@example.com",
|
||||
password="secret123",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Auth service temporarily unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_with_redirect(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
mock_reset_email = MagicMock()
|
||||
mock_client.auth.reset_password_email = mock_reset_email
|
||||
|
||||
request = PasswordResetRequest(
|
||||
email="test@example.com",
|
||||
redirect_to="http://localhost:3000/reset-password",
|
||||
)
|
||||
await sut.request_password_reset(request)
|
||||
|
||||
mock_reset_email.assert_called_once_with(
|
||||
"test@example.com",
|
||||
options={"redirect_to": "http://localhost:3000/reset-password"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_verification_rejects_untrusted_redirect_url(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, _, _ = gateway
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.create_verification(
|
||||
VerificationCreateRequest(
|
||||
username="tester",
|
||||
email="test@example.com",
|
||||
password="secret123",
|
||||
redirect_to="https://evil.example.com/callback",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Invalid redirect URL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_rejects_untrusted_redirect_url(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, _, _ = gateway
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.request_password_reset(
|
||||
PasswordResetRequest(
|
||||
email="test@example.com",
|
||||
redirect_to="https://evil.example.com/reset",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Invalid redirect URL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_swallows_auth_error(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_reset_email = MagicMock(side_effect=AuthError("rate limit exceeded", None))
|
||||
mock_client.auth.reset_password_email = mock_reset_email
|
||||
|
||||
request = PasswordResetRequest(email="test@example.com")
|
||||
|
||||
result = await sut.request_password_reset(request)
|
||||
|
||||
mock_reset_email.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_extracts_email_from_mapping(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
mock_reset_email = MagicMock()
|
||||
mock_client.auth.reset_password_email = mock_reset_email
|
||||
|
||||
request = PasswordResetRequest.model_construct(
|
||||
email={"email": "test@example.com"},
|
||||
redirect_to=None,
|
||||
)
|
||||
|
||||
await sut.request_password_reset(request)
|
||||
|
||||
mock_reset_email.assert_called_once_with("test@example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_password_reset_rejects_invalid_email_shape(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, _, _ = gateway
|
||||
request = PasswordResetRequest.model_construct(
|
||||
email={"unexpected": "value"},
|
||||
redirect_to=None,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.request_password_reset(request)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Invalid email"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_password_reset_updates_password_by_user_id(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, mock_admin_client = gateway
|
||||
verify_response = SimpleNamespace(
|
||||
session=SimpleNamespace(access_token="access"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
mock_verify_otp = MagicMock(return_value=verify_response)
|
||||
mock_client.auth.verify_otp = mock_verify_otp
|
||||
|
||||
mock_update_user_by_id = MagicMock()
|
||||
mock_admin_client.auth.admin = SimpleNamespace(
|
||||
update_user_by_id=mock_update_user_by_id
|
||||
)
|
||||
|
||||
request = PasswordResetConfirmRequest(
|
||||
email="test@example.com",
|
||||
token="123456",
|
||||
new_password="newpassword123",
|
||||
)
|
||||
|
||||
await sut.confirm_password_reset(request)
|
||||
|
||||
mock_verify_otp.assert_called_once_with(
|
||||
mock_sign_in_with_otp.assert_called_once_with(
|
||||
{
|
||||
"type": "recovery",
|
||||
"email": "test@example.com",
|
||||
"token": "123456",
|
||||
"phone": "+8613812345678",
|
||||
"options": {"should_create_user": True},
|
||||
}
|
||||
)
|
||||
mock_update_user_by_id.assert_called_once_with(
|
||||
"user-1",
|
||||
{"password": "newpassword123"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_password_reset_raises_when_user_id_missing(
|
||||
async def test_create_phone_session_uses_verify_otp(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
verify_response = SimpleNamespace(
|
||||
session=SimpleNamespace(access_token="access"),
|
||||
user=SimpleNamespace(id=""),
|
||||
session=SimpleNamespace(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
expires_in=3600,
|
||||
token_type="bearer",
|
||||
),
|
||||
user=SimpleNamespace(id="user-1", phone="+8613812345678"),
|
||||
)
|
||||
mock_client.auth.verify_otp = MagicMock(return_value=verify_response)
|
||||
|
||||
request = PasswordResetConfirmRequest(
|
||||
email="test@example.com",
|
||||
token="123456",
|
||||
new_password="newpassword123",
|
||||
response = await sut.create_phone_session(
|
||||
PhoneSessionCreateRequest(phone="+8613812345678", token="123456")
|
||||
)
|
||||
|
||||
assert response.user.id == "user-1"
|
||||
assert response.access_token == "access"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_phone_session_normalizes_phone_without_plus_prefix(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
verify_response = SimpleNamespace(
|
||||
session=SimpleNamespace(
|
||||
access_token="access",
|
||||
refresh_token="refresh",
|
||||
expires_in=3600,
|
||||
token_type="bearer",
|
||||
),
|
||||
user=SimpleNamespace(id="user-1", phone="14155552671"),
|
||||
)
|
||||
mock_client.auth.verify_otp = MagicMock(return_value=verify_response)
|
||||
|
||||
response = await sut.create_phone_session(
|
||||
PhoneSessionCreateRequest(phone="+14155552671", token="123456")
|
||||
)
|
||||
|
||||
assert response.user.phone == "+14155552671"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_session_maps_invalid_token(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
mock_client.auth.refresh_session = MagicMock(
|
||||
return_value=SimpleNamespace(session=None, user=None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.confirm_password_reset(request)
|
||||
await sut.refresh_session(SessionRefreshRequest(refresh_token="bad"))
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Invalid or expired verification code"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_resend_calls_reset_password_email(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
mock_reset_email = MagicMock()
|
||||
mock_client.auth.reset_password_email = mock_reset_email
|
||||
|
||||
await sut.resend_verification(
|
||||
VerificationResendRequest(
|
||||
type="recovery",
|
||||
email="test@example.com",
|
||||
redirect_to="http://localhost:3000/reset-password",
|
||||
)
|
||||
)
|
||||
|
||||
mock_reset_email.assert_called_once_with(
|
||||
"test@example.com",
|
||||
options={"redirect_to": "http://localhost:3000/reset-password"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_verification_maps_internal_error_to_503(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_client.auth.verify_otp = MagicMock(
|
||||
side_effect=AuthError("internal_server_error", None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.verify_verification(
|
||||
VerificationVerifyRequest(
|
||||
type="signup",
|
||||
email="test@example.com",
|
||||
token="123456",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Auth service temporarily unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_maps_internal_error_to_503(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_client.auth.sign_in_with_password = MagicMock(
|
||||
side_effect=AuthError("internal_server_error", None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.create_session(
|
||||
SessionCreateRequest(
|
||||
email="test@example.com",
|
||||
password="secret123",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Auth service temporarily unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_session_maps_bad_gateway_to_503(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_client.auth.refresh_session = MagicMock(
|
||||
side_effect=AuthError("bad_gateway", None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.refresh_session(SessionRefreshRequest(refresh_token="rt"))
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Auth service temporarily unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_password_reset_maps_service_unavailable_to_503(
|
||||
self, gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock]
|
||||
) -> None:
|
||||
sut, mock_client, _ = gateway
|
||||
from supabase import AuthError
|
||||
|
||||
mock_client.auth.verify_otp = MagicMock(
|
||||
side_effect=AuthError("service_unavailable", None)
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await sut.confirm_password_reset(
|
||||
PasswordResetConfirmRequest(
|
||||
email="test@example.com",
|
||||
token="123456",
|
||||
new_password="newpassword123",
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Auth service temporarily unavailable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_email_uses_in_memory_cache(
|
||||
async def test_get_user_by_phone_uses_in_memory_cache(
|
||||
self,
|
||||
gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -350,9 +115,9 @@ class TestSupabaseAuthGateway:
|
||||
sut, _, _ = gateway
|
||||
user = SimpleNamespace(
|
||||
id="user-1",
|
||||
email="cached@example.com",
|
||||
phone="+8613811112222",
|
||||
created_at="2026-03-16T00:00:00Z",
|
||||
email_confirmed_at=None,
|
||||
phone_confirmed_at=None,
|
||||
)
|
||||
list_calls = {"count": 0}
|
||||
|
||||
@@ -362,9 +127,39 @@ class TestSupabaseAuthGateway:
|
||||
|
||||
monkeypatch.setattr("v1.auth.gateway._list_auth_users", _fake_list_auth_users)
|
||||
|
||||
first = await sut.get_user_by_email("cached@example.com")
|
||||
second = await sut.get_user_by_email("CACHED@example.com")
|
||||
first = await sut.get_user_by_phone("+8613811112222")
|
||||
second = await sut.get_user_by_phone("+8613811112222")
|
||||
|
||||
assert first.id == "user-1"
|
||||
assert second.email == "cached@example.com"
|
||||
assert second.phone == "+8613811112222"
|
||||
assert list_calls["count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_user_ids_by_phone_supports_suffix_query(
|
||||
self,
|
||||
gateway: tuple[SupabaseAuthGateway, MagicMock, MagicMock],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sut, _, _ = gateway
|
||||
users = [
|
||||
SimpleNamespace(
|
||||
id="user-cn",
|
||||
phone="+8613811112222",
|
||||
created_at="2026-03-16T00:00:00Z",
|
||||
phone_confirmed_at=None,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="user-us",
|
||||
phone="+14155552671",
|
||||
created_at="2026-03-16T00:00:00Z",
|
||||
phone_confirmed_at=None,
|
||||
),
|
||||
]
|
||||
|
||||
monkeypatch.setattr("v1.auth.gateway._list_auth_users", lambda _client: users)
|
||||
|
||||
matched_cn = await sut.search_user_ids_by_phone("13811112222")
|
||||
matched_us = await sut.search_user_ids_by_phone("4155552671")
|
||||
|
||||
assert matched_cn == ["user-cn"]
|
||||
assert matched_us == ["user-us"]
|
||||
|
||||
Reference in New Issue
Block a user