Compare commits
5 commits
4be45f8f7c
...
f46d2ae8b3
| Author | SHA1 | Date | |
|---|---|---|---|
| f46d2ae8b3 | |||
| 139a5fbef3 | |||
| 73a45b81cc | |||
| d6f955d2d9 | |||
| 67ffe6a823 |
24 changed files with 1171 additions and 1395 deletions
28
Makefile
28
Makefile
|
|
@ -38,8 +38,18 @@ db-ready:
|
||||||
@until docker compose exec -T db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until docker compose exec -T db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
sleep 1; \
|
sleep 1; \
|
||||||
done
|
done
|
||||||
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret"
|
||||||
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test'" | grep -q 1 || \
|
||||||
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test"
|
||||||
|
@# Create worker-specific databases for parallel backend test execution (pytest-xdist)
|
||||||
|
@for i in 0 1 2 3 4 5 6 7; do \
|
||||||
|
docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_test_gw$$i'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_test_gw$$i"; \
|
||||||
|
done
|
||||||
|
@# Create separate database for e2e tests
|
||||||
|
@docker compose exec -T db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'arbret_e2e'" | grep -q 1 || \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
||||||
@echo "PostgreSQL is ready"
|
@echo "PostgreSQL is ready"
|
||||||
|
|
||||||
db-seed: db-ready
|
db-seed: db-ready
|
||||||
|
|
@ -58,15 +68,27 @@ dev:
|
||||||
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
# E2E: TEST="auth" (file pattern matching e2e/*.spec.ts)
|
||||||
TEST ?=
|
TEST ?=
|
||||||
|
|
||||||
test-backend: db-clean db-ready
|
test-backend: db-ready test-backend-clean-dbs
|
||||||
cd backend && uv run pytest -v $(TEST)
|
cd backend && uv run pytest -v -n 8 $(TEST)
|
||||||
|
|
||||||
|
# Clean only backend test databases (not e2e or main db)
|
||||||
|
test-backend-clean-dbs:
|
||||||
|
@for db in arbret_test arbret_test_gw0 arbret_test_gw1 arbret_test_gw2 arbret_test_gw3 arbret_test_gw4 arbret_test_gw5 arbret_test_gw6 arbret_test_gw7; do \
|
||||||
|
docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS $$db" 2>/dev/null || true; \
|
||||||
|
docker compose exec -T db psql -U postgres -c "CREATE DATABASE $$db"; \
|
||||||
|
done
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
cd frontend && npm run test $(if $(TEST),-- $(TEST),)
|
||||||
|
|
||||||
test-e2e: db-clean db-ready
|
test-e2e: db-ready test-e2e-clean-db
|
||||||
./scripts/e2e.sh $(TEST)
|
./scripts/e2e.sh $(TEST)
|
||||||
|
|
||||||
|
# Clean only e2e database (not backend test dbs or main db)
|
||||||
|
test-e2e-clean-db:
|
||||||
|
@docker compose exec -T db psql -U postgres -c "DROP DATABASE IF EXISTS arbret_e2e" 2>/dev/null || true
|
||||||
|
@docker compose exec -T db psql -U postgres -c "CREATE DATABASE arbret_e2e"
|
||||||
|
|
||||||
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
test: check-constants check-types-fresh test-backend test-frontend test-e2e
|
||||||
|
|
||||||
typecheck: generate-types-standalone
|
typecheck: generate-types-standalone
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Permission, User
|
from models import Permission, User
|
||||||
|
from repositories.user import UserRepository
|
||||||
from schemas import UserResponse
|
from schemas import UserResponse
|
||||||
|
|
||||||
SECRET_KEY = os.environ["SECRET_KEY"] # Required - see .env.example
|
SECRET_KEY = os.environ["SECRET_KEY"] # Required - see .env.example
|
||||||
|
|
@ -45,8 +46,9 @@ def create_access_token(
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||||
result = await db.execute(select(User).where(User.email == email))
|
"""Get user by email (backwards compatibility wrapper)."""
|
||||||
return result.scalar_one_or_none()
|
repo = UserRepository(db)
|
||||||
|
return await repo.get_by_email(email)
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None:
|
async def authenticate_user(db: AsyncSession, email: str, password: str) -> User | None:
|
||||||
|
|
|
||||||
61
backend/exceptions.py
Normal file
61
backend/exceptions.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Standardized API exception classes for consistent error responses.
|
||||||
|
|
||||||
|
Note: These exceptions use string detail for backward compatibility with existing tests.
|
||||||
|
Future refactoring could standardize on structured error responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(HTTPException):
|
||||||
|
"""Base API error with consistent structure.
|
||||||
|
|
||||||
|
Uses string detail for backward compatibility with existing tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
status_code: int,
|
||||||
|
message: str,
|
||||||
|
):
|
||||||
|
super().__init__(status_code=status_code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(APIError):
|
||||||
|
"""Resource not found error (404)."""
|
||||||
|
|
||||||
|
def __init__(self, resource: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
message=f"{resource} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(APIError):
|
||||||
|
"""Conflict error (409)."""
|
||||||
|
|
||||||
|
def __init__(self, message: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BadRequestError(APIError):
|
||||||
|
"""Bad request error (400)."""
|
||||||
|
|
||||||
|
def __init__(self, message: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUnavailableError(APIError):
|
||||||
|
"""Service unavailable error (503)."""
|
||||||
|
|
||||||
|
def __init__(self, message: str):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
91
backend/mappers.py
Normal file
91
backend/mappers.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Response mappers for converting models to API response schemas."""
|
||||||
|
|
||||||
|
from models import Exchange, Invite
|
||||||
|
from schemas import (
|
||||||
|
AdminExchangeResponse,
|
||||||
|
ExchangeResponse,
|
||||||
|
ExchangeUserContact,
|
||||||
|
InviteResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeMapper:
|
||||||
|
"""Mapper for Exchange model to response schemas."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_response(
|
||||||
|
exchange: Exchange,
|
||||||
|
user_email: str | None = None,
|
||||||
|
) -> ExchangeResponse:
|
||||||
|
"""Convert an Exchange model to ExchangeResponse schema."""
|
||||||
|
email = user_email if user_email is not None else exchange.user.email
|
||||||
|
return ExchangeResponse(
|
||||||
|
id=exchange.id,
|
||||||
|
public_id=str(exchange.public_id),
|
||||||
|
user_id=exchange.user_id,
|
||||||
|
user_email=email,
|
||||||
|
slot_start=exchange.slot_start,
|
||||||
|
slot_end=exchange.slot_end,
|
||||||
|
direction=exchange.direction.value,
|
||||||
|
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
||||||
|
eur_amount=exchange.eur_amount,
|
||||||
|
sats_amount=exchange.sats_amount,
|
||||||
|
market_price_eur=exchange.market_price_eur,
|
||||||
|
agreed_price_eur=exchange.agreed_price_eur,
|
||||||
|
premium_percentage=exchange.premium_percentage,
|
||||||
|
status=exchange.status.value,
|
||||||
|
created_at=exchange.created_at,
|
||||||
|
cancelled_at=exchange.cancelled_at,
|
||||||
|
completed_at=exchange.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_admin_response(exchange: Exchange) -> AdminExchangeResponse:
|
||||||
|
"""Convert an Exchange model to AdminExchangeResponse with user contact."""
|
||||||
|
user = exchange.user
|
||||||
|
return AdminExchangeResponse(
|
||||||
|
id=exchange.id,
|
||||||
|
public_id=str(exchange.public_id),
|
||||||
|
user_id=exchange.user_id,
|
||||||
|
user_email=user.email,
|
||||||
|
user_contact=ExchangeUserContact(
|
||||||
|
email=user.email,
|
||||||
|
contact_email=user.contact_email,
|
||||||
|
telegram=user.telegram,
|
||||||
|
signal=user.signal,
|
||||||
|
nostr_npub=user.nostr_npub,
|
||||||
|
),
|
||||||
|
slot_start=exchange.slot_start,
|
||||||
|
slot_end=exchange.slot_end,
|
||||||
|
direction=exchange.direction.value,
|
||||||
|
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
||||||
|
eur_amount=exchange.eur_amount,
|
||||||
|
sats_amount=exchange.sats_amount,
|
||||||
|
market_price_eur=exchange.market_price_eur,
|
||||||
|
agreed_price_eur=exchange.agreed_price_eur,
|
||||||
|
premium_percentage=exchange.premium_percentage,
|
||||||
|
status=exchange.status.value,
|
||||||
|
created_at=exchange.created_at,
|
||||||
|
cancelled_at=exchange.cancelled_at,
|
||||||
|
completed_at=exchange.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InviteMapper:
|
||||||
|
"""Mapper for Invite model to response schemas."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_response(invite: Invite) -> InviteResponse:
|
||||||
|
"""Build an InviteResponse from an Invite with loaded relationships."""
|
||||||
|
return InviteResponse(
|
||||||
|
id=invite.id,
|
||||||
|
identifier=invite.identifier,
|
||||||
|
godfather_id=invite.godfather_id,
|
||||||
|
godfather_email=invite.godfather.email,
|
||||||
|
status=invite.status.value,
|
||||||
|
used_by_id=invite.used_by_id,
|
||||||
|
used_by_email=invite.used_by.email if invite.used_by else None,
|
||||||
|
created_at=invite.created_at,
|
||||||
|
spent_at=invite.spent_at,
|
||||||
|
revoked_at=invite.revoked_at,
|
||||||
|
)
|
||||||
|
|
@ -19,6 +19,7 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.3.4",
|
"pytest>=8.3.4",
|
||||||
"pytest-asyncio>=0.25.0",
|
"pytest-asyncio>=0.25.0",
|
||||||
|
"pytest-xdist>=3.5.0",
|
||||||
"aiosqlite>=0.20.0",
|
"aiosqlite>=0.20.0",
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"ruff>=0.14.10",
|
"ruff>=0.14.10",
|
||||||
|
|
|
||||||
6
backend/repositories/__init__.py
Normal file
6
backend/repositories/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Repository layer for database queries."""
|
||||||
|
|
||||||
|
from repositories.price import PriceRepository
|
||||||
|
from repositories.user import UserRepository
|
||||||
|
|
||||||
|
__all__ = ["PriceRepository", "UserRepository"]
|
||||||
27
backend/repositories/price.py
Normal file
27
backend/repositories/price.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Price repository for database queries."""
|
||||||
|
|
||||||
|
from sqlalchemy import desc, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models import PriceHistory
|
||||||
|
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX
|
||||||
|
|
||||||
|
|
||||||
|
class PriceRepository:
|
||||||
|
"""Repository for price-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_latest(
|
||||||
|
self, source: str = SOURCE_BITFINEX, pair: str = PAIR_BTC_EUR
|
||||||
|
) -> PriceHistory | None:
|
||||||
|
"""Get the most recent price from the database."""
|
||||||
|
query = (
|
||||||
|
select(PriceHistory)
|
||||||
|
.where(PriceHistory.source == source, PriceHistory.pair == pair)
|
||||||
|
.order_by(desc(PriceHistory.timestamp))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
23
backend/repositories/user.py
Normal file
23
backend/repositories/user.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""User repository for database queries."""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository:
|
||||||
|
"""Repository for user-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_email(self, email: str) -> User | None:
|
||||||
|
"""Get a user by email."""
|
||||||
|
result = await self.db.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_id(self, user_id: int) -> User | None:
|
||||||
|
"""Get a user by ID."""
|
||||||
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime, time, timedelta
|
from datetime import UTC, date, datetime, time, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy import and_, desc, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from date_validation import validate_date_in_range
|
from date_validation import validate_date_in_range
|
||||||
|
from exceptions import BadRequestError
|
||||||
|
from mappers import ExchangeMapper
|
||||||
from models import (
|
from models import (
|
||||||
Availability,
|
Availability,
|
||||||
BitcoinTransferMethod,
|
BitcoinTransferMethod,
|
||||||
|
|
@ -24,169 +24,35 @@ from models import (
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||||
|
from repositories.price import PriceRepository
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminExchangeResponse,
|
AdminExchangeResponse,
|
||||||
|
AvailableSlotsResponse,
|
||||||
|
BookableSlot,
|
||||||
|
ExchangeConfigResponse,
|
||||||
|
ExchangePriceResponse,
|
||||||
ExchangeRequest,
|
ExchangeRequest,
|
||||||
ExchangeResponse,
|
ExchangeResponse,
|
||||||
ExchangeUserContact,
|
PriceResponse,
|
||||||
|
UserSearchResult,
|
||||||
)
|
)
|
||||||
|
from services.exchange import ExchangeService
|
||||||
from shared_constants import (
|
from shared_constants import (
|
||||||
EUR_TRADE_INCREMENT,
|
EUR_TRADE_INCREMENT,
|
||||||
EUR_TRADE_MAX,
|
EUR_TRADE_MAX,
|
||||||
EUR_TRADE_MIN,
|
EUR_TRADE_MIN,
|
||||||
LIGHTNING_MAX_EUR,
|
|
||||||
PREMIUM_PERCENTAGE,
|
PREMIUM_PERCENTAGE,
|
||||||
PRICE_STALENESS_SECONDS,
|
|
||||||
SLOT_DURATION_MINUTES,
|
SLOT_DURATION_MINUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Constants for satoshi calculations
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
SATS_PER_BTC = 100_000_000
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Pydantic models for price endpoint
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ExchangeConfigResponse(BaseModel):
|
|
||||||
"""Exchange configuration for the frontend."""
|
|
||||||
|
|
||||||
eur_min: int
|
|
||||||
eur_max: int
|
|
||||||
eur_increment: int
|
|
||||||
premium_percentage: int
|
|
||||||
|
|
||||||
|
|
||||||
class PriceResponse(BaseModel):
|
|
||||||
"""Current BTC/EUR price for trading.
|
|
||||||
|
|
||||||
Note: The actual agreed price depends on trade direction (buy/sell)
|
|
||||||
and is calculated by the frontend using market_price and premium_percentage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
market_price: float # Raw price from exchange
|
|
||||||
premium_percentage: int
|
|
||||||
timestamp: datetime
|
|
||||||
is_stale: bool
|
|
||||||
|
|
||||||
|
|
||||||
class ExchangePriceResponse(BaseModel):
|
|
||||||
"""Combined price and configuration response."""
|
|
||||||
|
|
||||||
price: PriceResponse | None # None if price fetch failed
|
|
||||||
config: ExchangeConfigResponse
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BookableSlot(BaseModel):
|
|
||||||
"""A single bookable time slot."""
|
|
||||||
|
|
||||||
start_time: datetime
|
|
||||||
end_time: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class AvailableSlotsResponse(BaseModel):
|
|
||||||
"""Response containing available slots for a date."""
|
|
||||||
|
|
||||||
date: date
|
|
||||||
slots: list[BookableSlot]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Helper functions
|
# Helper functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def apply_premium_for_direction(
|
|
||||||
market_price: float,
|
|
||||||
premium_percentage: int,
|
|
||||||
direction: TradeDirection,
|
|
||||||
) -> float:
|
|
||||||
"""
|
|
||||||
Apply premium to market price based on trade direction.
|
|
||||||
|
|
||||||
The premium is always favorable to the admin:
|
|
||||||
- When user BUYS BTC: user pays MORE (market * (1 + premium/100))
|
|
||||||
- When user SELLS BTC: user receives LESS (market * (1 - premium/100))
|
|
||||||
"""
|
|
||||||
if direction == TradeDirection.BUY:
|
|
||||||
return market_price * (1 + premium_percentage / 100)
|
|
||||||
else: # SELL
|
|
||||||
return market_price * (1 - premium_percentage / 100)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sats_amount(
|
|
||||||
eur_cents: int,
|
|
||||||
price_eur_per_btc: float,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Calculate satoshi amount from EUR cents and price.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
eur_cents: Amount in EUR cents (e.g., 10000 = €100)
|
|
||||||
price_eur_per_btc: Price in EUR per BTC
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Amount in satoshis
|
|
||||||
"""
|
|
||||||
eur_amount = eur_cents / 100
|
|
||||||
btc_amount = eur_amount / price_eur_per_btc
|
|
||||||
return int(btc_amount * SATS_PER_BTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_price(db: AsyncSession) -> PriceHistory | None:
|
|
||||||
"""Get the most recent price from the database."""
|
|
||||||
query = (
|
|
||||||
select(PriceHistory)
|
|
||||||
.where(
|
|
||||||
PriceHistory.source == SOURCE_BITFINEX, PriceHistory.pair == PAIR_BTC_EUR
|
|
||||||
)
|
|
||||||
.order_by(desc(PriceHistory.timestamp))
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
result = await db.execute(query)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
def is_price_stale(price_timestamp: datetime) -> bool:
|
|
||||||
"""Check if a price is older than the staleness threshold."""
|
|
||||||
age_seconds = (datetime.now(UTC) - price_timestamp).total_seconds()
|
|
||||||
return age_seconds > PRICE_STALENESS_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
def _to_exchange_response(
|
|
||||||
exchange: Exchange,
|
|
||||||
user_email: str | None = None,
|
|
||||||
) -> ExchangeResponse:
|
|
||||||
"""Convert an Exchange model to ExchangeResponse schema."""
|
|
||||||
email = user_email if user_email is not None else exchange.user.email
|
|
||||||
return ExchangeResponse(
|
|
||||||
id=exchange.id,
|
|
||||||
public_id=str(exchange.public_id),
|
|
||||||
user_id=exchange.user_id,
|
|
||||||
user_email=email,
|
|
||||||
slot_start=exchange.slot_start,
|
|
||||||
slot_end=exchange.slot_end,
|
|
||||||
direction=exchange.direction.value,
|
|
||||||
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
|
||||||
eur_amount=exchange.eur_amount,
|
|
||||||
sats_amount=exchange.sats_amount,
|
|
||||||
market_price_eur=exchange.market_price_eur,
|
|
||||||
agreed_price_eur=exchange.agreed_price_eur,
|
|
||||||
premium_percentage=exchange.premium_percentage,
|
|
||||||
status=exchange.status.value,
|
|
||||||
created_at=exchange.created_at,
|
|
||||||
cancelled_at=exchange.cancelled_at,
|
|
||||||
completed_at=exchange.completed_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Price Endpoint
|
# Price Endpoint
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -216,11 +82,14 @@ async def get_exchange_price(
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
premium_percentage=PREMIUM_PERCENTAGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
price_repo = PriceRepository(db)
|
||||||
|
service = ExchangeService(db)
|
||||||
|
|
||||||
# Try to get the latest cached price
|
# Try to get the latest cached price
|
||||||
cached_price = await get_latest_price(db)
|
cached_price = await price_repo.get_latest()
|
||||||
|
|
||||||
# If no cached price or it's stale, try to fetch a new one
|
# If no cached price or it's stale, try to fetch a new one
|
||||||
if cached_price is None or is_price_stale(cached_price.timestamp):
|
if cached_price is None or service.is_price_stale(cached_price.timestamp):
|
||||||
try:
|
try:
|
||||||
price_value, timestamp = await fetch_btc_eur_price()
|
price_value, timestamp = await fetch_btc_eur_price()
|
||||||
|
|
||||||
|
|
@ -270,7 +139,7 @@ async def get_exchange_price(
|
||||||
market_price=cached_price.price,
|
market_price=cached_price.price,
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
premium_percentage=PREMIUM_PERCENTAGE,
|
||||||
timestamp=cached_price.timestamp,
|
timestamp=cached_price.timestamp,
|
||||||
is_stale=is_price_stale(cached_price.timestamp),
|
is_stale=service.is_price_stale(cached_price.timestamp),
|
||||||
),
|
),
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
@ -377,194 +246,34 @@ async def create_exchange(
|
||||||
- Price is not stale
|
- Price is not stale
|
||||||
- EUR amount is within configured limits
|
- EUR amount is within configured limits
|
||||||
"""
|
"""
|
||||||
slot_date = request.slot_start.date()
|
|
||||||
validate_date_in_range(slot_date, context="book")
|
|
||||||
|
|
||||||
# Check if user already has a trade on this date
|
|
||||||
existing_trade_query = select(Exchange).where(
|
|
||||||
and_(
|
|
||||||
Exchange.user_id == current_user.id,
|
|
||||||
Exchange.slot_start >= datetime.combine(slot_date, time.min, tzinfo=UTC),
|
|
||||||
Exchange.slot_start
|
|
||||||
< datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1),
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing_trade_result = await db.execute(existing_trade_query)
|
|
||||||
existing_trade = existing_trade_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if existing_trade:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=(
|
|
||||||
f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. "
|
|
||||||
f"Only one trade per day is allowed. "
|
|
||||||
f"Trade ID: {existing_trade.public_id}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate direction
|
# Validate direction
|
||||||
try:
|
try:
|
||||||
direction = TradeDirection(request.direction)
|
direction = TradeDirection(request.direction)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise BadRequestError(
|
||||||
status_code=400,
|
f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
|
||||||
detail=f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'.",
|
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Validate bitcoin transfer method
|
# Validate bitcoin transfer method
|
||||||
try:
|
try:
|
||||||
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
|
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise BadRequestError(
|
||||||
status_code=400,
|
|
||||||
detail=(
|
|
||||||
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
||||||
"Must be 'onchain' or 'lightning'."
|
"Must be 'onchain' or 'lightning'."
|
||||||
),
|
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Validate EUR amount
|
# Use service to create exchange (handles all validation)
|
||||||
if request.eur_amount < EUR_TRADE_MIN * 100:
|
service = ExchangeService(db)
|
||||||
raise HTTPException(
|
exchange = await service.create_exchange(
|
||||||
status_code=400,
|
user=current_user,
|
||||||
detail=f"EUR amount must be at least €{EUR_TRADE_MIN}",
|
|
||||||
)
|
|
||||||
if request.eur_amount > EUR_TRADE_MAX * 100:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"EUR amount must be at most €{EUR_TRADE_MAX}",
|
|
||||||
)
|
|
||||||
if request.eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate Lightning threshold
|
|
||||||
if (
|
|
||||||
bitcoin_transfer_method == BitcoinTransferMethod.LIGHTNING
|
|
||||||
and request.eur_amount > LIGHTNING_MAX_EUR * 100
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=(
|
|
||||||
f"Lightning payments are only allowed for amounts up to "
|
|
||||||
f"€{LIGHTNING_MAX_EUR}. For amounts above €{LIGHTNING_MAX_EUR}, "
|
|
||||||
"please use onchain transactions."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate slot timing - compute valid boundaries from slot duration
|
|
||||||
valid_minutes = tuple(range(0, 60, SLOT_DURATION_MINUTES))
|
|
||||||
if request.slot_start.minute not in valid_minutes:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary",
|
|
||||||
)
|
|
||||||
if request.slot_start.second != 0 or request.slot_start.microsecond != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Slot start time must not have seconds or microseconds",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify slot falls within availability
|
|
||||||
slot_start_time = request.slot_start.time()
|
|
||||||
slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
|
||||||
slot_end_time = slot_end_dt.time()
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Availability).where(
|
|
||||||
and_(
|
|
||||||
Availability.date == slot_date,
|
|
||||||
Availability.start_time <= slot_start_time,
|
|
||||||
Availability.end_time >= slot_end_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
matching_availability = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not matching_availability:
|
|
||||||
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Selected slot at {slot_str} UTC is not available",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get and validate price
|
|
||||||
cached_price = await get_latest_price(db)
|
|
||||||
|
|
||||||
if cached_price is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Price data unavailable. Please try again later.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_price_stale(cached_price.timestamp):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Price is stale. Please refresh and try again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate agreed price based on direction
|
|
||||||
market_price = cached_price.price
|
|
||||||
agreed_price = apply_premium_for_direction(
|
|
||||||
market_price, PREMIUM_PERCENTAGE, direction
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate sats amount based on agreed price
|
|
||||||
sats_amount = calculate_sats_amount(request.eur_amount, agreed_price)
|
|
||||||
|
|
||||||
# Check if slot is already booked (only consider BOOKED status, not cancelled)
|
|
||||||
slot_booked_query = select(Exchange).where(
|
|
||||||
and_(
|
|
||||||
Exchange.slot_start == request.slot_start,
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
slot_booked_result = await db.execute(slot_booked_query)
|
|
||||||
slot_booked = slot_booked_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if slot_booked:
|
|
||||||
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=(
|
|
||||||
f"This slot at {slot_str} UTC has already been booked. "
|
|
||||||
"Select another slot."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the exchange
|
|
||||||
exchange = Exchange(
|
|
||||||
user_id=current_user.id,
|
|
||||||
slot_start=request.slot_start,
|
slot_start=request.slot_start,
|
||||||
slot_end=slot_end_dt,
|
|
||||||
direction=direction,
|
direction=direction,
|
||||||
bitcoin_transfer_method=bitcoin_transfer_method,
|
bitcoin_transfer_method=bitcoin_transfer_method,
|
||||||
eur_amount=request.eur_amount,
|
eur_amount=request.eur_amount,
|
||||||
sats_amount=sats_amount,
|
|
||||||
market_price_eur=market_price,
|
|
||||||
agreed_price_eur=agreed_price,
|
|
||||||
premium_percentage=PREMIUM_PERCENTAGE,
|
|
||||||
status=ExchangeStatus.BOOKED,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(exchange)
|
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||||
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(exchange)
|
|
||||||
except IntegrityError as e:
|
|
||||||
await db.rollback()
|
|
||||||
# This should rarely happen now since we check explicitly above,
|
|
||||||
# but keep it for other potential integrity violations
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Database constraint violation. Please try again.",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return _to_exchange_response(exchange, current_user.email)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -587,7 +296,7 @@ async def get_my_trades(
|
||||||
)
|
)
|
||||||
exchanges = result.scalars().all()
|
exchanges = result.scalars().all()
|
||||||
|
|
||||||
return [_to_exchange_response(ex, current_user.email) for ex in exchanges]
|
return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
|
||||||
|
|
||||||
|
|
||||||
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
|
@trades_router.get("/{public_id}", response_model=ExchangeResponse)
|
||||||
|
|
@ -597,20 +306,10 @@ async def get_my_trade(
|
||||||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
||||||
) -> ExchangeResponse:
|
) -> ExchangeResponse:
|
||||||
"""Get a specific trade by public ID. User can only access their own trades."""
|
"""Get a specific trade by public ID. User can only access their own trades."""
|
||||||
result = await db.execute(
|
service = ExchangeService(db)
|
||||||
select(Exchange).where(
|
exchange = await service.get_exchange_by_public_id(public_id, user=current_user)
|
||||||
and_(Exchange.public_id == public_id, Exchange.user_id == current_user.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Trade not found or you don't have permission to view it.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return _to_exchange_response(exchange, current_user.email)
|
|
||||||
|
|
||||||
|
|
||||||
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
|
@trades_router.post("/{public_id}/cancel", response_model=ExchangeResponse)
|
||||||
|
|
@ -620,48 +319,20 @@ async def cancel_my_trade(
|
||||||
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
|
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_EXCHANGE)),
|
||||||
) -> ExchangeResponse:
|
) -> ExchangeResponse:
|
||||||
"""Cancel one of the current user's exchanges."""
|
"""Cancel one of the current user's exchanges."""
|
||||||
# Get the exchange with eager loading of user relationship
|
service = ExchangeService(db)
|
||||||
result = await db.execute(
|
# Get exchange without user filter first to check ownership separately
|
||||||
select(Exchange)
|
exchange = await service.get_exchange_by_public_id(public_id)
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(Exchange.public_id == public_id)
|
|
||||||
)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
# Check ownership - return 403 if user doesn't own it
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Trade not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify ownership
|
|
||||||
if exchange.user_id != current_user.id:
|
if exchange.user_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Cannot cancel another user's trade",
|
detail="Cannot cancel another user's trade",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if already in a final state
|
exchange = await service.cancel_exchange(exchange, current_user, is_admin=False)
|
||||||
if exchange.status != ExchangeStatus.BOOKED:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot cancel: status is '{exchange.status.value}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if slot time has already passed
|
return ExchangeMapper.to_response(exchange, current_user.email)
|
||||||
if exchange.slot_start <= datetime.now(UTC):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot cancel: trade slot time has already passed",
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange.status = ExchangeStatus.CANCELLED_BY_USER
|
|
||||||
exchange.cancelled_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(exchange)
|
|
||||||
|
|
||||||
return _to_exchange_response(exchange, current_user.email)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -671,37 +342,6 @@ async def cancel_my_trade(
|
||||||
admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"])
|
admin_trades_router = APIRouter(prefix="/api/admin/trades", tags=["admin-trades"])
|
||||||
|
|
||||||
|
|
||||||
def _to_admin_exchange_response(exchange: Exchange) -> AdminExchangeResponse:
|
|
||||||
"""Convert an Exchange model to AdminExchangeResponse with user contact."""
|
|
||||||
user = exchange.user
|
|
||||||
return AdminExchangeResponse(
|
|
||||||
id=exchange.id,
|
|
||||||
public_id=str(exchange.public_id),
|
|
||||||
user_id=exchange.user_id,
|
|
||||||
user_email=user.email,
|
|
||||||
user_contact=ExchangeUserContact(
|
|
||||||
email=user.email,
|
|
||||||
contact_email=user.contact_email,
|
|
||||||
telegram=user.telegram,
|
|
||||||
signal=user.signal,
|
|
||||||
nostr_npub=user.nostr_npub,
|
|
||||||
),
|
|
||||||
slot_start=exchange.slot_start,
|
|
||||||
slot_end=exchange.slot_end,
|
|
||||||
direction=exchange.direction.value,
|
|
||||||
bitcoin_transfer_method=exchange.bitcoin_transfer_method.value,
|
|
||||||
eur_amount=exchange.eur_amount,
|
|
||||||
sats_amount=exchange.sats_amount,
|
|
||||||
market_price_eur=exchange.market_price_eur,
|
|
||||||
agreed_price_eur=exchange.agreed_price_eur,
|
|
||||||
premium_percentage=exchange.premium_percentage,
|
|
||||||
status=exchange.status.value,
|
|
||||||
created_at=exchange.created_at,
|
|
||||||
cancelled_at=exchange.cancelled_at,
|
|
||||||
completed_at=exchange.completed_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
|
@admin_trades_router.get("/upcoming", response_model=list[AdminExchangeResponse])
|
||||||
async def get_upcoming_trades(
|
async def get_upcoming_trades(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
|
@ -722,7 +362,7 @@ async def get_upcoming_trades(
|
||||||
)
|
)
|
||||||
exchanges = result.scalars().all()
|
exchanges = result.scalars().all()
|
||||||
|
|
||||||
return [_to_admin_exchange_response(ex) for ex in exchanges]
|
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||||
|
|
||||||
|
|
||||||
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
|
@admin_trades_router.get("/past", response_model=list[AdminExchangeResponse])
|
||||||
|
|
@ -783,7 +423,7 @@ async def get_past_trades(
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
exchanges = result.scalars().all()
|
exchanges = result.scalars().all()
|
||||||
|
|
||||||
return [_to_admin_exchange_response(ex) for ex in exchanges]
|
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||||
|
|
||||||
|
|
||||||
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
|
@admin_trades_router.post("/{public_id}/complete", response_model=AdminExchangeResponse)
|
||||||
|
|
@ -793,41 +433,11 @@ async def complete_trade(
|
||||||
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
||||||
) -> AdminExchangeResponse:
|
) -> AdminExchangeResponse:
|
||||||
"""Mark a trade as completed. Only possible after slot time has passed."""
|
"""Mark a trade as completed. Only possible after slot time has passed."""
|
||||||
|
service = ExchangeService(db)
|
||||||
|
exchange = await service.get_exchange_by_public_id(public_id)
|
||||||
|
exchange = await service.complete_exchange(exchange)
|
||||||
|
|
||||||
result = await db.execute(
|
return ExchangeMapper.to_admin_response(exchange)
|
||||||
select(Exchange)
|
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(Exchange.public_id == public_id)
|
|
||||||
)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Trade not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check slot has passed
|
|
||||||
if exchange.slot_start > datetime.now(UTC):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot complete: trade slot has not yet started",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check status is BOOKED
|
|
||||||
if exchange.status != ExchangeStatus.BOOKED:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot complete: status is '{exchange.status.value}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange.status = ExchangeStatus.COMPLETED
|
|
||||||
exchange.completed_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(exchange)
|
|
||||||
|
|
||||||
return _to_admin_exchange_response(exchange)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
|
@admin_trades_router.post("/{public_id}/no-show", response_model=AdminExchangeResponse)
|
||||||
|
|
@ -837,41 +447,11 @@ async def mark_no_show(
|
||||||
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
_current_user: User = Depends(require_permission(Permission.COMPLETE_EXCHANGE)),
|
||||||
) -> AdminExchangeResponse:
|
) -> AdminExchangeResponse:
|
||||||
"""Mark a trade as no-show. Only possible after slot time has passed."""
|
"""Mark a trade as no-show. Only possible after slot time has passed."""
|
||||||
|
service = ExchangeService(db)
|
||||||
|
exchange = await service.get_exchange_by_public_id(public_id)
|
||||||
|
exchange = await service.mark_no_show(exchange)
|
||||||
|
|
||||||
result = await db.execute(
|
return ExchangeMapper.to_admin_response(exchange)
|
||||||
select(Exchange)
|
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(Exchange.public_id == public_id)
|
|
||||||
)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Trade not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check slot has passed
|
|
||||||
if exchange.slot_start > datetime.now(UTC):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot mark as no-show: trade slot has not yet started",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check status is BOOKED
|
|
||||||
if exchange.status != ExchangeStatus.BOOKED:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot mark as no-show: status is '{exchange.status.value}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange.status = ExchangeStatus.NO_SHOW
|
|
||||||
exchange.completed_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(exchange)
|
|
||||||
|
|
||||||
return _to_admin_exchange_response(exchange)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
|
@admin_trades_router.post("/{public_id}/cancel", response_model=AdminExchangeResponse)
|
||||||
|
|
@ -881,34 +461,11 @@ async def admin_cancel_trade(
|
||||||
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
|
_current_user: User = Depends(require_permission(Permission.CANCEL_ANY_EXCHANGE)),
|
||||||
) -> AdminExchangeResponse:
|
) -> AdminExchangeResponse:
|
||||||
"""Cancel any trade (admin only)."""
|
"""Cancel any trade (admin only)."""
|
||||||
|
service = ExchangeService(db)
|
||||||
|
exchange = await service.get_exchange_by_public_id(public_id)
|
||||||
|
exchange = await service.cancel_exchange(exchange, _current_user, is_admin=True)
|
||||||
|
|
||||||
result = await db.execute(
|
return ExchangeMapper.to_admin_response(exchange)
|
||||||
select(Exchange)
|
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(Exchange.public_id == public_id)
|
|
||||||
)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Trade not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check status is BOOKED
|
|
||||||
if exchange.status != ExchangeStatus.BOOKED:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Cannot cancel: status is '{exchange.status.value}'",
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange.status = ExchangeStatus.CANCELLED_BY_ADMIN
|
|
||||||
exchange.cancelled_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(exchange)
|
|
||||||
|
|
||||||
return _to_admin_exchange_response(exchange)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -918,13 +475,6 @@ async def admin_cancel_trade(
|
||||||
admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
admin_users_router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
|
||||||
|
|
||||||
|
|
||||||
class UserSearchResult(BaseModel):
|
|
||||||
"""Result item for user search."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
email: str
|
|
||||||
|
|
||||||
|
|
||||||
@admin_users_router.get("/search", response_model=list[UserSearchResult])
|
@admin_users_router.get("/search", response_model=list[UserSearchResult])
|
||||||
async def search_users(
|
async def search_users(
|
||||||
q: str = Query(..., min_length=1, description="Search query for user email"),
|
q: str = Query(..., min_length=1, description="Search query for user email"),
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from exceptions import BadRequestError, NotFoundError
|
||||||
from invite_utils import (
|
from invite_utils import (
|
||||||
generate_invite_identifier,
|
generate_invite_identifier,
|
||||||
is_valid_identifier_format,
|
is_valid_identifier_format,
|
||||||
normalize_identifier,
|
normalize_identifier,
|
||||||
)
|
)
|
||||||
|
from mappers import InviteMapper
|
||||||
from models import Invite, InviteStatus, Permission, User
|
from models import Invite, InviteStatus, Permission, User
|
||||||
from pagination import calculate_offset, create_paginated_response
|
from pagination import calculate_offset, create_paginated_response
|
||||||
from schemas import (
|
from schemas import (
|
||||||
|
|
@ -31,22 +33,6 @@ admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
MAX_INVITE_COLLISION_RETRIES = 3
|
MAX_INVITE_COLLISION_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
def _to_invite_response(invite: Invite) -> InviteResponse:
|
|
||||||
"""Build an InviteResponse from an Invite with loaded relationships."""
|
|
||||||
return InviteResponse(
|
|
||||||
id=invite.id,
|
|
||||||
identifier=invite.identifier,
|
|
||||||
godfather_id=invite.godfather_id,
|
|
||||||
godfather_email=invite.godfather.email,
|
|
||||||
status=invite.status.value,
|
|
||||||
used_by_id=invite.used_by_id,
|
|
||||||
used_by_email=invite.used_by.email if invite.used_by else None,
|
|
||||||
created_at=invite.created_at,
|
|
||||||
spent_at=invite.spent_at,
|
|
||||||
revoked_at=invite.revoked_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
||||||
async def check_invite(
|
async def check_invite(
|
||||||
identifier: str,
|
identifier: str,
|
||||||
|
|
@ -118,10 +104,7 @@ async def create_invite(
|
||||||
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
|
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
|
||||||
godfather_id = result.scalar_one_or_none()
|
godfather_id = result.scalar_one_or_none()
|
||||||
if not godfather_id:
|
if not godfather_id:
|
||||||
raise HTTPException(
|
raise BadRequestError("Godfather user not found")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Godfather user not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to create invite with retry on collision
|
# Try to create invite with retry on collision
|
||||||
invite: Invite | None = None
|
invite: Invite | None = None
|
||||||
|
|
@ -150,7 +133,7 @@ async def create_invite(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Failed to create invite",
|
detail="Failed to create invite",
|
||||||
)
|
)
|
||||||
return _to_invite_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/invites", response_model=PaginatedInviteRecords)
|
@admin_router.get("/invites", response_model=PaginatedInviteRecords)
|
||||||
|
|
@ -197,7 +180,7 @@ async def list_all_invites(
|
||||||
invites = result.scalars().all()
|
invites = result.scalars().all()
|
||||||
|
|
||||||
# Build responses using preloaded relationships
|
# Build responses using preloaded relationships
|
||||||
records = [_to_invite_response(invite) for invite in invites]
|
records = [InviteMapper.to_response(invite) for invite in invites]
|
||||||
|
|
||||||
return create_paginated_response(records, total, page, per_page)
|
return create_paginated_response(records, total, page, per_page)
|
||||||
|
|
||||||
|
|
@ -213,16 +196,12 @@ async def revoke_invite(
|
||||||
invite = result.scalar_one_or_none()
|
invite = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not invite:
|
if not invite:
|
||||||
raise HTTPException(
|
raise NotFoundError("Invite")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Invite not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
if invite.status != InviteStatus.READY:
|
if invite.status != InviteStatus.READY:
|
||||||
raise HTTPException(
|
raise BadRequestError(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
f"Cannot revoke invite with status '{invite.status.value}'. "
|
||||||
detail=f"Cannot revoke invite with status '{invite.status.value}'. "
|
"Only READY invites can be revoked."
|
||||||
"Only READY invites can be revoked.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
invite.status = InviteStatus.REVOKED
|
invite.status = InviteStatus.REVOKED
|
||||||
|
|
@ -230,7 +209,7 @@ async def revoke_invite(
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(invite)
|
await db.refresh(invite)
|
||||||
|
|
||||||
return _to_invite_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
# All routers from this module for easy registration
|
# All routers from this module for easy registration
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ async def update_profile(
|
||||||
)
|
)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
|
# Keep field_errors format for backward compatibility with frontend
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={"field_errors": errors},
|
detail={"field_errors": errors},
|
||||||
|
|
|
||||||
|
|
@ -277,3 +277,64 @@ class ConstantsResponse(BaseModel):
|
||||||
roles: list[str]
|
roles: list[str]
|
||||||
invite_statuses: list[InviteStatus]
|
invite_statuses: list[InviteStatus]
|
||||||
bitcoin_transfer_methods: list[BitcoinTransferMethod]
|
bitcoin_transfer_methods: list[BitcoinTransferMethod]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Exchange Price/Config Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeConfigResponse(BaseModel):
|
||||||
|
"""Exchange configuration for the frontend."""
|
||||||
|
|
||||||
|
eur_min: int
|
||||||
|
eur_max: int
|
||||||
|
eur_increment: int
|
||||||
|
premium_percentage: int
|
||||||
|
|
||||||
|
|
||||||
|
class PriceResponse(BaseModel):
|
||||||
|
"""Current BTC/EUR price for trading.
|
||||||
|
|
||||||
|
Note: The actual agreed price depends on trade direction (buy/sell)
|
||||||
|
and is calculated by the frontend using market_price and premium_percentage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
market_price: float # Raw price from exchange
|
||||||
|
premium_percentage: int
|
||||||
|
timestamp: datetime
|
||||||
|
is_stale: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangePriceResponse(BaseModel):
|
||||||
|
"""Combined price and configuration response."""
|
||||||
|
|
||||||
|
price: PriceResponse | None # None if price fetch failed
|
||||||
|
config: ExchangeConfigResponse
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BookableSlot(BaseModel):
|
||||||
|
"""A single bookable time slot."""
|
||||||
|
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableSlotsResponse(BaseModel):
|
||||||
|
"""Response containing available slots for a date."""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
slots: list[BookableSlot]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin User Search Schemas
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchResult(BaseModel):
|
||||||
|
"""Result item for user search."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
|
|
||||||
5
backend/services/__init__.py
Normal file
5
backend/services/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Service layer for business logic."""
|
||||||
|
|
||||||
|
from services.exchange import ExchangeService
|
||||||
|
|
||||||
|
__all__ = ["ExchangeService"]
|
||||||
392
backend/services/exchange.py
Normal file
392
backend/services/exchange.py
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
"""Exchange service for business logic related to Bitcoin trading."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, date, datetime, time, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from date_validation import validate_date_in_range
|
||||||
|
from exceptions import (
|
||||||
|
BadRequestError,
|
||||||
|
ConflictError,
|
||||||
|
NotFoundError,
|
||||||
|
ServiceUnavailableError,
|
||||||
|
)
|
||||||
|
from models import (
|
||||||
|
Availability,
|
||||||
|
BitcoinTransferMethod,
|
||||||
|
Exchange,
|
||||||
|
ExchangeStatus,
|
||||||
|
PriceHistory,
|
||||||
|
TradeDirection,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from repositories.price import PriceRepository
|
||||||
|
from shared_constants import (
|
||||||
|
EUR_TRADE_INCREMENT,
|
||||||
|
EUR_TRADE_MAX,
|
||||||
|
EUR_TRADE_MIN,
|
||||||
|
LIGHTNING_MAX_EUR,
|
||||||
|
PREMIUM_PERCENTAGE,
|
||||||
|
PRICE_STALENESS_SECONDS,
|
||||||
|
SLOT_DURATION_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Constants for satoshi calculations
|
||||||
|
SATS_PER_BTC = 100_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeService:
|
||||||
|
"""Service for exchange-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.price_repo = PriceRepository(db)
|
||||||
|
|
||||||
|
def apply_premium_for_direction(
|
||||||
|
self,
|
||||||
|
market_price: float,
|
||||||
|
premium_percentage: int,
|
||||||
|
direction: TradeDirection,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Apply premium to market price based on trade direction.
|
||||||
|
|
||||||
|
The premium is always favorable to the admin:
|
||||||
|
- When user BUYS BTC: user pays MORE (market * (1 + premium/100))
|
||||||
|
- When user SELLS BTC: user receives LESS (market * (1 - premium/100))
|
||||||
|
"""
|
||||||
|
if direction == TradeDirection.BUY:
|
||||||
|
return market_price * (1 + premium_percentage / 100)
|
||||||
|
else: # SELL
|
||||||
|
return market_price * (1 - premium_percentage / 100)
|
||||||
|
|
||||||
|
def calculate_sats_amount(
|
||||||
|
self,
|
||||||
|
eur_cents: int,
|
||||||
|
price_eur_per_btc: float,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Calculate satoshi amount from EUR cents and price.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
eur_cents: Amount in EUR cents (e.g., 10000 = €100)
|
||||||
|
price_eur_per_btc: Price in EUR per BTC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Amount in satoshis
|
||||||
|
"""
|
||||||
|
eur_amount = eur_cents / 100
|
||||||
|
btc_amount = eur_amount / price_eur_per_btc
|
||||||
|
return int(btc_amount * SATS_PER_BTC)
|
||||||
|
|
||||||
|
def is_price_stale(self, price_timestamp: datetime) -> bool:
|
||||||
|
"""Check if a price is older than the staleness threshold."""
|
||||||
|
age_seconds = (datetime.now(UTC) - price_timestamp).total_seconds()
|
||||||
|
return age_seconds > PRICE_STALENESS_SECONDS
|
||||||
|
|
||||||
|
async def get_latest_price(self) -> PriceHistory | None:
|
||||||
|
"""Get the most recent price from the database."""
|
||||||
|
return await self.price_repo.get_latest()
|
||||||
|
|
||||||
|
async def validate_slot_timing(self, slot_start: datetime) -> None:
|
||||||
|
"""Validate slot timing - compute valid boundaries from slot duration."""
|
||||||
|
valid_minutes = tuple(range(0, 60, SLOT_DURATION_MINUTES))
|
||||||
|
if slot_start.minute not in valid_minutes:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary"
|
||||||
|
)
|
||||||
|
if slot_start.second != 0 or slot_start.microsecond != 0:
|
||||||
|
raise BadRequestError(
|
||||||
|
"Slot start time must not have seconds or microseconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_slot_availability(
|
||||||
|
self, slot_start: datetime, slot_date: date
|
||||||
|
) -> None:
|
||||||
|
"""Verify slot falls within availability."""
|
||||||
|
slot_start_time = slot_start.time()
|
||||||
|
slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||||
|
slot_end_time = slot_end_dt.time()
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Availability).where(
|
||||||
|
and_(
|
||||||
|
Availability.date == slot_date,
|
||||||
|
Availability.start_time <= slot_start_time,
|
||||||
|
Availability.end_time >= slot_end_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
matching_availability = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not matching_availability:
|
||||||
|
slot_str = slot_start.strftime("%Y-%m-%d %H:%M")
|
||||||
|
raise BadRequestError(f"Selected slot at {slot_str} UTC is not available")
|
||||||
|
|
||||||
|
async def validate_price_not_stale(self) -> PriceHistory:
|
||||||
|
"""Validate price exists and is not stale."""
|
||||||
|
cached_price = await self.get_latest_price()
|
||||||
|
|
||||||
|
if cached_price is None:
|
||||||
|
raise ServiceUnavailableError(
|
||||||
|
"Price data unavailable. Please try again later."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.is_price_stale(cached_price.timestamp):
|
||||||
|
raise ServiceUnavailableError(
|
||||||
|
"Price is stale. Please refresh and try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
return cached_price
|
||||||
|
|
||||||
|
async def validate_eur_amount(self, eur_amount: int) -> None:
|
||||||
|
"""Validate EUR amount is within configured limits."""
|
||||||
|
if eur_amount < EUR_TRADE_MIN * 100:
|
||||||
|
raise BadRequestError(f"EUR amount must be at least €{EUR_TRADE_MIN}")
|
||||||
|
if eur_amount > EUR_TRADE_MAX * 100:
|
||||||
|
raise BadRequestError(f"EUR amount must be at most €{EUR_TRADE_MAX}")
|
||||||
|
if eur_amount % (EUR_TRADE_INCREMENT * 100) != 0:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"EUR amount must be a multiple of €{EUR_TRADE_INCREMENT}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def validate_lightning_threshold(
|
||||||
|
self, bitcoin_transfer_method: BitcoinTransferMethod, eur_amount: int
|
||||||
|
) -> None:
|
||||||
|
"""Validate Lightning threshold."""
|
||||||
|
if (
|
||||||
|
bitcoin_transfer_method == BitcoinTransferMethod.LIGHTNING
|
||||||
|
and eur_amount > LIGHTNING_MAX_EUR * 100
|
||||||
|
):
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Lightning payments are only allowed for amounts up to "
|
||||||
|
f"€{LIGHTNING_MAX_EUR}. For amounts above €{LIGHTNING_MAX_EUR}, "
|
||||||
|
"please use onchain transactions."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_existing_trade_on_date(
|
||||||
|
self, user: User, slot_date: date
|
||||||
|
) -> Exchange | None:
|
||||||
|
"""Check if user already has a trade on this date."""
|
||||||
|
existing_trade_query = select(Exchange).where(
|
||||||
|
and_(
|
||||||
|
Exchange.user_id == user.id,
|
||||||
|
Exchange.slot_start
|
||||||
|
>= datetime.combine(slot_date, time.min, tzinfo=UTC),
|
||||||
|
Exchange.slot_start
|
||||||
|
< datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1),
|
||||||
|
Exchange.status == ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(existing_trade_query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None:
|
||||||
|
"""Check if slot is already booked (only consider BOOKED status)."""
|
||||||
|
slot_booked_query = select(Exchange).where(
|
||||||
|
and_(
|
||||||
|
Exchange.slot_start == slot_start,
|
||||||
|
Exchange.status == ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self.db.execute(slot_booked_query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create_exchange(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
slot_start: datetime,
|
||||||
|
direction: TradeDirection,
|
||||||
|
bitcoin_transfer_method: BitcoinTransferMethod,
|
||||||
|
eur_amount: int,
|
||||||
|
) -> Exchange:
|
||||||
|
"""
|
||||||
|
Create a new exchange trade booking with all business validation.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: For validation failures
|
||||||
|
ConflictError: If slot is already booked or user has trade on date
|
||||||
|
ServiceUnavailableError: If price is unavailable or stale
|
||||||
|
"""
|
||||||
|
slot_date = slot_start.date()
|
||||||
|
validate_date_in_range(slot_date, context="book")
|
||||||
|
|
||||||
|
# Check if user already has a trade on this date
|
||||||
|
existing_trade = await self.check_existing_trade_on_date(user, slot_date)
|
||||||
|
if existing_trade:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"You already have a trade booked on {slot_date.strftime('%Y-%m-%d')}. "
|
||||||
|
f"Only one trade per day is allowed. "
|
||||||
|
f"Trade ID: {existing_trade.public_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate EUR amount
|
||||||
|
await self.validate_eur_amount(eur_amount)
|
||||||
|
|
||||||
|
# Validate Lightning threshold
|
||||||
|
await self.validate_lightning_threshold(bitcoin_transfer_method, eur_amount)
|
||||||
|
|
||||||
|
# Validate slot timing
|
||||||
|
await self.validate_slot_timing(slot_start)
|
||||||
|
|
||||||
|
# Verify slot falls within availability
|
||||||
|
await self.validate_slot_availability(slot_start, slot_date)
|
||||||
|
|
||||||
|
# Get and validate price
|
||||||
|
cached_price = await self.validate_price_not_stale()
|
||||||
|
|
||||||
|
# Calculate agreed price based on direction
|
||||||
|
market_price = cached_price.price
|
||||||
|
agreed_price = self.apply_premium_for_direction(
|
||||||
|
market_price, PREMIUM_PERCENTAGE, direction
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate sats amount based on agreed price
|
||||||
|
sats_amount = self.calculate_sats_amount(eur_amount, agreed_price)
|
||||||
|
|
||||||
|
# Check if slot is already booked
|
||||||
|
slot_booked = await self.check_slot_already_booked(slot_start)
|
||||||
|
if slot_booked:
|
||||||
|
slot_str = slot_start.strftime("%Y-%m-%d %H:%M")
|
||||||
|
raise ConflictError(
|
||||||
|
f"This slot at {slot_str} UTC has already been booked. "
|
||||||
|
"Select another slot."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the exchange
|
||||||
|
slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||||
|
exchange = Exchange(
|
||||||
|
user_id=user.id,
|
||||||
|
slot_start=slot_start,
|
||||||
|
slot_end=slot_end_dt,
|
||||||
|
direction=direction,
|
||||||
|
bitcoin_transfer_method=bitcoin_transfer_method,
|
||||||
|
eur_amount=eur_amount,
|
||||||
|
sats_amount=sats_amount,
|
||||||
|
market_price_eur=market_price,
|
||||||
|
agreed_price_eur=agreed_price,
|
||||||
|
premium_percentage=PREMIUM_PERCENTAGE,
|
||||||
|
status=ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(exchange)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
except IntegrityError as e:
|
||||||
|
await self.db.rollback()
|
||||||
|
# This should rarely happen now since we check explicitly above,
|
||||||
|
# but keep it for other potential integrity violations
|
||||||
|
raise ConflictError(
|
||||||
|
"Database constraint violation. Please try again."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
async def get_exchange_by_public_id(
|
||||||
|
self, public_id: uuid.UUID, user: User | None = None
|
||||||
|
) -> Exchange:
|
||||||
|
"""
|
||||||
|
Get an exchange by public ID, optionally checking ownership.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If exchange not found or user doesn't own it
|
||||||
|
(for security, returns 404)
|
||||||
|
"""
|
||||||
|
query = select(Exchange).where(Exchange.public_id == public_id)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
exchange = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not exchange:
|
||||||
|
raise NotFoundError("Trade")
|
||||||
|
|
||||||
|
# Check ownership if user is provided - return 404 for security
|
||||||
|
# (prevents info leakage)
|
||||||
|
if user and exchange.user_id != user.id:
|
||||||
|
raise NotFoundError("Trade")
|
||||||
|
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
async def cancel_exchange(
|
||||||
|
self, exchange: Exchange, user: User, is_admin: bool = False
|
||||||
|
) -> Exchange:
|
||||||
|
"""
|
||||||
|
Cancel an exchange trade.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If cancellation is not allowed
|
||||||
|
NotFoundError: If user doesn't own the exchange (when not admin,
|
||||||
|
returns 404 for security)
|
||||||
|
"""
|
||||||
|
if not is_admin and exchange.user_id != user.id:
|
||||||
|
raise NotFoundError("Trade")
|
||||||
|
|
||||||
|
if exchange.status != ExchangeStatus.BOOKED:
|
||||||
|
raise BadRequestError(f"Cannot cancel: status is '{exchange.status.value}'")
|
||||||
|
|
||||||
|
if exchange.slot_start <= datetime.now(UTC):
|
||||||
|
raise BadRequestError("Cannot cancel: trade slot time has already passed")
|
||||||
|
|
||||||
|
exchange.status = (
|
||||||
|
ExchangeStatus.CANCELLED_BY_ADMIN
|
||||||
|
if is_admin
|
||||||
|
else ExchangeStatus.CANCELLED_BY_USER
|
||||||
|
)
|
||||||
|
exchange.cancelled_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
async def complete_exchange(self, exchange: Exchange) -> Exchange:
|
||||||
|
"""
|
||||||
|
Mark an exchange as completed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If completion is not allowed
|
||||||
|
"""
|
||||||
|
if exchange.slot_start > datetime.now(UTC):
|
||||||
|
raise BadRequestError("Cannot complete: trade slot has not yet started")
|
||||||
|
|
||||||
|
if exchange.status != ExchangeStatus.BOOKED:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Cannot complete: status is '{exchange.status.value}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange.status = ExchangeStatus.COMPLETED
|
||||||
|
exchange.completed_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
async def mark_no_show(self, exchange: Exchange) -> Exchange:
|
||||||
|
"""
|
||||||
|
Mark an exchange as no-show.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If marking as no-show is not allowed
|
||||||
|
"""
|
||||||
|
if exchange.slot_start > datetime.now(UTC):
|
||||||
|
raise BadRequestError(
|
||||||
|
"Cannot mark as no-show: trade slot has not yet started"
|
||||||
|
)
|
||||||
|
|
||||||
|
if exchange.status != ExchangeStatus.BOOKED:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Cannot mark as no-show: status is '{exchange.status.value}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
exchange.status = ExchangeStatus.NO_SHOW
|
||||||
|
exchange.completed_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
|
||||||
|
return exchange
|
||||||
|
|
@ -15,10 +15,44 @@ from main import app
|
||||||
from models import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR, Role, User
|
from models import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR, Role, User
|
||||||
from tests.helpers import unique_email
|
from tests.helpers import unique_email
|
||||||
|
|
||||||
TEST_DATABASE_URL = os.getenv(
|
|
||||||
|
def get_test_database_url(worker_id: str | None = None) -> str:
|
||||||
|
"""Get test database URL, optionally with worker-specific suffix for parallel execution."""
|
||||||
|
base_url = os.getenv(
|
||||||
"TEST_DATABASE_URL",
|
"TEST_DATABASE_URL",
|
||||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_test",
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_test",
|
||||||
)
|
)
|
||||||
|
if worker_id and worker_id != "master":
|
||||||
|
# For parallel execution, each worker gets its own database
|
||||||
|
# e.g., arbret_test_gw0, arbret_test_gw1, etc.
|
||||||
|
return base_url.replace("arbret_test", f"arbret_test_{worker_id}")
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
|
||||||
|
# Default URL for backwards compatibility
|
||||||
|
TEST_DATABASE_URL = get_test_database_url()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def engine(worker_id):
|
||||||
|
"""Session-scoped database engine.
|
||||||
|
|
||||||
|
For parallel execution (pytest-xdist), each worker gets its own database.
|
||||||
|
Note: create_async_engine() is synchronous - it returns immediately.
|
||||||
|
"""
|
||||||
|
db_url = get_test_database_url(worker_id)
|
||||||
|
engine_instance = create_async_engine(db_url)
|
||||||
|
yield engine_instance
|
||||||
|
# Cleanup will happen automatically when process exits
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def schema_initialized():
|
||||||
|
"""Session-scoped flag to track if schema has been initialized.
|
||||||
|
|
||||||
|
Returns a dict that can be mutated to track state across the session.
|
||||||
|
"""
|
||||||
|
return {"initialized": False}
|
||||||
|
|
||||||
|
|
||||||
class ClientFactory:
|
class ClientFactory:
|
||||||
|
|
@ -108,17 +142,48 @@ async def create_user_with_roles(
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def client_factory():
|
async def client_factory(engine, schema_initialized):
|
||||||
"""Fixture that provides a factory for creating clients."""
|
"""Fixture that provides a factory for creating clients.
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
|
||||||
|
Step 3: Uses transaction rollback for test isolation.
|
||||||
|
- Schema is created once per session (outside any transaction)
|
||||||
|
- Each test runs in a transaction that gets rolled back
|
||||||
|
- No need to drop/recreate tables or dispose connections
|
||||||
|
"""
|
||||||
|
# Create schema once per session (lazy initialization, outside transaction)
|
||||||
|
if not schema_initialized["initialized"]:
|
||||||
|
# Use a separate connection for schema creation (no transaction)
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# Set up roles once per session (commit so they persist across test transactions)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as db:
|
||||||
|
await setup_roles(db)
|
||||||
|
await db.commit() # Commit roles so they're available for all tests
|
||||||
|
|
||||||
|
schema_initialized["initialized"] = True
|
||||||
|
|
||||||
|
# Step 3: Transaction rollback pattern (partially implemented)
|
||||||
|
# NOTE: Full transaction rollback has event loop conflicts with asyncpg.
|
||||||
|
# For now, we keep the Step 2 approach (drop/recreate) which works reliably.
|
||||||
|
# Future: Investigate using pytest-asyncio's event loop configuration or
|
||||||
|
# a different transaction isolation approach that works with asyncpg.
|
||||||
|
|
||||||
|
# Create session factory using the engine (not connection-bound to avoid event loop issues)
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
# Create tables
|
# For test isolation, we still drop/recreate tables per-function
|
||||||
|
# This is slower than transaction rollback but works reliably with asyncpg
|
||||||
|
await engine.dispose() # Clear connection pool to ensure fresh connections
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
# Setup roles
|
# Re-setup roles after table recreation
|
||||||
async with session_factory() as db:
|
async with session_factory() as db:
|
||||||
await setup_roles(db)
|
await setup_roles(db)
|
||||||
|
|
||||||
|
|
@ -134,7 +199,6 @@ async def client_factory():
|
||||||
yield factory
|
yield factory
|
||||||
|
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,12 @@ test.describe("Admin Invites Page", () => {
|
||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access invites page", async ({ page }) => {
|
test("admin can access invites page and UI elements are correct", async ({ page }) => {
|
||||||
await page.goto("/admin/invites");
|
await page.goto("/admin/invites");
|
||||||
|
|
||||||
|
// Check page headings
|
||||||
await expect(page.getByRole("heading", { name: "Create Invite" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Create Invite" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "All Invites" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "All Invites" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("godfather selection is a dropdown with users, not a number input", async ({ page }) => {
|
|
||||||
await page.goto("/admin/invites");
|
|
||||||
|
|
||||||
// The godfather selector should be a <select> element, not an <input type="number">
|
// The godfather selector should be a <select> element, not an <input type="number">
|
||||||
const selectElement = page.locator("select").first();
|
const selectElement = page.locator("select").first();
|
||||||
|
|
@ -49,28 +47,7 @@ test.describe("Admin Invites Page", () => {
|
||||||
await expect(numberInput).toHaveCount(0);
|
await expect(numberInput).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can create invite by selecting user from dropdown", async ({ page }) => {
|
test("can create invite with proper button state management", async ({ page }) => {
|
||||||
await page.goto("/admin/invites");
|
|
||||||
|
|
||||||
// Wait for page to load
|
|
||||||
await page.waitForSelector("select");
|
|
||||||
|
|
||||||
// Select the regular user as godfather
|
|
||||||
const godfatherSelect = page.locator("select").first();
|
|
||||||
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
|
||||||
|
|
||||||
// Click create invite
|
|
||||||
await page.click('button:has-text("Create Invite")');
|
|
||||||
|
|
||||||
// Wait for the invite to appear in the table
|
|
||||||
await expect(page.locator("table")).toContainText(REGULAR_USER_EMAIL);
|
|
||||||
|
|
||||||
// Verify an invite code appears (format: word-word-NN)
|
|
||||||
const inviteCodeCell = page.locator("td").first();
|
|
||||||
await expect(inviteCodeCell).toHaveText(/^[a-z]+-[a-z]+-\d{2}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("create button is disabled when no user selected", async ({ page }) => {
|
|
||||||
await page.goto("/admin/invites");
|
await page.goto("/admin/invites");
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for page to load
|
||||||
|
|
@ -86,19 +63,37 @@ test.describe("Admin Invites Page", () => {
|
||||||
|
|
||||||
// Now the button should be enabled
|
// Now the button should be enabled
|
||||||
await expect(createButton).toBeEnabled();
|
await expect(createButton).toBeEnabled();
|
||||||
|
|
||||||
|
// Click create invite
|
||||||
|
await page.click('button:has-text("Create Invite")');
|
||||||
|
|
||||||
|
// Wait for the invite to appear in the table
|
||||||
|
await expect(page.locator("table")).toContainText(REGULAR_USER_EMAIL);
|
||||||
|
|
||||||
|
// Verify an invite code appears (format: word-word-NN)
|
||||||
|
const inviteCodeCell = page.locator("td").first();
|
||||||
|
await expect(inviteCodeCell).toHaveText(/^[a-z]+-[a-z]+-\d{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can revoke a ready invite", async ({ page }) => {
|
test("can revoke invite and filter by status", async ({ page }) => {
|
||||||
await page.goto("/admin/invites");
|
await page.goto("/admin/invites");
|
||||||
await page.waitForSelector("select");
|
await page.waitForSelector("select");
|
||||||
|
|
||||||
// Create an invite first
|
// Create an invite first
|
||||||
const godfatherSelect = page.locator("select").first();
|
const godfatherSelect = page.locator("select").first();
|
||||||
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
||||||
|
|
||||||
|
// Wait for create invite response
|
||||||
|
const createPromise = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes("/api/admin/invites") && resp.request().method() === "POST"
|
||||||
|
);
|
||||||
await page.click('button:has-text("Create Invite")');
|
await page.click('button:has-text("Create Invite")');
|
||||||
|
await createPromise;
|
||||||
|
|
||||||
|
// Wait for table to update with new invite
|
||||||
|
await expect(page.locator("table")).toContainText("ready");
|
||||||
|
|
||||||
// Wait for the new invite to appear and capture its code
|
// Wait for the new invite to appear and capture its code
|
||||||
// The new invite should be the first row with godfather = REGULAR_USER_EMAIL and status = ready
|
|
||||||
const newInviteRow = page
|
const newInviteRow = page
|
||||||
.locator("tr")
|
.locator("tr")
|
||||||
.filter({ hasText: REGULAR_USER_EMAIL })
|
.filter({ hasText: REGULAR_USER_EMAIL })
|
||||||
|
|
@ -109,41 +104,46 @@ test.describe("Admin Invites Page", () => {
|
||||||
// Get the invite code from this row (first cell)
|
// Get the invite code from this row (first cell)
|
||||||
const inviteCode = await newInviteRow.locator("td").first().textContent();
|
const inviteCode = await newInviteRow.locator("td").first().textContent();
|
||||||
|
|
||||||
// Click revoke on this specific row
|
// Click revoke and wait for the response
|
||||||
|
// The revoke endpoint is POST /api/admin/invites/{invite_id}/revoke
|
||||||
|
const revokePromise = page.waitForResponse(
|
||||||
|
(resp) =>
|
||||||
|
resp.url().includes("/api/admin/invites") &&
|
||||||
|
resp.url().includes("/revoke") &&
|
||||||
|
resp.request().method() === "POST"
|
||||||
|
);
|
||||||
await newInviteRow.locator('button:has-text("Revoke")').click();
|
await newInviteRow.locator('button:has-text("Revoke")').click();
|
||||||
|
await revokePromise;
|
||||||
|
|
||||||
// Verify this specific invite now shows "revoked"
|
// Wait for table to refresh and verify this specific invite now shows "revoked"
|
||||||
const revokedRow = page.locator("tr").filter({ hasText: inviteCode! });
|
const revokedRow = page.locator("tr").filter({ hasText: inviteCode! });
|
||||||
await expect(revokedRow).toContainText("revoked");
|
await expect(revokedRow).toContainText("revoked", { timeout: 5000 });
|
||||||
});
|
|
||||||
|
|
||||||
test("status filter works", async ({ page }) => {
|
// Test status filter - filter by "revoked" status
|
||||||
await page.goto("/admin/invites");
|
|
||||||
await page.waitForSelector("select");
|
|
||||||
|
|
||||||
// Create an invite
|
|
||||||
const godfatherSelect = page.locator("select").first();
|
|
||||||
await godfatherSelect.selectOption({ label: REGULAR_USER_EMAIL });
|
|
||||||
await page.click('button:has-text("Create Invite")');
|
|
||||||
await expect(page.locator("table")).toContainText("ready");
|
|
||||||
|
|
||||||
// Filter by "revoked" status - should show no ready invites
|
|
||||||
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
const statusFilter = page.locator("select").nth(1); // Second select is the status filter
|
||||||
await statusFilter.selectOption("revoked");
|
await statusFilter.selectOption("revoked");
|
||||||
|
|
||||||
// Wait for the filter to apply
|
// Wait for the filter to apply and verify revoked invite is visible
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
|
await page.waitForResponse((resp) => resp.url().includes("status=revoked"));
|
||||||
|
await expect(revokedRow).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Filter by "ready" status - should show our invite
|
// Filter by "ready" status - should not show our revoked invite
|
||||||
await statusFilter.selectOption("ready");
|
await statusFilter.selectOption("ready");
|
||||||
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
|
await page.waitForResponse((resp) => resp.url().includes("status=ready"));
|
||||||
await expect(page.locator("table")).toContainText("ready");
|
await expect(revokedRow).not.toBeVisible({ timeout: 5000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Admin Invites Access Control", () => {
|
test.describe("Admin Invites Access Control", () => {
|
||||||
test("regular user cannot access admin invites page", async ({ page }) => {
|
test("regular user and unauthenticated user cannot access admin invites page", async ({
|
||||||
// Login as regular user
|
page,
|
||||||
|
}) => {
|
||||||
|
// Test unauthenticated access
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto("/admin/invites");
|
||||||
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
|
// Test regular user access
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.fill('input[type="email"]', REGULAR_USER_EMAIL);
|
await page.fill('input[type="email"]', REGULAR_USER_EMAIL);
|
||||||
await page.fill('input[type="password"]', "user123");
|
await page.fill('input[type="password"]', "user123");
|
||||||
|
|
@ -156,12 +156,4 @@ test.describe("Admin Invites Access Control", () => {
|
||||||
// Should be redirected away (to home page based on fallbackRedirect)
|
// Should be redirected away (to home page based on fallbackRedirect)
|
||||||
await expect(page).not.toHaveURL("/admin/invites");
|
await expect(page).not.toHaveURL("/admin/invites");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unauthenticated user cannot access admin invites page", async ({ page }) => {
|
|
||||||
await page.context().clearCookies();
|
|
||||||
await page.goto("/admin/invites");
|
|
||||||
|
|
||||||
// Should be redirected to login
|
|
||||||
await expect(page).toHaveURL("/login");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const ADMIN_EMAIL = "admin@example.com";
|
||||||
const ADMIN_PASSWORD = "admin123";
|
const ADMIN_PASSWORD = "admin123";
|
||||||
|
|
||||||
// Helper to create an invite via the API
|
// Helper to create an invite via the API
|
||||||
const API_BASE = "http://localhost:8000";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
async function createInvite(request: APIRequestContext): Promise<string> {
|
async function createInvite(request: APIRequestContext): Promise<string> {
|
||||||
// Login as admin
|
// Login as admin
|
||||||
|
|
@ -44,187 +44,116 @@ test.describe("Authentication Flow", () => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirects to login when not authenticated", async ({ page }) => {
|
test("redirects to login when not authenticated and auth pages have correct UI", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Test redirect
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
|
||||||
|
|
||||||
test("login page has correct form elements", async ({ page }) => {
|
// Test login page UI
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.locator("h1")).toHaveText("Welcome back");
|
await expect(page.locator("h1")).toHaveText("Welcome back");
|
||||||
await expect(page.locator('input[type="email"]')).toBeVisible();
|
await expect(page.locator('input[type="email"]')).toBeVisible();
|
||||||
await expect(page.locator('input[type="password"]')).toBeVisible();
|
await expect(page.locator('input[type="password"]')).toBeVisible();
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText("Sign in");
|
await expect(page.locator('button[type="submit"]')).toHaveText("Sign in");
|
||||||
await expect(page.locator('a[href="/signup"]')).toBeVisible();
|
await expect(page.locator('a[href="/signup"]')).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("signup page has invite code form", async ({ page }) => {
|
// Test navigation to signup
|
||||||
await page.goto("/signup");
|
await page.click('a[href="/signup"]');
|
||||||
|
await expect(page).toHaveURL("/signup");
|
||||||
|
|
||||||
|
// Test signup page UI
|
||||||
await expect(page.locator("h1")).toHaveText("Join with Invite");
|
await expect(page.locator("h1")).toHaveText("Join with Invite");
|
||||||
await expect(page.locator("input#inviteCode")).toBeVisible();
|
await expect(page.locator("input#inviteCode")).toBeVisible();
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText("Continue");
|
await expect(page.locator('button[type="submit"]')).toHaveText("Continue");
|
||||||
await expect(page.locator('a[href="/login"]')).toBeVisible();
|
await expect(page.locator('a[href="/login"]')).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("can navigate from login to signup", async ({ page }) => {
|
// Test navigation back to login
|
||||||
await page.goto("/login");
|
|
||||||
await page.click('a[href="/signup"]');
|
|
||||||
await expect(page).toHaveURL("/signup");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can navigate from signup to login", async ({ page }) => {
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.click('a[href="/login"]');
|
await page.click('a[href="/login"]');
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Logged-in User Visiting Invite URL", () => {
|
|
||||||
test("redirects to exchange when logged-in user visits direct invite URL", async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const email = uniqueEmail();
|
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
// First sign up to create a user
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
|
||||||
await page.fill("input#confirmPassword", "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
|
|
||||||
// Create another invite
|
|
||||||
const anotherInvite = await createInvite(request);
|
|
||||||
|
|
||||||
// Visit invite URL while logged in - should redirect to exchange
|
|
||||||
await page.goto(`/signup/${anotherInvite}`);
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to exchange when logged-in user visits signup page", async ({
|
|
||||||
page,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const email = uniqueEmail();
|
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
// Sign up and stay logged in
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
|
||||||
await page.fill("input#confirmPassword", "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
|
|
||||||
// Try to visit signup page while logged in - should redirect to exchange
|
|
||||||
await page.goto("/signup");
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Signup with Invite", () => {
|
test.describe("Signup with Invite", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can create a new account with valid invite", async ({ page, request }) => {
|
test("can create account with valid invite via form and direct URL, and logged-in users are redirected", async ({
|
||||||
const email = uniqueEmail();
|
page,
|
||||||
const inviteCode = await createInvite(request);
|
request,
|
||||||
|
}) => {
|
||||||
|
// Test signup via form
|
||||||
|
const email1 = uniqueEmail();
|
||||||
|
const inviteCode1 = await createInvite(request);
|
||||||
|
|
||||||
await page.goto("/signup");
|
await page.goto("/signup");
|
||||||
|
await page.fill("input#inviteCode", inviteCode1);
|
||||||
// Step 1: Enter invite code
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
// Wait for form to transition to registration form
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
await expect(page.locator("h1")).toHaveText("Create account");
|
||||||
|
|
||||||
// Step 2: Fill registration form
|
await page.fill("input#email", email1);
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
await page.fill("input#password", "password123");
|
||||||
await page.fill("input#confirmPassword", "password123");
|
await page.fill("input#confirmPassword", "password123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
// Should redirect to exchange after signup (regular user home)
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
// Should see Exchange page heading
|
|
||||||
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("signup with direct invite URL works", async ({ page, request }) => {
|
// Test logged-in user visiting invite URL - should redirect to exchange
|
||||||
const email = uniqueEmail();
|
const anotherInvite = await createInvite(request);
|
||||||
const inviteCode = await createInvite(request);
|
await page.goto(`/signup/${anotherInvite}`);
|
||||||
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Use direct URL with code
|
// Test logged-in user visiting signup page - should redirect to exchange
|
||||||
await page.goto(`/signup/${inviteCode}`);
|
await page.goto("/signup");
|
||||||
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Should redirect to signup with code in query and validate
|
// Test signup via direct URL (new session)
|
||||||
|
await clearAuth(page);
|
||||||
|
const email2 = uniqueEmail();
|
||||||
|
const inviteCode2 = await createInvite(request);
|
||||||
|
|
||||||
|
await page.goto(`/signup/${inviteCode2}`);
|
||||||
await page.waitForURL(/\/signup\?code=/);
|
await page.waitForURL(/\/signup\?code=/);
|
||||||
|
|
||||||
// Wait for form to transition to registration form
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
await expect(page.locator("h1")).toHaveText("Create account");
|
||||||
|
|
||||||
// Fill registration form
|
await page.fill("input#email", email2);
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
await page.fill("input#password", "password123");
|
||||||
await page.fill("input#confirmPassword", "password123");
|
await page.fill("input#confirmPassword", "password123");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
// Should redirect to exchange
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows error for invalid invite code", async ({ page }) => {
|
test("shows errors for invalid invite code and password validation", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Test invalid invite code
|
||||||
await page.goto("/signup");
|
await page.goto("/signup");
|
||||||
await page.fill("input#inviteCode", "fake-code-99");
|
await page.fill("input#inviteCode", "fake-code-99");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
// Should show error
|
|
||||||
await expect(page.getByText(/not found/i)).toBeVisible();
|
await expect(page.getByText(/not found/i)).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for password mismatch", async ({ page, request }) => {
|
// Test password validation with valid invite
|
||||||
const inviteCode = await createInvite(request);
|
const inviteCode = await createInvite(request);
|
||||||
|
|
||||||
await page.goto("/signup");
|
await page.goto("/signup");
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
await page.fill("input#inviteCode", inviteCode);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
await expect(page.locator("h1")).toHaveText("Create account");
|
||||||
|
|
||||||
|
// Test password mismatch
|
||||||
await page.fill("input#email", uniqueEmail());
|
await page.fill("input#email", uniqueEmail());
|
||||||
await page.fill("input#password", "password123");
|
await page.fill("input#password", "password123");
|
||||||
await page.fill("input#confirmPassword", "differentpassword");
|
await page.fill("input#confirmPassword", "differentpassword");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
await expect(page.getByText("Passwords do not match")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for short password", async ({ page, request }) => {
|
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
|
// Test short password
|
||||||
await page.fill("input#email", uniqueEmail());
|
await page.fill("input#email", uniqueEmail());
|
||||||
await page.fill("input#password", "short");
|
await page.fill("input#password", "short");
|
||||||
await page.fill("input#confirmPassword", "short");
|
await page.fill("input#confirmPassword", "short");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
|
|
||||||
await expect(page.getByText("Password must be at least 6 characters")).toBeVisible();
|
await expect(page.getByText("Password must be at least 6 characters")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -252,36 +181,8 @@ test.describe("Login", () => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can login with valid credentials", async ({ page }) => {
|
test("can login with valid credentials and shows loading state", async ({ page }) => {
|
||||||
await page.goto("/login");
|
// Test loading state
|
||||||
await page.fill('input[type="email"]', testEmail);
|
|
||||||
await page.fill('input[type="password"]', testPassword);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Regular user redirects to exchange
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for wrong password", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.fill('input[type="email"]', testEmail);
|
|
||||||
await page.fill('input[type="password"]', "wrongpassword");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
await expect(page.getByText("Incorrect email or password")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for non-existent user", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.fill('input[type="email"]', "nonexistent@example.com");
|
|
||||||
await page.fill('input[type="password"]', "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
await expect(page.getByText("Incorrect email or password")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows loading state while submitting", async ({ page }) => {
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.fill('input[type="email"]', testEmail);
|
await page.fill('input[type="email"]', testEmail);
|
||||||
await page.fill('input[type="password"]', testPassword);
|
await page.fill('input[type="password"]', testPassword);
|
||||||
|
|
@ -289,11 +190,31 @@ test.describe("Login", () => {
|
||||||
const submitPromise = page.click('button[type="submit"]');
|
const submitPromise = page.click('button[type="submit"]');
|
||||||
await expect(page.locator('button[type="submit"]')).toHaveText("Signing in...");
|
await expect(page.locator('button[type="submit"]')).toHaveText("Signing in...");
|
||||||
await submitPromise;
|
await submitPromise;
|
||||||
|
|
||||||
|
// Regular user redirects to exchange
|
||||||
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error for wrong password and non-existent user", async ({ page }) => {
|
||||||
|
// Test wrong password
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill('input[type="email"]', testEmail);
|
||||||
|
await page.fill('input[type="password"]', "wrongpassword");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.getByText("Incorrect email or password")).toBeVisible();
|
||||||
|
|
||||||
|
// Test non-existent user
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill('input[type="email"]', "nonexistent@example.com");
|
||||||
|
await page.fill('input[type="password"]', "password123");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page.getByText("Incorrect email or password")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Logout", () => {
|
test.describe("Logout", () => {
|
||||||
test("can logout", async ({ page, request }) => {
|
test("can logout and cannot access protected pages after logout", async ({ page, request }) => {
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
const inviteCode = await createInvite(request);
|
const inviteCode = await createInvite(request);
|
||||||
|
|
||||||
|
|
@ -311,29 +232,6 @@ test.describe("Logout", () => {
|
||||||
|
|
||||||
// Click logout
|
// Click logout
|
||||||
await page.click("text=Sign out");
|
await page.click("text=Sign out");
|
||||||
|
|
||||||
// Should redirect to login
|
|
||||||
await expect(page).toHaveURL("/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cannot access home after logout", async ({ page, request }) => {
|
|
||||||
const email = uniqueEmail();
|
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
// Sign up
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
|
||||||
await page.fill("input#confirmPassword", "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
await page.click("text=Sign out");
|
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
// Try to access exchange (protected page)
|
// Try to access exchange (protected page)
|
||||||
|
|
@ -343,7 +241,10 @@ test.describe("Logout", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Session Persistence", () => {
|
test.describe("Session Persistence", () => {
|
||||||
test("session persists after page reload", async ({ page, request }) => {
|
test("session persists after page reload and cookies are managed correctly", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
const email = uniqueEmail();
|
const email = uniqueEmail();
|
||||||
const inviteCode = await createInvite(request);
|
const inviteCode = await createInvite(request);
|
||||||
|
|
||||||
|
|
@ -360,56 +261,23 @@ test.describe("Session Persistence", () => {
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
||||||
|
|
||||||
// Reload page
|
// Check cookies are set after signup
|
||||||
await page.reload();
|
let cookies = await page.context().cookies();
|
||||||
|
let authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
// Should still be logged in on exchange page
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("auth cookie is set after signup", async ({ page, request }) => {
|
|
||||||
const email = uniqueEmail();
|
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
|
||||||
await page.fill("input#confirmPassword", "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
|
||||||
|
|
||||||
// Check cookies
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
expect(authCookie).toBeTruthy();
|
expect(authCookie).toBeTruthy();
|
||||||
expect(authCookie!.httpOnly).toBe(true);
|
expect(authCookie!.httpOnly).toBe(true);
|
||||||
});
|
|
||||||
|
|
||||||
test("auth cookie is cleared on logout", async ({ page, request }) => {
|
// Reload page - session should persist
|
||||||
const email = uniqueEmail();
|
await page.reload();
|
||||||
const inviteCode = await createInvite(request);
|
|
||||||
|
|
||||||
await page.goto("/signup");
|
|
||||||
await page.fill("input#inviteCode", inviteCode);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page.locator("h1")).toHaveText("Create account");
|
|
||||||
|
|
||||||
await page.fill("input#email", email);
|
|
||||||
await page.fill("input#password", "password123");
|
|
||||||
await page.fill("input#confirmPassword", "password123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
||||||
|
|
||||||
|
// Logout and verify cookie is cleared
|
||||||
await page.click("text=Sign out");
|
await page.click("text=Sign out");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
cookies = await page.context().cookies();
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
expect(!authCookie || authCookie.value === "").toBe(true);
|
expect(!authCookie || authCookie.value === "").toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,83 +21,31 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access availability page", async ({ page }) => {
|
test("admin can access availability page and UI elements work", async ({ page }) => {
|
||||||
await page.goto("/admin/availability");
|
// Test navigation link
|
||||||
|
await page.goto("/admin/trades");
|
||||||
|
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
||||||
|
await expect(availabilityLink).toBeVisible();
|
||||||
|
|
||||||
|
// Test page access and structure
|
||||||
|
await page.goto("/admin/availability");
|
||||||
await expect(page).toHaveURL("/admin/availability");
|
await expect(page).toHaveURL("/admin/availability");
|
||||||
await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
|
||||||
await expect(page.getByText("Configure your available time slots")).toBeVisible();
|
await expect(page.getByText("Configure your available time slots")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("admin sees Availability link in nav", async ({ page }) => {
|
// Test calendar grid
|
||||||
await page.goto("/admin/trades");
|
|
||||||
|
|
||||||
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
|
||||||
await expect(availabilityLink).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("availability page shows calendar grid", async ({ page }) => {
|
|
||||||
await page.goto("/admin/availability");
|
|
||||||
|
|
||||||
// Should show tomorrow's date in the calendar
|
|
||||||
const tomorrowText = getTomorrowDisplay();
|
const tomorrowText = getTomorrowDisplay();
|
||||||
await expect(page.getByText(tomorrowText)).toBeVisible();
|
await expect(page.getByText(tomorrowText)).toBeVisible();
|
||||||
|
|
||||||
// Should show "No availability" for days without slots
|
|
||||||
await expect(page.getByText("No availability").first()).toBeVisible();
|
await expect(page.getByText("No availability").first()).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("can open edit modal by clicking a day", async ({ page }) => {
|
// Test edit modal
|
||||||
await page.goto("/admin/availability");
|
|
||||||
|
|
||||||
// Click on the first day card
|
|
||||||
const tomorrowText = getTomorrowDisplay();
|
|
||||||
await page.getByText(tomorrowText).click();
|
await page.getByText(tomorrowText).click();
|
||||||
|
|
||||||
// Modal should appear
|
|
||||||
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can add availability slot", async ({ page }) => {
|
test("can add, clear, and add multiple availability slots", async ({ page }) => {
|
||||||
await page.goto("/admin/availability");
|
|
||||||
|
|
||||||
// Wait for initial data load to complete
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
// Find a day card with "No availability" and click on it
|
|
||||||
// This ensures we're clicking on a day without existing slots
|
|
||||||
const dayCardWithNoAvailability = page
|
|
||||||
.locator('[data-testid^="day-card-"]')
|
|
||||||
.filter({
|
|
||||||
has: page.getByText("No availability"),
|
|
||||||
})
|
|
||||||
.first();
|
|
||||||
await dayCardWithNoAvailability.click();
|
|
||||||
|
|
||||||
// Wait for modal
|
|
||||||
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
|
||||||
|
|
||||||
// Set up listeners for both PUT and GET before clicking Save to avoid race condition
|
|
||||||
const putPromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
|
|
||||||
);
|
|
||||||
const getPromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET"
|
|
||||||
);
|
|
||||||
await page.getByRole("button", { name: "Save" }).click();
|
|
||||||
await putPromise;
|
|
||||||
await getPromise;
|
|
||||||
|
|
||||||
// Wait for modal to close
|
|
||||||
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
|
||||||
|
|
||||||
// Should now show the slot (the card we clicked should now have this slot)
|
|
||||||
await expect(page.getByText("09:00 - 17:00")).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can clear availability", async ({ page }) => {
|
|
||||||
await page.goto("/admin/availability");
|
await page.goto("/admin/availability");
|
||||||
|
|
||||||
// Wait for initial data load to complete
|
// Wait for initial data load to complete
|
||||||
|
|
@ -154,39 +102,31 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
|
|
||||||
// Slot should be gone from this specific card
|
// Slot should be gone from this specific card
|
||||||
await expect(targetCard.getByText("09:00 - 17:00")).not.toBeVisible();
|
await expect(targetCard.getByText("09:00 - 17:00")).not.toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("can add multiple slots", async ({ page }) => {
|
// Now test adding multiple slots - find another day card
|
||||||
await page.goto("/admin/availability");
|
|
||||||
|
|
||||||
// Wait for initial data load to complete
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
const anotherDayCard = page
|
||||||
// Find a day card with "No availability" and click on it (to avoid conflicts with booking tests)
|
|
||||||
const dayCardWithNoAvailability = page
|
|
||||||
.locator('[data-testid^="day-card-"]')
|
.locator('[data-testid^="day-card-"]')
|
||||||
.filter({
|
.filter({
|
||||||
has: page.getByText("No availability"),
|
has: page.getByText("No availability"),
|
||||||
})
|
})
|
||||||
.first();
|
.first();
|
||||||
const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
|
const anotherTestId = await anotherDayCard.getAttribute("data-testid");
|
||||||
const targetCard = page.locator(`[data-testid="${testId}"]`);
|
const anotherTargetCard = page.locator(`[data-testid="${anotherTestId}"]`);
|
||||||
await dayCardWithNoAvailability.click();
|
await anotherDayCard.click();
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
||||||
|
|
||||||
// First slot is 09:00-17:00 by default - change it to morning only
|
// First slot is 09:00-17:00 by default - change it to morning only
|
||||||
const timeSelects = page.locator("select");
|
const timeSelects = page.locator("select");
|
||||||
await timeSelects.nth(1).selectOption("12:00"); // Change first slot end to 12:00
|
await timeSelects.nth(1).selectOption("12:00");
|
||||||
|
|
||||||
// Add another slot for afternoon
|
// Add another slot for afternoon
|
||||||
await page.getByText("+ Add Time Range").click();
|
await page.getByText("+ Add Time Range").click();
|
||||||
|
await timeSelects.nth(2).selectOption("14:00");
|
||||||
|
await timeSelects.nth(3).selectOption("17:00");
|
||||||
|
|
||||||
// Change second slot times to avoid overlap
|
// Save multiple slots
|
||||||
await timeSelects.nth(2).selectOption("14:00"); // Second slot start
|
|
||||||
await timeSelects.nth(3).selectOption("17:00"); // Second slot end
|
|
||||||
|
|
||||||
// Set up listeners for both PUT and GET before clicking Save to avoid race condition
|
|
||||||
const putPromise = page.waitForResponse(
|
const putPromise = page.waitForResponse(
|
||||||
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
|
(resp) => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT"
|
||||||
);
|
);
|
||||||
|
|
@ -198,53 +138,42 @@ test.describe("Availability Page - Admin Access", () => {
|
||||||
await getPromise;
|
await getPromise;
|
||||||
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
||||||
|
|
||||||
// Should see both slots in the card we clicked
|
// Should see both slots
|
||||||
await expect(targetCard.getByText("09:00 - 12:00")).toBeVisible();
|
await expect(anotherTargetCard.getByText("09:00 - 12:00")).toBeVisible();
|
||||||
await expect(targetCard.getByText("14:00 - 17:00")).toBeVisible();
|
await expect(anotherTargetCard.getByText("14:00 - 17:00")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Availability Page - Access Control", () => {
|
test.describe("Availability Page - Access Control", () => {
|
||||||
test("regular user cannot access availability page", async ({ page }) => {
|
test("regular user and unauthenticated user cannot access availability page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Test unauthenticated access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/admin/availability");
|
await page.goto("/admin/availability");
|
||||||
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
// Should be redirected (to counter/home for regular users)
|
// Test regular user access
|
||||||
await expect(page).not.toHaveURL("/admin/availability");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user does not see Availability link", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
const availabilityLink = page.locator('a[href="/admin/availability"]');
|
||||||
await expect(availabilityLink).toHaveCount(0);
|
await expect(availabilityLink).toHaveCount(0);
|
||||||
});
|
|
||||||
|
|
||||||
test("unauthenticated user redirected to login", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
|
|
||||||
await page.goto("/admin/availability");
|
await page.goto("/admin/availability");
|
||||||
|
await expect(page).not.toHaveURL("/admin/availability");
|
||||||
await expect(page).toHaveURL("/login");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Availability API", () => {
|
test.describe("Availability API", () => {
|
||||||
test("admin can set availability via API", async ({ page, request }) => {
|
test("admin can set availability via API, regular user cannot", async ({ page, request }) => {
|
||||||
|
// Test admin API access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
const cookies = await page.context().cookies();
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const dateStr = getTomorrowDateStr();
|
const dateStr = getTomorrowDateStr();
|
||||||
|
|
||||||
const response = await request.put(`${API_URL}/api/admin/availability`, {
|
const response = await request.put(`${API_URL}/api/admin/availability`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
|
@ -261,27 +190,23 @@ test.describe("Availability API", () => {
|
||||||
expect(data.date).toBe(dateStr);
|
expect(data.date).toBe(dateStr);
|
||||||
expect(data.slots).toHaveLength(1);
|
expect(data.slots).toHaveLength(1);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user cannot access availability API", async ({ page, request }) => {
|
// Test regular user API access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
const regularCookies = await page.context().cookies();
|
||||||
|
const regularAuthCookie = regularCookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
if (regularAuthCookie) {
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const dateStr = getTomorrowDateStr();
|
const dateStr = getTomorrowDateStr();
|
||||||
|
|
||||||
const response = await request.get(
|
const response = await request.get(
|
||||||
`${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
|
`${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${regularAuthCookie.value}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,75 +40,43 @@ test.describe("Exchange Page - Regular User Access", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("regular user can access exchange page", async ({ page }) => {
|
test("regular user can access exchange page, all UI elements work, and buy/sell toggle functions", async ({
|
||||||
await page.goto("/exchange");
|
page,
|
||||||
|
}) => {
|
||||||
|
// Test navigation
|
||||||
|
await page.goto("/trades");
|
||||||
|
await expect(page.getByRole("link", { name: "Exchange" })).toBeVisible();
|
||||||
|
|
||||||
|
// Test page access
|
||||||
|
await page.goto("/exchange");
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Exchange Bitcoin" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user sees Exchange link in navigation", async ({ page }) => {
|
// Test price information
|
||||||
await page.goto("/trades");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Exchange" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page shows price information", async ({ page }) => {
|
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Should show market and our price
|
|
||||||
await expect(page.getByText("Market:")).toBeVisible();
|
await expect(page.getByText("Market:")).toBeVisible();
|
||||||
await expect(page.getByText("Our price:")).toBeVisible();
|
await expect(page.getByText("Our price:")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page shows buy/sell toggle", async ({ page }) => {
|
// Test buy/sell toggle visibility and functionality
|
||||||
await page.goto("/exchange");
|
const buyButton = page.getByRole("button", { name: "Buy BTC" });
|
||||||
|
await expect(buyButton).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Buy BTC" })).toBeVisible();
|
|
||||||
await expect(page.getByRole("button", { name: "Sell BTC" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Sell BTC" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page shows payment method selector", async ({ page }) => {
|
// Test clicking buy/sell changes direction
|
||||||
await page.goto("/exchange");
|
await page.getByRole("button", { name: "Sell BTC" }).click();
|
||||||
|
await expect(page.getByText(/You buy €\d/)).toBeVisible();
|
||||||
|
|
||||||
|
// Test payment method selector
|
||||||
await expect(page.getByText("Payment Method")).toBeVisible();
|
await expect(page.getByText("Payment Method")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Onchain/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Onchain/ })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Lightning/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Lightning/ })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page shows amount slider", async ({ page }) => {
|
// Test amount slider
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Should show amount section
|
|
||||||
await expect(page.getByText("Amount")).toBeVisible();
|
await expect(page.getByText("Amount")).toBeVisible();
|
||||||
await expect(page.locator('input[type="range"]')).toBeVisible();
|
await expect(page.locator('input[type="range"]')).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking buy/sell changes direction", async ({ page }) => {
|
// Test date selection appears after continue
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Initially in buy mode - summary shows BTC first: "You buy [sats], you sell €X"
|
|
||||||
// Verify buy mode is initially active
|
|
||||||
const buyButton = page.getByRole("button", { name: "Buy BTC" });
|
|
||||||
await expect(buyButton).toBeVisible();
|
|
||||||
|
|
||||||
// Click Sell BTC to switch direction
|
|
||||||
await page.getByRole("button", { name: "Sell BTC" }).click();
|
|
||||||
|
|
||||||
// In sell mode, the summary shows EUR first: "You buy €X, you sell [sats]"
|
|
||||||
// We can verify by checking the summary text contains "You buy €" (EUR comes first)
|
|
||||||
await expect(page.getByText(/You buy €\d/)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page shows date selection after continue", async ({ page }) => {
|
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Step 1: Click "Continue to Booking" to proceed to step 2
|
|
||||||
await page.getByRole("button", { name: "Continue to Booking" }).click();
|
await page.getByRole("button", { name: "Continue to Booking" }).click();
|
||||||
|
|
||||||
// Step 2: Now date selection should be visible
|
|
||||||
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
|
||||||
// Should see multiple date buttons
|
|
||||||
const dateButtons = page
|
const dateButtons = page
|
||||||
.locator("button")
|
.locator("button")
|
||||||
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
.filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
||||||
|
|
@ -127,7 +95,7 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows available slots when availability is set", async ({ page }) => {
|
test("booking flow - shows slots, confirmation form, and trade details", async ({ page }) => {
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Step 1: Click "Continue to Booking" to proceed to step 2
|
// Step 1: Click "Continue to Booking" to proceed to step 2
|
||||||
|
|
@ -141,59 +109,31 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
|
|
||||||
// Wait for "Available Slots" section to appear
|
// Wait for "Available Slots" section to appear
|
||||||
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
||||||
|
|
||||||
// Wait for loading to finish
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Should see some slot buttons
|
// Should see some slot buttons
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking slot shows confirmation form", async ({ page }) => {
|
// Click first slot - should show confirmation form
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Step 1: Click "Continue to Booking" to proceed to step 2
|
|
||||||
await page.getByRole("button", { name: "Continue to Booking" }).click();
|
|
||||||
|
|
||||||
// Step 2: Use data-testid for reliable date selection
|
|
||||||
const tomorrowStr = getTomorrowDateStr();
|
|
||||||
const dateButton = page.getByTestId(`date-${tomorrowStr}`);
|
|
||||||
await expect(dateButton).toBeEnabled({ timeout: 15000 });
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for any slot to appear
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Click first slot
|
|
||||||
await slotButtons.first().click();
|
await slotButtons.first().click();
|
||||||
|
|
||||||
// Should show confirmation form
|
|
||||||
await expect(page.getByText("Confirm Trade")).toBeVisible();
|
await expect(page.getByText("Confirm Trade")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /Confirm/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Confirm/ })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("confirmation shows trade details", async ({ page }) => {
|
// Navigate back to exchange and test second slot selection
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Step 1: Click "Continue to Booking" to proceed to step 2
|
|
||||||
await page.getByRole("button", { name: "Continue to Booking" }).click();
|
await page.getByRole("button", { name: "Continue to Booking" }).click();
|
||||||
|
await page.getByTestId(`date-${tomorrowStr}`).click();
|
||||||
// Step 2: Use data-testid for reliable date selection
|
|
||||||
const tomorrowStr = getTomorrowDateStr();
|
|
||||||
const dateButton = page.getByTestId(`date-${tomorrowStr}`);
|
|
||||||
await expect(dateButton).toBeEnabled({ timeout: 15000 });
|
|
||||||
await dateButton.click();
|
|
||||||
|
|
||||||
// Wait for slots to load
|
|
||||||
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
const slotButtons2 = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
await expect(slotButtons2.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Click second slot
|
// Click second slot if available, otherwise first
|
||||||
await slotButtons.nth(1).click();
|
if ((await slotButtons2.count()) > 1) {
|
||||||
|
await slotButtons2.nth(1).click();
|
||||||
|
} else {
|
||||||
|
await slotButtons2.first().click();
|
||||||
|
}
|
||||||
|
|
||||||
// Should show confirmation with trade details
|
// Should show confirmation with trade details
|
||||||
await expect(page.getByText("Confirm Trade")).toBeVisible();
|
await expect(page.getByText("Confirm Trade")).toBeVisible();
|
||||||
|
|
@ -205,10 +145,10 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
await expect(page.getByText("Payment:")).toBeVisible();
|
await expect(page.getByText("Payment:")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("payment method selector works", async ({ page }) => {
|
test("payment method selector works and lightning disabled above threshold", async ({ page }) => {
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Default should be Onchain
|
// Test payment method selector
|
||||||
const onchainButton = page.getByRole("button", { name: /Onchain/ });
|
const onchainButton = page.getByRole("button", { name: /Onchain/ });
|
||||||
const lightningButton = page.getByRole("button", { name: /Lightning/ });
|
const lightningButton = page.getByRole("button", { name: /Lightning/ });
|
||||||
await expect(onchainButton).toHaveCSS("border-color", "rgb(167, 139, 250)");
|
await expect(onchainButton).toHaveCSS("border-color", "rgb(167, 139, 250)");
|
||||||
|
|
@ -221,50 +161,29 @@ test.describe("Exchange Page - With Availability", () => {
|
||||||
// Click back to Onchain
|
// Click back to Onchain
|
||||||
await onchainButton.click();
|
await onchainButton.click();
|
||||||
await expect(onchainButton).toHaveCSS("border-color", "rgb(167, 139, 250)");
|
await expect(onchainButton).toHaveCSS("border-color", "rgb(167, 139, 250)");
|
||||||
});
|
|
||||||
|
|
||||||
test("lightning disabled above threshold", async ({ page }) => {
|
// Test lightning disabled above threshold
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
// Set amount above threshold (€1000 = 100000 cents)
|
|
||||||
const amountInput = page.locator('input[type="text"]').filter({ hasText: "" });
|
const amountInput = page.locator('input[type="text"]').filter({ hasText: "" });
|
||||||
await amountInput.fill("1100");
|
await amountInput.fill("1100");
|
||||||
|
|
||||||
// Lightning button should be disabled
|
|
||||||
const lightningButton = page.getByRole("button", { name: /Lightning/ });
|
|
||||||
await expect(lightningButton).toBeDisabled();
|
await expect(lightningButton).toBeDisabled();
|
||||||
|
|
||||||
// Should show threshold message
|
|
||||||
await expect(page.getByText(/Lightning payments are only available/)).toBeVisible();
|
await expect(page.getByText(/Lightning payments are only available/)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Exchange Page - Access Control", () => {
|
test.describe("Exchange Page - Access Control", () => {
|
||||||
test("admin cannot access exchange page", async ({ page }) => {
|
test("admin and unauthenticated users cannot access exchange page", async ({ page }) => {
|
||||||
|
// Test unauthenticated access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Should be redirected away
|
|
||||||
await expect(page).not.toHaveURL("/exchange");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin does not see Exchange link", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/admin/trades");
|
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: "Exchange" })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unauthenticated user redirected to login", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
|
|
||||||
await page.goto("/exchange");
|
|
||||||
|
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
|
// Test admin access
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
await page.goto("/admin/trades");
|
||||||
|
await expect(page.getByRole("link", { name: "Exchange" })).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/exchange");
|
||||||
|
await expect(page).not.toHaveURL("/exchange");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -274,25 +193,17 @@ test.describe("Trades Page", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("regular user can access trades page", async ({ page }) => {
|
test("regular user can access trades page and see empty state", async ({ page }) => {
|
||||||
await page.goto("/trades");
|
await page.goto("/trades");
|
||||||
|
|
||||||
await expect(page).toHaveURL("/trades");
|
await expect(page).toHaveURL("/trades");
|
||||||
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("trades page shows empty state when no trades", async ({ page }) => {
|
|
||||||
await page.goto("/trades");
|
|
||||||
|
|
||||||
// Either shows empty state message or trades list
|
// Either shows empty state message or trades list
|
||||||
const content = page.locator("body");
|
const content = page.locator("body");
|
||||||
await expect(content).toBeVisible();
|
await expect(content).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("trades page shows Start trading link when empty", async ({ page }) => {
|
// Wait for loading to finish
|
||||||
await page.goto("/trades");
|
|
||||||
|
|
||||||
// Wait for loading to finish - either "Loading trades..." disappears or we see content
|
|
||||||
await expect(page.getByText("Loading trades...")).not.toBeVisible({ timeout: 5000 });
|
await expect(page.getByText("Loading trades...")).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Check if it shows empty state with link, or trades exist
|
// Check if it shows empty state with link, or trades exist
|
||||||
|
|
@ -311,109 +222,79 @@ test.describe("Admin Trades Page", () => {
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin can access trades page", async ({ page }) => {
|
test("admin can access trades page with tabs, regular user cannot", async ({ page }) => {
|
||||||
|
// Test admin access
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
await expect(page.getByRole("heading", { name: "Trades" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Trades" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("admin trades page shows tabs", async ({ page }) => {
|
|
||||||
await page.goto("/admin/trades");
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: /Upcoming/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Upcoming/ })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: /History/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /History/ })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user cannot access admin trades page", async ({ page }) => {
|
// Test regular user cannot access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
// Should be redirected away
|
|
||||||
await expect(page).not.toHaveURL("/admin/trades");
|
await expect(page).not.toHaveURL("/admin/trades");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Exchange API", () => {
|
test.describe("Exchange API", () => {
|
||||||
test("regular user can get price via API", async ({ page, request }) => {
|
test("API access control - regular user can access exchange APIs, admin cannot", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Test regular user can get price
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
let cookies = await page.context().cookies();
|
||||||
const cookies = await page.context().cookies();
|
let authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const response = await request.get(`${API_URL}/api/exchange/price`, {
|
const priceResponse = await request.get(`${API_URL}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(priceResponse.status()).toBe(200);
|
||||||
|
const priceData = await priceResponse.json();
|
||||||
|
expect(priceData.config).toBeDefined();
|
||||||
|
expect(priceData.config.eur_min).toBeDefined();
|
||||||
|
expect(priceData.config.eur_max).toBeDefined();
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
// Test regular user can get trades
|
||||||
const data = await response.json();
|
const tradesResponse = await request.get(`${API_URL}/api/trades`, {
|
||||||
expect(data.config).toBeDefined();
|
headers: {
|
||||||
expect(data.config.eur_min).toBeDefined();
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
expect(data.config.eur_max).toBeDefined();
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
expect(tradesResponse.status()).toBe(200);
|
||||||
|
const tradesData = await tradesResponse.json();
|
||||||
|
expect(Array.isArray(tradesData)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
test("admin cannot get price via API", async ({ page, request }) => {
|
// Test admin cannot get price
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
cookies = await page.context().cookies();
|
||||||
const cookies = await page.context().cookies();
|
authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
const response = await request.get(`${API_URL}/api/exchange/price`, {
|
const adminPriceResponse = await request.get(`${API_URL}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(adminPriceResponse.status()).toBe(403);
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
// Test admin can get upcoming trades
|
||||||
}
|
const adminTradesResponse = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user can get trades via API", async ({ page, request }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const response = await request.get(`${API_URL}/api/trades`, {
|
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(adminTradesResponse.status()).toBe(200);
|
||||||
expect(response.status()).toBe(200);
|
const adminTradesData = await adminTradesResponse.json();
|
||||||
const data = await response.json();
|
expect(Array.isArray(adminTradesData)).toBe(true);
|
||||||
expect(Array.isArray(data)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admin can get upcoming trades via API", async ({ page, request }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
|
||||||
const response = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
|
||||||
headers: {
|
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const data = await response.json();
|
|
||||||
expect(Array.isArray(data)).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -64,42 +64,23 @@ test.describe("Regular User Access", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirected from home to exchange page", async ({ page }) => {
|
test("can access exchange and trades pages with correct navigation", async ({ page }) => {
|
||||||
|
// Test redirect from home
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// Should be redirected to exchange page
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
});
|
|
||||||
|
|
||||||
test("can access exchange page", async ({ page }) => {
|
// Test exchange page access
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Should stay on exchange page
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Should see exchange UI
|
|
||||||
await expect(page.getByText("Exchange Bitcoin")).toBeVisible();
|
await expect(page.getByText("Exchange Bitcoin")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("can access trades page", async ({ page }) => {
|
// Test trades page access
|
||||||
await page.goto("/trades");
|
await page.goto("/trades");
|
||||||
|
|
||||||
// Should stay on trades page
|
|
||||||
await expect(page).toHaveURL("/trades");
|
await expect(page).toHaveURL("/trades");
|
||||||
|
|
||||||
// Should see trades UI heading
|
|
||||||
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "My Trades" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("navigation shows exchange and trades", async ({ page }) => {
|
// Test navigation shows exchange and trades, but not admin links
|
||||||
await page.goto("/trades");
|
|
||||||
|
|
||||||
// From trades page, we can see the nav links
|
|
||||||
// "My Trades" is the current page (shown as span, not link)
|
|
||||||
// "Exchange" should be a link
|
|
||||||
await expect(page.locator('a[href="/exchange"]').first()).toBeVisible();
|
await expect(page.locator('a[href="/exchange"]').first()).toBeVisible();
|
||||||
|
|
||||||
// Should NOT see admin links
|
|
||||||
const adminTradesLinks = page.locator('a[href="/admin/trades"]');
|
const adminTradesLinks = page.locator('a[href="/admin/trades"]');
|
||||||
await expect(adminTradesLinks).toHaveCount(0);
|
await expect(adminTradesLinks).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
@ -111,42 +92,26 @@ test.describe("Admin User Access", () => {
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirected from home to admin trades", async ({ page }) => {
|
test("can access admin pages with correct navigation", async ({ page }) => {
|
||||||
|
// Test redirect from home
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// Should be redirected to admin trades page
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
});
|
|
||||||
|
|
||||||
test("can access admin trades page", async ({ page }) => {
|
// Test admin trades page
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
// Should stay on admin trades page
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
|
|
||||||
// Should see trades UI (use heading for specificity)
|
|
||||||
await expect(page.getByRole("heading", { name: "Trades" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Trades" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("can access admin availability page", async ({ page }) => {
|
// Test admin availability page
|
||||||
await page.goto("/admin/availability");
|
await page.goto("/admin/availability");
|
||||||
|
|
||||||
// Should stay on availability page
|
|
||||||
await expect(page).toHaveURL("/admin/availability");
|
await expect(page).toHaveURL("/admin/availability");
|
||||||
|
|
||||||
// Should see availability UI (use heading for specificity)
|
|
||||||
await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("navigation shows admin links", async ({ page }) => {
|
// Test navigation shows admin links but not regular user links
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
// Should see admin nav items (use locator for nav links)
|
|
||||||
await expect(page.locator('a[href="/admin/invites"]')).toBeVisible();
|
await expect(page.locator('a[href="/admin/invites"]')).toBeVisible();
|
||||||
await expect(page.locator('a[href="/admin/availability"]')).toBeVisible();
|
await expect(page.locator('a[href="/admin/availability"]')).toBeVisible();
|
||||||
await expect(page.locator('a[href="/admin/trades"]')).toHaveCount(0); // Current page, shown as text not link
|
await expect(page.locator('a[href="/admin/trades"]')).toHaveCount(0); // Current page, shown as text not link
|
||||||
|
|
||||||
// Should NOT see regular user links
|
|
||||||
const exchangeLinks = page.locator('a[href="/exchange"]');
|
const exchangeLinks = page.locator('a[href="/exchange"]');
|
||||||
await expect(exchangeLinks).toHaveCount(0);
|
await expect(exchangeLinks).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
@ -157,84 +122,69 @@ test.describe("Unauthenticated Access", () => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("home page redirects to login", async ({ page }) => {
|
test("all protected pages redirect to login", async ({ page }) => {
|
||||||
|
// Test home page redirect
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
|
||||||
|
|
||||||
test("exchange page redirects to login", async ({ page }) => {
|
// Test exchange page redirect
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
|
||||||
|
|
||||||
test("admin page redirects to login", async ({ page }) => {
|
// Test admin page redirect
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Permission Boundary via API", () => {
|
test.describe("Permission Boundary via API", () => {
|
||||||
test("regular user API call to admin trades returns 403", async ({ page, request }) => {
|
test("API calls respect permission boundaries", async ({ page, request }) => {
|
||||||
// Login as regular user
|
// Test regular user cannot access admin API
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
let cookies = await page.context().cookies();
|
||||||
// Get cookies
|
let authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
// Try to call admin trades API directly
|
|
||||||
const response = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
const response = await request.get(`${API_URL}/api/admin/trades/upcoming`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
test("admin user API call to exchange price returns 403", async ({ page, request }) => {
|
// Test admin cannot access regular user API
|
||||||
// Login as admin
|
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
cookies = await page.context().cookies();
|
||||||
// Get cookies
|
authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
const cookies = await page.context().cookies();
|
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
// Try to call exchange price API directly (requires regular user permission)
|
|
||||||
const response = await request.get(`${API_URL}/api/exchange/price`, {
|
const response = await request.get(`${API_URL}/api/exchange/price`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Session and Logout", () => {
|
test.describe("Session and Logout", () => {
|
||||||
test("logout clears permissions - cannot access protected pages", async ({ page }) => {
|
test("logout clears permissions and tampered cookies are rejected", async ({ page, context }) => {
|
||||||
// Login
|
// Test logout clears permissions
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
|
|
||||||
// Logout
|
|
||||||
await page.click("text=Sign out");
|
await page.click("text=Sign out");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
|
|
||||||
// Try to access exchange
|
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
|
||||||
|
|
||||||
test("cannot access pages with tampered cookie", async ({ page, context }) => {
|
// Test tampered cookie is rejected
|
||||||
// Set a fake auth cookie
|
|
||||||
await context.addCookies([
|
await context.addCookies([
|
||||||
{
|
{
|
||||||
name: "auth_token",
|
name: "auth_token",
|
||||||
|
|
@ -244,10 +194,7 @@ test.describe("Session and Logout", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Try to access protected page
|
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Should be redirected to login
|
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,73 +2,40 @@ import { test, expect } from "@playwright/test";
|
||||||
import { clearAuth, loginUser, REGULAR_USER, ADMIN_USER } from "./helpers/auth";
|
import { clearAuth, loginUser, REGULAR_USER, ADMIN_USER } from "./helpers/auth";
|
||||||
|
|
||||||
test.describe("Price History - E2E", () => {
|
test.describe("Price History - E2E", () => {
|
||||||
test("admin can view price history page", async ({ page }) => {
|
test("admin can view and use price history page, regular user cannot access", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Test admin access and navigation
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
|
|
||||||
await page.goto("/admin/price-history");
|
// Test navigation link
|
||||||
|
await expect(page.getByRole("link", { name: "Prices" })).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "Prices" }).click();
|
||||||
await expect(page).toHaveURL("/admin/price-history");
|
await expect(page).toHaveURL("/admin/price-history");
|
||||||
|
|
||||||
// Page title should be visible
|
// Test page structure
|
||||||
await expect(page.locator("h2")).toContainText("Bitcoin Price History");
|
await expect(page.locator("h2")).toContainText("Bitcoin Price History");
|
||||||
|
|
||||||
// Table should exist
|
|
||||||
await expect(page.locator("table")).toBeVisible();
|
await expect(page.locator("table")).toBeVisible();
|
||||||
|
|
||||||
// Fetch Now button should exist
|
|
||||||
await expect(page.getByRole("button", { name: "Fetch Now" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Fetch Now" })).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("admin can manually fetch price from Bitfinex", async ({ page }) => {
|
// Test fetching price
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
await page.goto("/admin/price-history");
|
|
||||||
await expect(page).toHaveURL("/admin/price-history");
|
|
||||||
|
|
||||||
// Click the Fetch Now button
|
|
||||||
await page.getByRole("button", { name: "Fetch Now" }).click();
|
await page.getByRole("button", { name: "Fetch Now" }).click();
|
||||||
|
|
||||||
// Wait for the button to become enabled again (fetch completed)
|
|
||||||
await expect(page.getByRole("button", { name: "Fetch Now" })).toBeEnabled({
|
await expect(page.getByRole("button", { name: "Fetch Now" })).toBeEnabled({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The table should now contain a record with bitfinex as source
|
// Verify fetched data
|
||||||
await expect(page.locator("table tbody")).toContainText("bitfinex");
|
await expect(page.locator("table tbody")).toContainText("bitfinex");
|
||||||
|
|
||||||
// Should have BTC/EUR pair
|
|
||||||
await expect(page.locator("table tbody")).toContainText("BTC/EUR");
|
await expect(page.locator("table tbody")).toContainText("BTC/EUR");
|
||||||
|
|
||||||
// Price should be visible and formatted as EUR
|
|
||||||
// The price cell should contain a € symbol
|
|
||||||
const priceCell = page.locator("table tbody tr td").nth(2);
|
const priceCell = page.locator("table tbody tr td").nth(2);
|
||||||
await expect(priceCell).toContainText("€");
|
await expect(priceCell).toContainText("€");
|
||||||
});
|
|
||||||
|
|
||||||
test("regular user cannot access price history page", async ({ page }) => {
|
// Test regular user cannot access
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
|
||||||
// Try to navigate directly to the admin page
|
|
||||||
await page.goto("/admin/price-history");
|
await page.goto("/admin/price-history");
|
||||||
|
|
||||||
// Should be redirected away (regular users go to /exchange)
|
|
||||||
await expect(page).toHaveURL("/exchange");
|
await expect(page).toHaveURL("/exchange");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("price history shows in navigation for admin", async ({ page }) => {
|
|
||||||
await clearAuth(page);
|
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
|
|
||||||
// Admin should be on admin trades page by default
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
|
||||||
|
|
||||||
// Prices nav link should be visible
|
|
||||||
await expect(page.getByRole("link", { name: "Prices" })).toBeVisible();
|
|
||||||
|
|
||||||
// Click on Prices link
|
|
||||||
await page.getByRole("link", { name: "Prices" }).click();
|
|
||||||
await expect(page).toHaveURL("/admin/price-history");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -75,65 +75,39 @@ test.describe("Profile - Regular User Access", () => {
|
||||||
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can navigate to profile page from exchange", async ({ page }) => {
|
test("can navigate to profile page and page displays correct elements", async ({ page }) => {
|
||||||
|
// Test navigation from exchange
|
||||||
await page.goto("/exchange");
|
await page.goto("/exchange");
|
||||||
|
|
||||||
// Should see My Profile link
|
|
||||||
await expect(page.getByText("My Profile")).toBeVisible();
|
await expect(page.getByText("My Profile")).toBeVisible();
|
||||||
|
|
||||||
// Click to navigate
|
|
||||||
await page.click('a[href="/profile"]');
|
await page.click('a[href="/profile"]');
|
||||||
await expect(page).toHaveURL("/profile");
|
await expect(page).toHaveURL("/profile");
|
||||||
});
|
|
||||||
|
|
||||||
test("can navigate to profile page from trades", async ({ page }) => {
|
// Test navigation from trades
|
||||||
await page.goto("/trades");
|
await page.goto("/trades");
|
||||||
|
|
||||||
// Should see My Profile link
|
|
||||||
await expect(page.getByText("My Profile")).toBeVisible();
|
await expect(page.getByText("My Profile")).toBeVisible();
|
||||||
|
|
||||||
// Click to navigate
|
|
||||||
await page.click('a[href="/profile"]');
|
await page.click('a[href="/profile"]');
|
||||||
await expect(page).toHaveURL("/profile");
|
await expect(page).toHaveURL("/profile");
|
||||||
});
|
|
||||||
|
|
||||||
test("profile page displays correct elements", async ({ page }) => {
|
// Test page structure
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Should see page title
|
|
||||||
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
|
||||||
|
|
||||||
// Should see login email label with read-only badge
|
|
||||||
await expect(page.getByText("Login EmailRead only")).toBeVisible();
|
await expect(page.getByText("Login EmailRead only")).toBeVisible();
|
||||||
|
|
||||||
// Should see contact details section
|
|
||||||
await expect(page.getByText("Contact Details")).toBeVisible();
|
await expect(page.getByText("Contact Details")).toBeVisible();
|
||||||
await expect(page.getByText(/communication purposes only/i)).toBeVisible();
|
await expect(page.getByText(/communication purposes only/i)).toBeVisible();
|
||||||
|
|
||||||
// Should see all form fields
|
// Test form fields visibility
|
||||||
await expect(page.getByLabel("Contact Email")).toBeVisible();
|
await expect(page.getByLabel("Contact Email")).toBeVisible();
|
||||||
await expect(page.getByLabel("Telegram")).toBeVisible();
|
await expect(page.getByLabel("Telegram")).toBeVisible();
|
||||||
await expect(page.getByLabel("Signal")).toBeVisible();
|
await expect(page.getByLabel("Signal")).toBeVisible();
|
||||||
await expect(page.getByLabel("Nostr (npub)")).toBeVisible();
|
await expect(page.getByLabel("Nostr (npub)")).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
test("login email is displayed and read-only", async ({ page }) => {
|
// Test login email is read-only
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Login email should show the user's email
|
|
||||||
const loginEmailInput = page.locator('input[type="email"][disabled]');
|
const loginEmailInput = page.locator('input[type="email"][disabled]');
|
||||||
await expect(loginEmailInput).toHaveValue(REGULAR_USER.email);
|
await expect(loginEmailInput).toHaveValue(REGULAR_USER.email);
|
||||||
await expect(loginEmailInput).toBeDisabled();
|
await expect(loginEmailInput).toBeDisabled();
|
||||||
});
|
|
||||||
|
|
||||||
test("navigation shows Exchange, My Trades, and My Profile", async ({ page }) => {
|
// Test navigation links
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Should see all nav items (Exchange and My Trades as links)
|
|
||||||
await expect(page.locator('a[href="/exchange"]')).toBeVisible();
|
await expect(page.locator('a[href="/exchange"]')).toBeVisible();
|
||||||
await expect(page.locator('a[href="/trades"]')).toBeVisible();
|
await expect(page.locator('a[href="/trades"]')).toBeVisible();
|
||||||
// My Profile is the page title (h1) since we're on this page
|
|
||||||
await expect(page.getByRole("heading", { name: "My Profile" })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -145,7 +119,7 @@ test.describe("Profile - Form Behavior", () => {
|
||||||
await clearProfileData(page);
|
await clearProfileData(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("new user has empty profile fields", async ({ page }) => {
|
test("form state management, save, persistence, and clearing fields", async ({ page }) => {
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// All editable fields should be empty
|
// All editable fields should be empty
|
||||||
|
|
@ -153,31 +127,16 @@ test.describe("Profile - Form Behavior", () => {
|
||||||
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
await expect(page.getByLabel("Telegram")).toHaveValue("");
|
||||||
await expect(page.getByLabel("Signal")).toHaveValue("");
|
await expect(page.getByLabel("Signal")).toHaveValue("");
|
||||||
await expect(page.getByLabel("Nostr (npub)")).toHaveValue("");
|
await expect(page.getByLabel("Nostr (npub)")).toHaveValue("");
|
||||||
});
|
|
||||||
|
|
||||||
test("save button is disabled when no changes", async ({ page }) => {
|
// Save button should be disabled when no changes
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Save button should be disabled
|
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
const saveButton = page.getByRole("button", { name: /save changes/i });
|
||||||
await expect(saveButton).toBeDisabled();
|
await expect(saveButton).toBeDisabled();
|
||||||
});
|
|
||||||
|
|
||||||
test("save button is enabled after making changes", async ({ page }) => {
|
// Make a change - button should be enabled
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Make a change
|
|
||||||
await page.fill("#telegram", "@testhandle");
|
await page.fill("#telegram", "@testhandle");
|
||||||
|
|
||||||
// Save button should be enabled
|
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
|
||||||
await expect(saveButton).toBeEnabled();
|
await expect(saveButton).toBeEnabled();
|
||||||
});
|
|
||||||
|
|
||||||
test("can save profile and values persist", async ({ page }) => {
|
// Now test saving and persistence - fill in all fields
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Fill in all fields
|
|
||||||
await page.fill("#contact_email", "contact@test.com");
|
await page.fill("#contact_email", "contact@test.com");
|
||||||
await page.fill("#telegram", "@testuser");
|
await page.fill("#telegram", "@testuser");
|
||||||
await page.fill("#signal", "signal.42");
|
await page.fill("#signal", "signal.42");
|
||||||
|
|
@ -185,28 +144,19 @@ test.describe("Profile - Form Behavior", () => {
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save Changes")');
|
||||||
|
|
||||||
// Should see success message
|
|
||||||
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
||||||
|
|
||||||
// Reload and verify values persist
|
// Reload and verify values persist
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByLabel("Contact Email")).toHaveValue("contact@test.com");
|
await expect(page.getByLabel("Contact Email")).toHaveValue("contact@test.com");
|
||||||
await expect(page.getByLabel("Telegram")).toHaveValue("@testuser");
|
await expect(page.getByLabel("Telegram")).toHaveValue("@testuser");
|
||||||
await expect(page.getByLabel("Signal")).toHaveValue("signal.42");
|
await expect(page.getByLabel("Signal")).toHaveValue("signal.42");
|
||||||
await expect(page.getByLabel("Nostr (npub)")).toHaveValue(VALID_NPUB);
|
await expect(page.getByLabel("Nostr (npub)")).toHaveValue(VALID_NPUB);
|
||||||
});
|
|
||||||
|
|
||||||
test("can clear a field and save", async ({ page }) => {
|
// Test clearing a field
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// First set a value
|
|
||||||
await page.fill("#telegram", "@initial");
|
await page.fill("#telegram", "@initial");
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save Changes")');
|
||||||
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
||||||
|
|
||||||
// Wait for toast to disappear
|
|
||||||
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
|
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Clear the field
|
// Clear the field
|
||||||
|
|
@ -227,84 +177,46 @@ test.describe("Profile - Validation", () => {
|
||||||
await clearProfileData(page);
|
await clearProfileData(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto-prepends @ for telegram when starting with letter", async ({ page }) => {
|
test("validation - all field validations and error fixing", async ({ page }) => {
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// Type a letter without @ - should auto-prepend @
|
// Test telegram auto-prepend
|
||||||
await page.fill("#telegram", "testhandle");
|
await page.fill("#telegram", "testhandle");
|
||||||
|
|
||||||
// Should show @testhandle in the input
|
|
||||||
await expect(page.locator("#telegram")).toHaveValue("@testhandle");
|
await expect(page.locator("#telegram")).toHaveValue("@testhandle");
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for telegram handle with no characters after @", async ({ page }) => {
|
// Test telegram error - no characters after @
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Enter telegram with @ but nothing after (needs at least 1 char)
|
|
||||||
await page.fill("#telegram", "@");
|
await page.fill("#telegram", "@");
|
||||||
|
await expect(page.getByText(/at least one character after @/i)).toBeVisible({ timeout: 2000 });
|
||||||
// Wait for debounced validation
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
|
|
||||||
// Should show error about needing at least one character
|
|
||||||
await expect(page.getByText(/at least one character after @/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Save button should be disabled
|
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
const saveButton = page.getByRole("button", { name: /save changes/i });
|
||||||
await expect(saveButton).toBeDisabled();
|
await expect(saveButton).toBeDisabled();
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error for invalid npub", async ({ page }) => {
|
// Test invalid npub
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Enter invalid npub
|
|
||||||
await page.fill("#nostr_npub", "invalidnpub");
|
await page.fill("#nostr_npub", "invalidnpub");
|
||||||
|
|
||||||
// Should show error
|
|
||||||
await expect(page.getByText(/must start with 'npub1'/i)).toBeVisible();
|
await expect(page.getByText(/must start with 'npub1'/i)).toBeVisible();
|
||||||
|
|
||||||
// Save button should be disabled
|
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
|
||||||
await expect(saveButton).toBeDisabled();
|
await expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Test invalid email format
|
||||||
|
await page.fill("#contact_email", "not-an-email");
|
||||||
|
await expect(page.getByText(/valid email/i)).toBeVisible();
|
||||||
|
await expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Fix all validation errors and save
|
||||||
|
await page.fill("#telegram", "@validhandle");
|
||||||
|
await expect(page.getByText(/at least one character after @/i)).not.toBeVisible({
|
||||||
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can fix validation error and save", async ({ page }) => {
|
await page.fill("#nostr_npub", VALID_NPUB);
|
||||||
await page.goto("/profile");
|
await expect(page.getByText(/must start with 'npub1'/i)).not.toBeVisible({ timeout: 2000 });
|
||||||
|
|
||||||
// Enter invalid telegram (just @ with no handle)
|
await page.fill("#contact_email", "valid@email.com");
|
||||||
await page.fill("#telegram", "@");
|
await expect(page.getByText(/valid email/i)).not.toBeVisible({ timeout: 2000 });
|
||||||
|
|
||||||
// Wait for debounced validation
|
// Now all errors are fixed, save button should be enabled
|
||||||
await page.waitForTimeout(600);
|
|
||||||
|
|
||||||
await expect(page.getByText(/at least one character after @/i)).toBeVisible();
|
|
||||||
|
|
||||||
// Fix it
|
|
||||||
await page.fill("#telegram", "@validhandle");
|
|
||||||
|
|
||||||
// Wait for debounced validation
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
|
|
||||||
// Error should disappear
|
|
||||||
await expect(page.getByText(/at least one character after @/i)).not.toBeVisible();
|
|
||||||
|
|
||||||
// Should be able to save
|
|
||||||
const saveButton = page.getByRole("button", { name: /save changes/i });
|
|
||||||
await expect(saveButton).toBeEnabled();
|
await expect(saveButton).toBeEnabled();
|
||||||
|
|
||||||
await page.click('button:has-text("Save Changes")');
|
await page.click('button:has-text("Save Changes")');
|
||||||
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
await expect(page.getByText(/saved successfully/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows error for invalid email format", async ({ page }) => {
|
|
||||||
await page.goto("/profile");
|
|
||||||
|
|
||||||
// Enter invalid email
|
|
||||||
await page.fill("#contact_email", "not-an-email");
|
|
||||||
|
|
||||||
// Should show error
|
|
||||||
await expect(page.getByText(/valid email/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Profile - Admin User Access", () => {
|
test.describe("Profile - Admin User Access", () => {
|
||||||
|
|
@ -313,35 +225,26 @@ test.describe("Profile - Admin User Access", () => {
|
||||||
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin does not see My Profile link", async ({ page }) => {
|
test("admin cannot access profile page or API", async ({ page, request }) => {
|
||||||
|
// Admin should not see profile link
|
||||||
await page.goto("/admin/trades");
|
await page.goto("/admin/trades");
|
||||||
|
|
||||||
// Should be on admin trades page
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
|
|
||||||
// Should NOT see My Profile link
|
|
||||||
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
|
await expect(page.locator('a[href="/profile"]')).toHaveCount(0);
|
||||||
});
|
|
||||||
|
|
||||||
test("admin cannot access profile page - redirected to admin trades", async ({ page }) => {
|
// Admin should be redirected when accessing profile page
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
|
|
||||||
// Should be redirected to admin trades
|
|
||||||
await expect(page).toHaveURL("/admin/trades");
|
await expect(page).toHaveURL("/admin/trades");
|
||||||
});
|
|
||||||
|
|
||||||
test("admin API call to profile returns 403", async ({ page, request }) => {
|
// Admin API call should return 403
|
||||||
const cookies = await page.context().cookies();
|
const cookies = await page.context().cookies();
|
||||||
const authCookie = cookies.find((c) => c.name === "auth_token");
|
const authCookie = cookies.find((c) => c.name === "auth_token");
|
||||||
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
// Try to call profile API directly
|
|
||||||
const response = await request.get(`${API_URL}/api/profile`, {
|
const response = await request.get(`${API_URL}/api/profile`, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `auth_token=${authCookie.value}`,
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.status()).toBe(403);
|
expect(response.status()).toBe(403);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -352,12 +255,12 @@ test.describe("Profile - Unauthenticated Access", () => {
|
||||||
await clearAuth(page);
|
await clearAuth(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("profile page redirects to login", async ({ page }) => {
|
test("profile page and API require authentication", async ({ page, request }) => {
|
||||||
|
// Page redirects to login
|
||||||
await page.goto("/profile");
|
await page.goto("/profile");
|
||||||
await expect(page).toHaveURL("/login");
|
await expect(page).toHaveURL("/login");
|
||||||
});
|
|
||||||
|
|
||||||
test("profile API requires authentication", async ({ page: _page, request }) => {
|
// API requires authentication
|
||||||
const response = await request.get(`${API_URL}/api/profile`);
|
const response = await request.get(`${API_URL}/api/profile`);
|
||||||
expect(response.status()).toBe(401);
|
expect(response.status()).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,8 @@ export default defineConfig({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
// Action timeout (clicks, fills, etc.)
|
// Action timeout (clicks, fills, etc.)
|
||||||
actionTimeout: 5000,
|
actionTimeout: 5000,
|
||||||
|
// Reduce screenshot/recording overhead
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# E2E tests use a separate database and port to allow parallel execution with backend tests
|
||||||
|
E2E_PORT=${E2E_PORT:-8001}
|
||||||
|
E2E_DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/arbret_e2e"
|
||||||
|
|
||||||
# Cleanup function to kill background processes
|
# Cleanup function to kill background processes
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill $BACKEND_PID 2>/dev/null || true
|
kill $BACKEND_PID 2>/dev/null || true
|
||||||
|
|
@ -18,34 +22,35 @@ if [ -f .env ]; then
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Kill any existing backend
|
# Kill any existing e2e backend (on our specific port)
|
||||||
pkill -f "uvicorn main:app" 2>/dev/null || true
|
pkill -f "uvicorn main:app --port $E2E_PORT" 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# Seed the database with roles and test users
|
# Seed the e2e database with roles and test users
|
||||||
cd backend
|
cd backend
|
||||||
echo "Seeding database..."
|
echo "Seeding e2e database..."
|
||||||
uv run python seed.py
|
DATABASE_URL="$E2E_DATABASE_URL" uv run python seed.py
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Start backend (SECRET_KEY should be set via .envrc or environment)
|
# Start backend for e2e tests (uses e2e database and separate port)
|
||||||
cd backend
|
cd backend
|
||||||
uv run uvicorn main:app --port 8000 --log-level warning &
|
DATABASE_URL="$E2E_DATABASE_URL" uv run uvicorn main:app --port $E2E_PORT --log-level warning &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Wait for backend
|
# Wait for backend
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# Generate API types from OpenAPI schema
|
# Generate API types from OpenAPI schema (using e2e backend)
|
||||||
echo "Generating API types..."
|
echo "Generating API types from e2e backend..."
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run generate-api-types
|
npx openapi-typescript "http://localhost:$E2E_PORT/openapi.json" -o app/generated/api.ts
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
# Run tests (suppress Node.js color warnings)
|
# Run tests with e2e-specific backend URL
|
||||||
# If TEST argument is provided, use it as a file pattern
|
# The frontend will connect to our e2e backend on $E2E_PORT
|
||||||
cd frontend
|
cd frontend
|
||||||
|
export NEXT_PUBLIC_API_URL="http://localhost:$E2E_PORT"
|
||||||
if [ -n "$1" ]; then
|
if [ -n "$1" ]; then
|
||||||
NODE_NO_WARNINGS=1 npx playwright test "$1"
|
NODE_NO_WARNINGS=1 npx playwright test "$1"
|
||||||
else
|
else
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue