refactors

This commit is contained in:
counterweight 2025-12-26 20:04:46 +01:00
parent 4e1a339432
commit 82c4d0168e
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
28 changed files with 1042 additions and 782 deletions

View file

@ -1,395 +0,0 @@
import uuid
from datetime import UTC, date, datetime, time
from enum import Enum as PyEnum
from typing import TypedDict
from sqlalchemy import (
Column,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
String,
Table,
Time,
UniqueConstraint,
select,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class RoleConfig(TypedDict):
description: str
permissions: list["Permission"]
class Permission(str, PyEnum):
"""All available permissions in the system."""
# Audit permissions
VIEW_AUDIT = "view_audit"
FETCH_PRICE = "fetch_price"
# Profile permissions
MANAGE_OWN_PROFILE = "manage_own_profile"
# Invite permissions
MANAGE_INVITES = "manage_invites"
VIEW_OWN_INVITES = "view_own_invites"
# Exchange permissions (regular users)
CREATE_EXCHANGE = "create_exchange"
VIEW_OWN_EXCHANGES = "view_own_exchanges"
CANCEL_OWN_EXCHANGE = "cancel_own_exchange"
# Availability/Exchange permissions (admin)
MANAGE_AVAILABILITY = "manage_availability"
VIEW_ALL_EXCHANGES = "view_all_exchanges"
CANCEL_ANY_EXCHANGE = "cancel_any_exchange"
COMPLETE_EXCHANGE = "complete_exchange"
class InviteStatus(str, PyEnum):
"""Status of an invite."""
READY = "ready"
SPENT = "spent"
REVOKED = "revoked"
class ExchangeStatus(str, PyEnum):
"""Status of an exchange trade."""
BOOKED = "booked"
COMPLETED = "completed"
CANCELLED_BY_USER = "cancelled_by_user"
CANCELLED_BY_ADMIN = "cancelled_by_admin"
NO_SHOW = "no_show"
class TradeDirection(str, PyEnum):
"""Direction of a trade from the user's perspective."""
BUY = "buy" # User buys BTC, gives EUR
SELL = "sell" # User sells BTC, gets EUR
class BitcoinTransferMethod(str, PyEnum):
"""Bitcoin transfer method for exchange trades."""
ONCHAIN = "onchain"
LIGHTNING = "lightning"
# Role name constants
ROLE_ADMIN = "admin"
ROLE_REGULAR = "regular"
# Role definitions with their permissions
ROLE_DEFINITIONS: dict[str, RoleConfig] = {
ROLE_ADMIN: {
"description": "Administrator with audit/invite/exchange access",
"permissions": [
Permission.VIEW_AUDIT,
Permission.FETCH_PRICE,
Permission.MANAGE_INVITES,
Permission.MANAGE_AVAILABILITY,
Permission.VIEW_ALL_EXCHANGES,
Permission.CANCEL_ANY_EXCHANGE,
Permission.COMPLETE_EXCHANGE,
],
},
ROLE_REGULAR: {
"description": "Regular user with profile, invite, and exchange access",
"permissions": [
Permission.MANAGE_OWN_PROFILE,
Permission.VIEW_OWN_INVITES,
Permission.CREATE_EXCHANGE,
Permission.VIEW_OWN_EXCHANGES,
Permission.CANCEL_OWN_EXCHANGE,
],
},
}
# Association table: Role <-> Permission (many-to-many)
role_permissions = Table(
"role_permissions",
Base.metadata,
Column(
"role_id",
Integer,
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
),
Column("permission", Enum(Permission), primary_key=True),
)
# Association table: User <-> Role (many-to-many)
user_roles = Table(
"user_roles",
Base.metadata,
Column(
"user_id",
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"role_id",
Integer,
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
),
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=True)
# Relationship to users
users: Mapped[list["User"]] = relationship(
"User",
secondary=user_roles,
back_populates="roles",
)
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
"""Get all permissions for this role."""
query = select(role_permissions.c.permission).where(
role_permissions.c.role_id == self.id
)
result = await db.execute(query)
return {row[0] for row in result.fetchall()}
async def set_permissions(
self, db: AsyncSession, permissions: list[Permission]
) -> None:
"""Set all permissions for this role (replaces existing)."""
delete_query = role_permissions.delete().where(
role_permissions.c.role_id == self.id
)
await db.execute(delete_query)
for perm in permissions:
insert_query = role_permissions.insert().values(
role_id=self.id, permission=perm
)
await db.execute(insert_query)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Contact details (all optional)
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
telegram: Mapped[str | None] = mapped_column(String(64), nullable=True)
signal: Mapped[str | None] = mapped_column(String(64), nullable=True)
nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
# Godfather (who invited this user) - null for seeded/admin users
godfather_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
godfather: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[godfather_id],
)
# Relationship to roles
roles: Mapped[list[Role]] = relationship(
"Role",
secondary=user_roles,
back_populates="users",
lazy="selectin",
)
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
"""Get all permissions from all roles in a single query."""
result = await db.execute(
select(role_permissions.c.permission)
.join(user_roles, role_permissions.c.role_id == user_roles.c.role_id)
.where(user_roles.c.user_id == self.id)
)
return {row[0] for row in result.fetchall()}
async def has_permission(self, db: AsyncSession, permission: Permission) -> bool:
"""Check if user has a specific permission through any of their roles."""
permissions = await self.get_permissions(db)
return permission in permissions
@property
def role_names(self) -> list[str]:
"""Get list of role names for API responses."""
return [role.name for role in self.roles]
class Invite(Base):
__tablename__ = "invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
)
# Godfather - the user who owns this invite
godfather_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
godfather: Mapped[User] = relationship(
"User",
foreign_keys=[godfather_id],
lazy="joined",
)
# User who used this invite (null until spent)
used_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
used_by: Mapped[User | None] = relationship(
"User",
foreign_keys=[used_by_id],
lazy="joined",
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
spent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class Availability(Base):
"""Admin availability slots for booking."""
__tablename__ = "availability"
__table_args__ = (
UniqueConstraint("date", "start_time", name="uq_availability_date_start"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
start_time: Mapped[time] = mapped_column(Time, nullable=False)
end_time: Mapped[time] = mapped_column(Time, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
class PriceHistory(Base):
"""Price history records from external exchanges."""
__tablename__ = "price_history"
__table_args__ = (
UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
pair: Mapped[str] = mapped_column(String(20), nullable=False)
price: Mapped[float] = mapped_column(Float, nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
class Exchange(Base):
"""Bitcoin exchange trades booked by users."""
__tablename__ = "exchanges"
# Note: No unique constraint on slot_start to allow cancelled bookings
# to be replaced. Application-level check in create_exchange ensures only
# one BOOKED trade per slot. For existing databases, manually drop the
# constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS
# uq_exchange_slot_start;
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
public_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
unique=True,
index=True,
default=uuid.uuid4,
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="joined")
# Slot timing
slot_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Trade details
direction: Mapped[TradeDirection] = mapped_column(
Enum(TradeDirection), nullable=False
)
bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column(
Enum(BitcoinTransferMethod),
nullable=False,
default=BitcoinTransferMethod.ONCHAIN,
)
eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents
sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis
# Price information (snapshot at booking time)
market_price_eur: Mapped[float] = mapped_column(
Float, nullable=False
) # EUR per BTC
agreed_price_eur: Mapped[float] = mapped_column(
Float, nullable=False
) # EUR per BTC with premium
premium_percentage: Mapped[int] = mapped_column(
Integer, nullable=False
) # e.g. 5 for 5%
# Status
status: Mapped[ExchangeStatus] = mapped_column(
Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View file

@ -0,0 +1,43 @@
# Export all enums
# Export association tables
from .associations import role_permissions, user_roles
# Export models
from .availability import Availability
from .enums import (
BitcoinTransferMethod,
ExchangeStatus,
InviteStatus,
Permission,
TradeDirection,
)
from .exchange import Exchange
from .invite import Invite
from .price_history import PriceHistory
# Export role configuration
from .role_config import ROLE_ADMIN, ROLE_DEFINITIONS, ROLE_REGULAR
# Export types
from .types import RoleConfig
from .user import Role, User
__all__ = [
"ROLE_ADMIN",
"ROLE_DEFINITIONS",
"ROLE_REGULAR",
"Availability",
"BitcoinTransferMethod",
"Exchange",
"ExchangeStatus",
"Invite",
"InviteStatus",
"Permission",
"PriceHistory",
"Role",
"RoleConfig",
"TradeDirection",
"User",
"role_permissions",
"user_roles",
]

View file

@ -0,0 +1,36 @@
from sqlalchemy import Column, Enum, ForeignKey, Integer, Table
from database import Base
from .enums import Permission
# Association table: Role <-> Permission (many-to-many)
role_permissions = Table(
"role_permissions",
Base.metadata,
Column(
"role_id",
Integer,
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
),
Column("permission", Enum(Permission), primary_key=True),
)
# Association table: User <-> Role (many-to-many)
user_roles = Table(
"user_roles",
Base.metadata,
Column(
"user_id",
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"role_id",
Integer,
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
),
)

View file

@ -0,0 +1,28 @@
from datetime import UTC, date, datetime, time
from sqlalchemy import Date, DateTime, Integer, Time, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class Availability(Base):
"""Admin availability slots for booking."""
__tablename__ = "availability"
__table_args__ = (
UniqueConstraint("date", "start_time", name="uq_availability_date_start"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
start_time: Mapped[time] = mapped_column(Time, nullable=False)
end_time: Mapped[time] = mapped_column(Time, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)

59
backend/models/enums.py Normal file
View file

@ -0,0 +1,59 @@
from enum import Enum as PyEnum
class Permission(str, PyEnum):
"""All available permissions in the system."""
# Audit permissions
VIEW_AUDIT = "view_audit"
FETCH_PRICE = "fetch_price"
# Profile permissions
MANAGE_OWN_PROFILE = "manage_own_profile"
# Invite permissions
MANAGE_INVITES = "manage_invites"
VIEW_OWN_INVITES = "view_own_invites"
# Exchange permissions (regular users)
CREATE_EXCHANGE = "create_exchange"
VIEW_OWN_EXCHANGES = "view_own_exchanges"
CANCEL_OWN_EXCHANGE = "cancel_own_exchange"
# Availability/Exchange permissions (admin)
MANAGE_AVAILABILITY = "manage_availability"
VIEW_ALL_EXCHANGES = "view_all_exchanges"
CANCEL_ANY_EXCHANGE = "cancel_any_exchange"
COMPLETE_EXCHANGE = "complete_exchange"
class InviteStatus(str, PyEnum):
"""Status of an invite."""
READY = "ready"
SPENT = "spent"
REVOKED = "revoked"
class ExchangeStatus(str, PyEnum):
"""Status of an exchange trade."""
BOOKED = "booked"
COMPLETED = "completed"
CANCELLED_BY_USER = "cancelled_by_user"
CANCELLED_BY_ADMIN = "cancelled_by_admin"
NO_SHOW = "no_show"
class TradeDirection(str, PyEnum):
"""Direction of a trade from the user's perspective."""
BUY = "buy" # User buys BTC, gives EUR
SELL = "sell" # User sells BTC, gets EUR
class BitcoinTransferMethod(str, PyEnum):
"""Bitcoin transfer method for exchange trades."""
ONCHAIN = "onchain"
LIGHTNING = "lightning"

View file

@ -0,0 +1,83 @@
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from .enums import BitcoinTransferMethod, ExchangeStatus, TradeDirection
if TYPE_CHECKING:
from .user import User
class Exchange(Base):
"""Bitcoin exchange trades booked by users."""
__tablename__ = "exchanges"
# Note: No unique constraint on slot_start to allow cancelled bookings
# to be replaced. Application-level check in create_exchange ensures only
# one BOOKED trade per slot. For existing databases, manually drop the
# constraint: ALTER TABLE exchanges DROP CONSTRAINT IF EXISTS
# uq_exchange_slot_start;
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
public_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
nullable=False,
unique=True,
index=True,
default=uuid.uuid4,
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], lazy="joined")
# Slot timing
slot_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
slot_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Trade details
direction: Mapped[TradeDirection] = mapped_column(
Enum(TradeDirection), nullable=False
)
bitcoin_transfer_method: Mapped[BitcoinTransferMethod] = mapped_column(
Enum(BitcoinTransferMethod),
nullable=False,
default=BitcoinTransferMethod.ONCHAIN,
)
eur_amount: Mapped[int] = mapped_column(Integer, nullable=False) # EUR cents
sats_amount: Mapped[int] = mapped_column(Integer, nullable=False) # Satoshis
# Price information (snapshot at booking time)
market_price_eur: Mapped[float] = mapped_column(
Float, nullable=False
) # EUR per BTC
agreed_price_eur: Mapped[float] = mapped_column(
Float, nullable=False
) # EUR per BTC with premium
premium_percentage: Mapped[int] = mapped_column(
Integer, nullable=False
) # e.g. 5 for 5%
# Status
status: Mapped[ExchangeStatus] = mapped_column(
Enum(ExchangeStatus), nullable=False, default=ExchangeStatus.BOOKED
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
cancelled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

55
backend/models/invite.py Normal file
View file

@ -0,0 +1,55 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from .enums import InviteStatus
if TYPE_CHECKING:
from .user import User
class Invite(Base):
__tablename__ = "invites"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
identifier: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
status: Mapped[InviteStatus] = mapped_column(
Enum(InviteStatus), nullable=False, default=InviteStatus.READY
)
# Godfather - the user who owns this invite
godfather_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False, index=True
)
godfather: Mapped["User"] = relationship(
"User",
foreign_keys=[godfather_id],
lazy="joined",
)
# User who used this invite (null until spent)
used_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
used_by: Mapped["User | None"] = relationship(
"User",
foreign_keys=[used_by_id],
lazy="joined",
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)
spent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)

View file

@ -0,0 +1,26 @@
from datetime import UTC, datetime
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class PriceHistory(Base):
"""Price history records from external exchanges."""
__tablename__ = "price_history"
__table_args__ = (
UniqueConstraint("source", "pair", "timestamp", name="uq_price_source_pair_ts"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
pair: Mapped[str] = mapped_column(String(20), nullable=False)
price: Mapped[float] = mapped_column(Float, nullable=False)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(UTC)
)

View file

@ -0,0 +1,32 @@
from .enums import Permission
from .types import RoleConfig
# Role name constants
ROLE_ADMIN = "admin"
ROLE_REGULAR = "regular"
# Role definitions with their permissions
ROLE_DEFINITIONS: dict[str, RoleConfig] = {
ROLE_ADMIN: {
"description": "Administrator with audit/invite/exchange access",
"permissions": [
Permission.VIEW_AUDIT,
Permission.FETCH_PRICE,
Permission.MANAGE_INVITES,
Permission.MANAGE_AVAILABILITY,
Permission.VIEW_ALL_EXCHANGES,
Permission.CANCEL_ANY_EXCHANGE,
Permission.COMPLETE_EXCHANGE,
],
},
ROLE_REGULAR: {
"description": "Regular user with profile, invite, and exchange access",
"permissions": [
Permission.MANAGE_OWN_PROFILE,
Permission.VIEW_OWN_INVITES,
Permission.CREATE_EXCHANGE,
Permission.VIEW_OWN_EXCHANGES,
Permission.CANCEL_OWN_EXCHANGE,
],
},
}

8
backend/models/types.py Normal file
View file

@ -0,0 +1,8 @@
from typing import TypedDict
from .enums import Permission
class RoleConfig(TypedDict):
description: str
permissions: list[Permission]

98
backend/models/user.py Normal file
View file

@ -0,0 +1,98 @@
from sqlalchemy import ForeignKey, Integer, String, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
from .associations import role_permissions, user_roles
from .enums import Permission
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=True)
# Relationship to users
users: Mapped[list["User"]] = relationship(
"User",
secondary=user_roles,
back_populates="roles",
)
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
"""Get all permissions for this role."""
query = select(role_permissions.c.permission).where(
role_permissions.c.role_id == self.id
)
result = await db.execute(query)
return {row[0] for row in result.fetchall()}
async def set_permissions(
self, db: AsyncSession, permissions: list[Permission]
) -> None:
"""Set all permissions for this role (replaces existing)."""
delete_query = role_permissions.delete().where(
role_permissions.c.role_id == self.id
)
await db.execute(delete_query)
for perm in permissions:
insert_query = role_permissions.insert().values(
role_id=self.id, permission=perm
)
await db.execute(insert_query)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Contact details (all optional)
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
telegram: Mapped[str | None] = mapped_column(String(64), nullable=True)
signal: Mapped[str | None] = mapped_column(String(64), nullable=True)
nostr_npub: Mapped[str | None] = mapped_column(String(63), nullable=True)
# Godfather (who invited this user) - null for seeded/admin users
godfather_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
godfather: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[godfather_id],
)
# Relationship to roles
roles: Mapped[list[Role]] = relationship(
"Role",
secondary=user_roles,
back_populates="users",
lazy="selectin",
)
async def get_permissions(self, db: AsyncSession) -> set[Permission]:
"""Get all permissions from all roles in a single query."""
result = await db.execute(
select(role_permissions.c.permission)
.join(user_roles, role_permissions.c.role_id == user_roles.c.role_id)
.where(user_roles.c.user_id == self.id)
)
return {row[0] for row in result.fetchall()}
async def has_permission(self, db: AsyncSession, permission: Permission) -> bool:
"""Check if user has a specific permission through any of their roles."""
permissions = await self.get_permissions(db)
return permission in permissions
@property
def role_names(self) -> list[str]:
"""Get list of role names for API responses."""
return [role.name for role in self.roles]

View file

@ -1,340 +0,0 @@
"""Pydantic schemas for API request/response models."""
from datetime import date, datetime, time
from typing import Generic, TypeVar
from pydantic import BaseModel, EmailStr, field_validator
from models import BitcoinTransferMethod, InviteStatus, Permission
class UserCredentials(BaseModel):
"""Base model for user email/password."""
email: EmailStr
password: str
UserCreate = UserCredentials
UserLogin = UserCredentials
class UserResponse(BaseModel):
"""Response model for authenticated user info."""
id: int
email: str
roles: list[str]
permissions: list[str]
class RegisterWithInvite(BaseModel):
"""Request model for registration with invite."""
email: EmailStr
password: str
invite_identifier: str
RecordT = TypeVar("RecordT", bound=BaseModel)
class PaginatedResponse(BaseModel, Generic[RecordT]):
"""Generic paginated response wrapper."""
records: list[RecordT]
total: int
page: int
per_page: int
total_pages: int
class ProfileResponse(BaseModel):
"""Response model for profile data."""
contact_email: str | None
telegram: str | None
signal: str | None
nostr_npub: str | None
godfather_email: str | None = None
class ProfileUpdate(BaseModel):
"""Request model for updating profile."""
contact_email: str | None = None
telegram: str | None = None
signal: str | None = None
nostr_npub: str | None = None
class InviteCheckResponse(BaseModel):
"""Response for invite check endpoint."""
valid: bool
status: str | None = None
error: str | None = None
class InviteCreate(BaseModel):
"""Request model for creating an invite."""
godfather_id: int
class InviteResponse(BaseModel):
"""Response model for invite data (admin view)."""
id: int
identifier: str
godfather_id: int
godfather_email: str
status: str
used_by_id: int | None
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
revoked_at: datetime | None
class UserInviteResponse(BaseModel):
"""Response model for a user's invite (simpler than admin view)."""
id: int
identifier: str
status: str
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
PaginatedInviteRecords = PaginatedResponse[InviteResponse]
class AdminUserResponse(BaseModel):
"""Minimal user info for admin dropdowns."""
id: int
email: str
# =============================================================================
# Availability Schemas
# =============================================================================
class TimeSlot(BaseModel):
"""A single time slot (start and end time)."""
start_time: time
end_time: time
@field_validator("start_time", "end_time")
@classmethod
def validate_15min_boundary(cls, v: time) -> time:
"""Ensure times are on 15-minute boundaries."""
if v.minute not in (0, 15, 30, 45):
raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)")
if v.second != 0 or v.microsecond != 0:
raise ValueError("Time must not have seconds or microseconds")
return v
class AvailabilityDay(BaseModel):
"""Availability for a single day."""
date: date
slots: list[TimeSlot]
class AvailabilityResponse(BaseModel):
"""Response model for availability query."""
days: list[AvailabilityDay]
class SetAvailabilityRequest(BaseModel):
"""Request to set availability for a specific date."""
date: date
slots: list[TimeSlot]
class CopyAvailabilityRequest(BaseModel):
"""Request to copy availability from one day to others."""
source_date: date
target_dates: list[date]
# =============================================================================
# Booking Schemas
# =============================================================================
# =============================================================================
# Price History Schemas
# =============================================================================
class PriceHistoryResponse(BaseModel):
"""Response model for a price history record."""
id: int
source: str
pair: str
price: float
timestamp: datetime
created_at: datetime
# =============================================================================
# Exchange Schemas
# =============================================================================
class ExchangeRequest(BaseModel):
"""Request to create an exchange trade."""
slot_start: datetime
direction: str # "buy" or "sell"
bitcoin_transfer_method: str # "onchain" or "lightning"
eur_amount: int # EUR cents (e.g., 10000 = €100)
class ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
slot_start: datetime
slot_end: datetime
direction: str
bitcoin_transfer_method: str
eur_amount: int # EUR cents
sats_amount: int # Satoshis
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
class ExchangeUserContact(BaseModel):
"""User contact info for admin view."""
email: str
contact_email: str | None
telegram: str | None
signal: str | None
nostr_npub: str | None
class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
user_contact: ExchangeUserContact
slot_start: datetime
slot_end: datetime
direction: str
bitcoin_transfer_method: str
eur_amount: int
sats_amount: int
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
PaginatedExchanges = PaginatedResponse[ExchangeResponse]
PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse]
# =============================================================================
# Meta/Constants Schemas
# =============================================================================
class ConstantsResponse(BaseModel):
"""Response model for shared constants.
Note: Using actual enum types ensures OpenAPI schema includes enum values,
allowing frontend type generation to produce matching TypeScript enums.
"""
permissions: list[Permission]
roles: list[str]
invite_statuses: list[InviteStatus]
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

View file

@ -0,0 +1,88 @@
# Export pagination
# Export auth schemas
from .auth import RegisterWithInvite, UserCreate, UserCredentials, UserLogin
# Export availability schemas
from .availability import (
AvailabilityDay,
AvailabilityResponse,
CopyAvailabilityRequest,
SetAvailabilityRequest,
TimeSlot,
)
# Export exchange schemas
from .exchange import (
AdminExchangeResponse,
AvailableSlotsResponse,
BookableSlot,
ExchangeRequest,
ExchangeResponse,
ExchangeUserContact,
PaginatedAdminExchanges,
PaginatedExchanges,
)
# Export invite schemas
from .invite import (
InviteCheckResponse,
InviteCreate,
InviteResponse,
PaginatedInviteRecords,
UserInviteResponse,
)
# Export meta schemas
from .meta import ConstantsResponse
from .pagination import PaginatedResponse, RecordT
# Export price schemas
from .price import (
ExchangeConfigResponse,
ExchangePriceResponse,
PriceHistoryResponse,
PriceResponse,
)
# Export profile schemas
from .profile import ProfileResponse, ProfileUpdate
# Export user schemas
from .user import AdminUserResponse, UserResponse, UserSearchResult
__all__ = [
"AdminExchangeResponse",
"AdminUserResponse",
"AvailabilityDay",
"AvailabilityResponse",
"AvailableSlotsResponse",
"BookableSlot",
"ConstantsResponse",
"CopyAvailabilityRequest",
"ExchangeConfigResponse",
"ExchangePriceResponse",
"ExchangeRequest",
"ExchangeResponse",
"ExchangeUserContact",
"InviteCheckResponse",
"InviteCreate",
"InviteResponse",
"PaginatedAdminExchanges",
"PaginatedExchanges",
"PaginatedInviteRecords",
"PaginatedResponse",
"PriceHistoryResponse",
"PriceResponse",
"ProfileResponse",
"ProfileUpdate",
"RecordT",
"RegisterWithInvite",
"SetAvailabilityRequest",
"TimeSlot",
"UserCreate",
"UserCredentials",
"UserInviteResponse",
"UserLogin",
"UserResponse",
"UserSearchResult",
]

20
backend/schemas/auth.py Normal file
View file

@ -0,0 +1,20 @@
from pydantic import BaseModel, EmailStr
class UserCredentials(BaseModel):
"""Base model for user email/password."""
email: EmailStr
password: str
UserCreate = UserCredentials
UserLogin = UserCredentials
class RegisterWithInvite(BaseModel):
"""Request model for registration with invite."""
email: EmailStr
password: str
invite_identifier: str

View file

@ -0,0 +1,47 @@
from datetime import date, time
from pydantic import BaseModel, field_validator
class TimeSlot(BaseModel):
"""A single time slot (start and end time)."""
start_time: time
end_time: time
@field_validator("start_time", "end_time")
@classmethod
def validate_15min_boundary(cls, v: time) -> time:
"""Ensure times are on 15-minute boundaries."""
if v.minute not in (0, 15, 30, 45):
raise ValueError("Time must be on 15-minute boundary (:00, :15, :30, :45)")
if v.second != 0 or v.microsecond != 0:
raise ValueError("Time must not have seconds or microseconds")
return v
class AvailabilityDay(BaseModel):
"""Availability for a single day."""
date: date
slots: list[TimeSlot]
class AvailabilityResponse(BaseModel):
"""Response model for availability query."""
days: list[AvailabilityDay]
class SetAvailabilityRequest(BaseModel):
"""Request to set availability for a specific date."""
date: date
slots: list[TimeSlot]
class CopyAvailabilityRequest(BaseModel):
"""Request to copy availability from one day to others."""
source_date: date
target_dates: list[date]

View file

@ -0,0 +1,87 @@
from datetime import date, datetime
from pydantic import BaseModel
from .pagination import PaginatedResponse
class ExchangeRequest(BaseModel):
"""Request to create an exchange trade."""
slot_start: datetime
direction: str # "buy" or "sell"
bitcoin_transfer_method: str # "onchain" or "lightning"
eur_amount: int # EUR cents (e.g., 10000 = €100)
class ExchangeUserContact(BaseModel):
"""User contact info for admin view."""
email: str
contact_email: str | None
telegram: str | None
signal: str | None
nostr_npub: str | None
class ExchangeResponse(BaseModel):
"""Response model for an exchange trade."""
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
slot_start: datetime
slot_end: datetime
direction: str
bitcoin_transfer_method: str
eur_amount: int # EUR cents
sats_amount: int # Satoshis
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
class AdminExchangeResponse(BaseModel):
"""Response model for admin exchange view (includes user contact)."""
id: int # Keep for backward compatibility, but prefer public_id
public_id: str # UUID as string
user_id: int
user_email: str
user_contact: ExchangeUserContact
slot_start: datetime
slot_end: datetime
direction: str
bitcoin_transfer_method: str
eur_amount: int
sats_amount: int
market_price_eur: float
agreed_price_eur: float
premium_percentage: int
status: str
created_at: datetime
cancelled_at: datetime | None
completed_at: datetime | None
PaginatedExchanges = PaginatedResponse[ExchangeResponse]
PaginatedAdminExchanges = PaginatedResponse[AdminExchangeResponse]
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]

48
backend/schemas/invite.py Normal file
View file

@ -0,0 +1,48 @@
from datetime import datetime
from pydantic import BaseModel
from .pagination import PaginatedResponse
class InviteCheckResponse(BaseModel):
"""Response for invite check endpoint."""
valid: bool
status: str | None = None
error: str | None = None
class InviteCreate(BaseModel):
"""Request model for creating an invite."""
godfather_id: int
class InviteResponse(BaseModel):
"""Response model for invite data (admin view)."""
id: int
identifier: str
godfather_id: int
godfather_email: str
status: str
used_by_id: int | None
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
revoked_at: datetime | None
class UserInviteResponse(BaseModel):
"""Response model for a user's invite (simpler than admin view)."""
id: int
identifier: str
status: str
used_by_email: str | None
created_at: datetime
spent_at: datetime | None
PaginatedInviteRecords = PaginatedResponse[InviteResponse]

16
backend/schemas/meta.py Normal file
View file

@ -0,0 +1,16 @@
from pydantic import BaseModel
from models import BitcoinTransferMethod, InviteStatus, Permission
class ConstantsResponse(BaseModel):
"""Response model for shared constants.
Note: Using actual enum types ensures OpenAPI schema includes enum values,
allowing frontend type generation to produce matching TypeScript enums.
"""
permissions: list[Permission]
roles: list[str]
invite_statuses: list[InviteStatus]
bitcoin_transfer_methods: list[BitcoinTransferMethod]

View file

@ -0,0 +1,15 @@
from typing import Generic, TypeVar
from pydantic import BaseModel
RecordT = TypeVar("RecordT", bound=BaseModel)
class PaginatedResponse(BaseModel, Generic[RecordT]):
"""Generic paginated response wrapper."""
records: list[RecordT]
total: int
page: int
per_page: int
total_pages: int

44
backend/schemas/price.py Normal file
View file

@ -0,0 +1,44 @@
from datetime import datetime
from pydantic import BaseModel
class PriceHistoryResponse(BaseModel):
"""Response model for a price history record."""
id: int
source: str
pair: str
price: float
timestamp: datetime
created_at: datetime
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

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
class ProfileResponse(BaseModel):
"""Response model for profile data."""
contact_email: str | None
telegram: str | None
signal: str | None
nostr_npub: str | None
godfather_email: str | None = None
class ProfileUpdate(BaseModel):
"""Request model for updating profile."""
contact_email: str | None = None
telegram: str | None = None
signal: str | None = None
nostr_npub: str | None = None

24
backend/schemas/user.py Normal file
View file

@ -0,0 +1,24 @@
from pydantic import BaseModel
class UserResponse(BaseModel):
"""Response model for authenticated user info."""
id: int
email: str
roles: list[str]
permissions: list[str]
class AdminUserResponse(BaseModel):
"""Minimal user info for admin dropdowns."""
id: int
email: str
class UserSearchResult(BaseModel):
"""Result item for user search."""
id: int
email: str

View file

@ -65,9 +65,13 @@ async def seed_base_data(db: AsyncSession) -> None:
) )
db.add(regular_user) db.add(regular_user)
else: else:
# Update existing user # Update existing user - clear profile fields for test isolation
regular_user.hashed_password = get_password_hash(dev_user_password) regular_user.hashed_password = get_password_hash(dev_user_password)
regular_user.roles = [regular_role] regular_user.roles = [regular_role]
regular_user.contact_email = None
regular_user.telegram = None
regular_user.signal = None
regular_user.nostr_npub = None
# Create admin dev user # Create admin dev user
admin_user_result = await db.execute( admin_user_result = await db.execute(
@ -83,8 +87,12 @@ async def seed_base_data(db: AsyncSession) -> None:
) )
db.add(admin_user) db.add(admin_user)
else: else:
# Update existing user # Update existing user - clear profile fields for test isolation
admin_user.hashed_password = get_password_hash(dev_admin_password) admin_user.hashed_password = get_password_hash(dev_admin_password)
admin_user.roles = [admin_role] admin_user.roles = [admin_role]
admin_user.contact_email = None
admin_user.telegram = None
admin_user.signal = None
admin_user.nostr_npub = None
await db.commit() await db.commit()

View file

@ -65,7 +65,9 @@ test.describe("Availability Page - Admin Access", () => {
// Get the testid so we can find the same card later // Get the testid so we can find the same card later
const testId = await dayCardWithNoAvailability.getAttribute("data-testid"); const testId = await dayCardWithNoAvailability.getAttribute("data-testid");
const targetCard = page.locator(`[data-testid="${testId}"]`); if (!testId) {
throw new Error("Could not get testid from day card");
}
// First add availability // First add availability
await dayCardWithNoAvailability.click(); await dayCardWithNoAvailability.click();
@ -83,8 +85,14 @@ test.describe("Availability Page - Admin Access", () => {
await saveGetPromise; await saveGetPromise;
await expect(page.getByRole("heading", { name: /Edit Time Slots/ })).not.toBeVisible(); await expect(page.getByRole("heading", { name: /Edit Time Slots/ })).not.toBeVisible();
// Verify slot exists in the specific card we clicked // Re-query the card after save to avoid stale element references
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible(); // React may have re-rendered the entire list, so we need a fresh reference
const targetCard = page.locator(`[data-testid="${testId}"]`);
// Wait for "No availability" to disappear first, indicating slots have been loaded
await expect(targetCard.getByText("No availability")).not.toBeVisible({ timeout: 10000 });
// Then verify the specific slot text appears - this ensures the component has re-rendered
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible({ timeout: 5000 });
// Now clear it - click on the same card using the testid // Now clear it - click on the same card using the testid
await targetCard.click(); await targetCard.click();

View file

@ -10,6 +10,7 @@ import { getBackendUrl } from "./helpers/backend-url";
*/ */
// Set up availability for a date using the API // Set up availability for a date using the API
// Includes retry logic to handle race conditions with database reset
async function setAvailability(page: Page, dateStr: string) { async function setAvailability(page: Page, dateStr: string) {
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");
@ -18,21 +19,63 @@ async function setAvailability(page: Page, dateStr: string) {
throw new Error("No auth cookie found when trying to set availability"); throw new Error("No auth cookie found when trying to set availability");
} }
const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, { const maxRetries = 3;
headers: { let lastError: Error | null = null;
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
date: dateStr,
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
if (!response.ok()) { for (let attempt = 0; attempt < maxRetries; attempt++) {
const body = await response.text(); try {
throw new Error(`Failed to set availability: ${response.status()} - ${body}`); const response = await page.request.put(`${getBackendUrl()}/api/admin/availability`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
date: dateStr,
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
},
});
if (response.ok()) {
// Verify the response indicates success
const body = await response.json();
if (body.date === dateStr && body.slots?.length > 0) {
return; // Success
}
throw new Error(`Unexpected availability response: ${JSON.stringify(body)}`);
}
const body = await response.text();
const error = new Error(`Failed to set availability: ${response.status()} - ${body}`);
// Don't retry on 4xx errors (client errors), only on 5xx (server errors)
if (response.status() >= 400 && response.status() < 500) {
throw error;
}
lastError = error;
// Don't retry on the last attempt
if (attempt < maxRetries - 1) {
// Exponential backoff: 200ms, 400ms, 800ms
const delay = 200 * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Don't retry on the last attempt
if (attempt < maxRetries - 1) {
// Exponential backoff: 200ms, 400ms, 800ms
const delay = 200 * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
}
} }
// If we get here, all retries failed
throw new Error(`Failed to set availability after ${maxRetries} attempts: ${lastError?.message}`);
} }
test.describe("Exchange Page - Regular User Access", () => { test.describe("Exchange Page - Regular User Access", () => {

View file

@ -9,13 +9,41 @@ import { getBackendUrl } from "./backend-url";
/** /**
* Reset the database for the current worker. * Reset the database for the current worker.
* Truncates all tables and re-seeds base data. * Truncates all tables and re-seeds base data.
* Retries up to 3 times with exponential backoff to handle transient failures.
*/ */
export async function resetDatabase(request: APIRequestContext): Promise<void> { export async function resetDatabase(request: APIRequestContext): Promise<void> {
const backendUrl = getBackendUrl(); const backendUrl = getBackendUrl();
const response = await request.post(`${backendUrl}/api/test/reset`); const maxRetries = 3;
let lastError: Error | null = null;
if (!response.ok()) { for (let attempt = 0; attempt < maxRetries; attempt++) {
const text = await response.text(); try {
throw new Error(`Failed to reset database: ${response.status()} - ${text}`); const response = await request.post(`${backendUrl}/api/test/reset`);
if (response.ok()) {
// Verify the response body indicates success
const body = await response.json();
if (body.status === "reset") {
return; // Success
}
throw new Error(`Unexpected reset response: ${JSON.stringify(body)}`);
}
const text = await response.text();
throw new Error(`Failed to reset database: ${response.status()} - ${text}`);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Don't retry on the last attempt
if (attempt < maxRetries - 1) {
// Exponential backoff: 100ms, 200ms, 400ms
const delay = 100 * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
}
} }
// If we get here, all retries failed
throw new Error(`Failed to reset database after ${maxRetries} attempts: ${lastError?.message}`);
} }

View file

@ -22,13 +22,12 @@ test.beforeEach(async ({ context, request }, testInfo) => {
process.env.NEXT_PUBLIC_API_URL = backendUrl; process.env.NEXT_PUBLIC_API_URL = backendUrl;
// Reset database before each test for isolation // Reset database before each test for isolation
try { // This must complete successfully before tests run to avoid race conditions
await resetDatabase(request); await resetDatabase(request);
} catch (error) {
// If reset fails, log but don't fail the test // Small delay to ensure database transaction commits are visible
// This allows tests to run even if reset endpoint is unavailable // This prevents race conditions where tests start before reset completes
console.warn(`Failed to reset database: ${error}`); await new Promise((resolve) => setTimeout(resolve, 100));
}
// Add init script to set English language before any page loads // Add init script to set English language before any page loads
// This must be called before any page.goto() calls // This must be called before any page.goto() calls

View file

@ -48,24 +48,43 @@ async function loginUser(page: Page, email: string, password: string) {
} }
// Helper to clear profile data via API // Helper to clear profile data via API
// Verifies the operation succeeds to prevent race conditions
async function clearProfileData(page: Page) { async function clearProfileData(page: Page) {
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) {
await page.request.put(`${getBackendUrl()}/api/profile`, { throw new Error("No auth cookie found when trying to clear profile data");
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
},
});
} }
const response = await page.request.put(`${getBackendUrl()}/api/profile`, {
headers: {
Cookie: `auth_token=${authCookie.value}`,
"Content-Type": "application/json",
},
data: {
contact_email: null,
telegram: null,
signal: null,
nostr_npub: null,
},
});
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to clear profile data: ${response.status()} - ${text}`);
}
// Verify the response indicates fields were cleared
const body = await response.json();
if (body.telegram !== null && body.telegram !== undefined && body.telegram !== "") {
throw new Error(
`Profile data not cleared properly. Telegram still has value: ${body.telegram}`
);
}
// Small delay to ensure database commit is visible to subsequent operations
await new Promise((resolve) => setTimeout(resolve, 100));
} }
test.describe("Profile - Regular User Access", () => { test.describe("Profile - Regular User Access", () => {
@ -122,14 +141,23 @@ test.describe("Profile - Form Behavior", () => {
}); });
await clearAuth(page); await clearAuth(page);
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
// Clear any existing profile data // Clear any existing profile data and verify it's cleared
await clearProfileData(page); await clearProfileData(page);
// Navigate to profile page to verify it's actually cleared
await page.goto("/profile");
// Wait for page to load and verify fields are empty
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
await expect(page.getByLabel("Telegram")).toHaveValue("");
await expect(page.getByLabel("Signal")).toHaveValue("");
await expect(page.getByLabel("Nostr (npub)")).toHaveValue("");
}); });
test("form state management, save, persistence, and clearing fields", async ({ page }) => { test("form state management, save, persistence, and clearing fields", async ({ page }) => {
// Page is already loaded in beforeEach, but ensure we're on it
await page.goto("/profile"); await page.goto("/profile");
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
// All editable fields should be empty // All editable fields should be empty (verified in beforeEach, but double-check)
await expect(page.getByLabel("Contact Email")).toHaveValue(""); await expect(page.getByLabel("Contact Email")).toHaveValue("");
await expect(page.getByLabel("Telegram")).toHaveValue(""); await expect(page.getByLabel("Telegram")).toHaveValue("");
await expect(page.getByLabel("Signal")).toHaveValue(""); await expect(page.getByLabel("Signal")).toHaveValue("");
@ -166,13 +194,17 @@ test.describe("Profile - Form Behavior", () => {
await expect(page.getByText(/saved successfully/i)).toBeVisible(); await expect(page.getByText(/saved successfully/i)).toBeVisible();
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 - use clear() instead of fill("") for reliable clearing
await page.fill("#telegram", ""); await page.locator("#telegram").clear();
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();
await expect(page.getByText(/saved successfully/i)).not.toBeVisible({ timeout: 5000 });
// Reload and verify it's cleared // Reload and wait for page to fully load before checking
await page.reload(); await page.reload();
// Wait for the form to be loaded (check for a form field to ensure page is ready)
await expect(page.getByLabel("Contact Email")).toBeVisible({ timeout: 10000 });
// Verify telegram field is cleared
await expect(page.getByLabel("Telegram")).toHaveValue(""); await expect(page.getByLabel("Telegram")).toHaveValue("");
}); });
}); });