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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions oltinpay/oltinpay-api/src/users/router.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Users router."""

from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query

from src.auth.dependencies import CurrentUser, DbSession
from src.common.exceptions import ConflictException
from src.auth.dependencies import CurrentUser, DbSession, get_current_user
from src.common.exceptions import ConflictException, NotFoundException
from src.users import service
from src.users.schemas import (
OltinIdCreate,
UserLookupResult,
UserResponse,
UserSearchResult,
UserUpdate,
Expand Down Expand Up @@ -65,6 +66,29 @@ async def search_users(
return [UserSearchResult.model_validate(u) for u in users]


@router.get(
"/lookup",
response_model=UserLookupResult,
dependencies=[Depends(get_current_user)],
)
async def lookup_user(
db: DbSession,
oltin_id: str = Query(..., min_length=1, max_length=32),
) -> UserLookupResult:
"""Resolve a recipient oltin_id to their wallet address for a client-signed
P2P transfer.

404 if no such user. ``wallet_address`` may be null (the recipient never
completed wallet onboarding) — the client blocks the send in that case.
Auth is required (route-level dependency) so addresses are not enumerable
anonymously; the resolved user is not otherwise used here.
"""
user = await service.get_user_by_oltin_id(db, oltin_id)
if user is None:
raise NotFoundException("User not found")
return UserLookupResult.model_validate(user)


@router.post("/wallet", response_model=UserResponse)
async def register_wallet(
data: WalletRegister,
Expand Down
13 changes: 13 additions & 0 deletions oltinpay/oltinpay-api/src/users/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,16 @@ class UserSearchResult(BaseModel):

oltin_id: str
telegram_id: int


class UserLookupResult(BaseModel):
"""Resolve a recipient oltin_id to their on-chain wallet address.

``wallet_address`` is null when the recipient never onboarded a wallet —
the client blocks the send in that case.
"""

model_config = ConfigDict(from_attributes=True)

oltin_id: str
wallet_address: str | None = None
88 changes: 88 additions & 0 deletions oltinpay/oltinpay-api/tests/test_users_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Integration tests for GET /api/v1/users/lookup.

Resolves a recipient ``oltin_id`` to their on-chain ``wallet_address`` so the
Mini App can sign a real P2P OLTIN transfer to the right address. Reuses the
test_user / second_user fixtures + in-memory SQLite from conftest.py.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import pytest

if TYPE_CHECKING:
from httpx import AsyncClient

ENDPOINT = "/api/v1/users/lookup"
WALLET_ENDPOINT = "/api/v1/users/wallet"
VALID_ADDRESS = "0xA0A78aA9B9619fbc3bC12b5756442BD7A7D6779e"


@pytest.mark.usefixtures("second_user")
@pytest.mark.asyncio
async def test_lookup_found_without_wallet_returns_null(
client: AsyncClient, test_user: dict[str, Any]
) -> None:
"""A resolvable user who never onboarded a wallet returns wallet_address=null
(the client blocks the send with 'recipient has no wallet')."""
response = await client.get(
ENDPOINT, params={"oltin_id": "seconduser"}, headers=test_user["headers"]
)
assert response.status_code == 200
body = response.json()
assert body["oltin_id"] == "seconduser"
assert body["wallet_address"] is None


@pytest.mark.asyncio
async def test_lookup_found_with_wallet(
client: AsyncClient, test_user: dict[str, Any], second_user: dict[str, Any]
) -> None:
"""Once the recipient has bound a wallet, lookup returns its lowercase address."""
bind = await client.post(
WALLET_ENDPOINT,
json={"wallet_address": VALID_ADDRESS},
headers=second_user["headers"],
)
assert bind.status_code == 200

response = await client.get(
ENDPOINT, params={"oltin_id": "seconduser"}, headers=test_user["headers"]
)
assert response.status_code == 200
body = response.json()
assert body["oltin_id"] == "seconduser"
assert body["wallet_address"] == VALID_ADDRESS.lower()


@pytest.mark.usefixtures("second_user")
@pytest.mark.parametrize("query", ["seconduser", "SECONDUSER", "@seconduser", " seconduser "])
@pytest.mark.asyncio
async def test_lookup_normalizes_oltin_id(
client: AsyncClient, test_user: dict[str, Any], query: str
) -> None:
"""oltin_id is normalized (lowercase, strip, leading @) — same as search/bind."""
response = await client.get(
ENDPOINT, params={"oltin_id": query}, headers=test_user["headers"]
)
assert response.status_code == 200
assert response.json()["oltin_id"] == "seconduser"


@pytest.mark.asyncio
async def test_lookup_not_found(
client: AsyncClient, test_user: dict[str, Any]
) -> None:
"""An unknown oltin_id resolves to 404, not a null/empty 200."""
response = await client.get(
ENDPOINT, params={"oltin_id": "ghostuser"}, headers=test_user["headers"]
)
assert response.status_code == 404


@pytest.mark.asyncio
async def test_lookup_requires_auth(client: AsyncClient) -> None:
"""Resolving another user's address requires authentication."""
response = await client.get(ENDPOINT, params={"oltin_id": "seconduser"})
assert response.status_code in (401, 403)
Loading
Loading