Compare commits

..

12 Commits

11 changed files with 743 additions and 5 deletions
@@ -0,0 +1,30 @@
"""add date_of_birth column to users table
Revision ID: 0002_add_date_of_birth_to_users
Revises: 0001_initial
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_add_date_of_birth_to_users"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add date_of_birth column to users table."""
op.add_column(
"users",
sa.Column("date_of_birth", sa.Date(), nullable=True),
)
def downgrade() -> None:
"""Remove date_of_birth column from users table."""
op.drop_column("users", "date_of_birth")
@@ -0,0 +1,329 @@
"""trust & safety: reports, moderation_actions, appeals + user role/ban/suspend
Revision ID: 0003_trust_safety
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0003_trust_safety"
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create trust & safety tables and extend users table."""
bind = op.get_bind()
# ── ENUM types ──────────────────────────────────────────────────────
# user_role
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(bind, checkfirst=True)
# report_target_type
report_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
)
report_target_type.create(bind, checkfirst=True)
# reason_category
reason_category = postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
)
reason_category.create(bind, checkfirst=True)
# report_status
report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status.create(bind, checkfirst=True)
# resolution
resolution_enum = postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution"
)
resolution_enum.create(bind, checkfirst=True)
# moderation_action_type
moderation_action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
)
moderation_action_type.create(bind, checkfirst=True)
# moderation_target_type
moderation_target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="moderation_target_type"
)
moderation_target_type.create(bind, checkfirst=True)
# appeal_status
appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status.create(bind, checkfirst=True)
# ── Extend users table ──────────────────────────────────────────────
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role"),
nullable=False,
server_default="user",
),
)
op.add_column(
"users",
sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"users",
sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True),
)
# ── reports table ───────────────────────────────────────────────────
op.create_table(
"reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message", name="report_target_type"
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
),
nullable=False,
),
sa.Column(
"details", sa.Text(), nullable=True,
),
sa.Column(
"status",
postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
),
nullable=False,
server_default="open",
),
sa.Column("resolved_by", sa.Integer(), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM(
"action_taken", "no_action", "false_positive", name="resolution"
),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
onupdate=sa.func.now(),
nullable=True,
),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
sa.UniqueConstraint(
"reporter_id", "target_type", "target_id",
name="uq_reporter_target",
),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
op.create_index(
op.f("ix_reports_reporter_id"), "reports", ["reporter_id"], unique=False
)
op.create_index(
op.f("ix_reports_status"), "reports", ["status"], unique=False
)
# ── moderation_actions table ────────────────────────────────────────
op.create_table(
"moderation_actions",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message",
name="moderation_target_type",
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=True),
sa.Column(
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="moderation_action_type",
),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["report_id"], ["reports.id"], ondelete="SET NULL"),
)
op.create_index(
op.f("ix_moderation_actions_id"),
"moderation_actions",
["id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_moderator_id"),
"moderation_actions",
["moderator_id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_target_user_id"),
"moderation_actions",
["target_user_id"],
unique=False,
)
op.create_index(
op.f("ix_moderation_actions_report_id"),
"moderation_actions",
["report_id"],
unique=False,
)
# ── appeals table ───────────────────────────────────────────────────
op.create_table(
"appeals",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"moderation_action_id", sa.Integer(), nullable=False
),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
),
nullable=False,
server_default="pending",
),
sa.Column("reviewed_by", sa.Integer(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(
["moderation_action_id"],
["moderation_actions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["reviewed_by"], ["users.id"], ondelete="SET NULL"
),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
op.create_index(
op.f("ix_appeals_moderation_action_id"),
"appeals",
["moderation_action_id"],
unique=False,
)
op.create_index(
op.f("ix_appeals_user_id"),
"appeals",
["user_id"],
unique=False,
)
op.create_index(
op.f("ix_appeals_status"),
"appeals",
["status"],
unique=False,
)
def downgrade() -> None:
"""Remove trust & safety tables and revert users table."""
op.drop_index(op.f("ix_appeals_status"), table_name="appeals")
op.drop_index(op.f("ix_appeals_user_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_report_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_moderator_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_status"), table_name="reports")
op.drop_index(op.f("ix_reports_reporter_id"), table_name="reports")
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="moderation_action_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="resolution").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_status").drop(bind, checkfirst=True)
postgresql.ENUM(name="reason_category").drop(bind, checkfirst=True)
postgresql.ENUM(name="report_target_type").drop(bind, checkfirst=True)
postgresql.ENUM(name="user_role").drop(bind, checkfirst=True)
+4 -1
View File
@@ -2,5 +2,8 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.report import Report
from app.models.moderation_action import ModerationAction
from app.models.appeal import Appeal
__all__ = ["User", "Session", "World", "Asset"]
__all__ = ["User", "Session", "World", "Asset", "Report", "ModerationAction", "Appeal"]
+47
View File
@@ -0,0 +1,47 @@
"""Appeal model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, autoincrement=True)
moderation_action_id = Column(
Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False
)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
reason = Column(Text, nullable=False)
status = Column(
ENUM(AppelStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewer = relationship("User", foreign_keys=[reviewed_by])
+63
View File
@@ -0,0 +1,63 @@
"""ModerationAction model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ModerationActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class ModerationTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, autoincrement=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
target_type = Column(
ENUM(ModerationTargetType, name="moderation_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=True)
action = Column(
ENUM(ModerationActionType, name="moderation_action_type", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Relationships
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", foreign_keys=[report_id])
appeals = relationship("Appeal", back_populates="moderation_action", cascade="all, delete-orphan")
+88
View File
@@ -0,0 +1,88 @@
"""Report model for Trust & Safety."""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class ReportTargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken"
NO_ACTION = "no_action"
FALSE_POSITIVE = "false_positive"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, autoincrement=True)
reporter_id = Column(Integer, ForeignKey("users.id"), nullable=False)
target_type = Column(
ENUM(ReportTargetType, name="report_target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
)
resolved_by = Column(Integer, ForeignKey("users.id"), nullable=True)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
nullable=True,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
__table_args__ = (
UniqueConstraint("reporter_id", "target_type", "target_id",
name="uq_reporter_target"),
)
+36 -1
View File
@@ -1,10 +1,20 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class UserRole(str, enum.Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -12,10 +22,35 @@ class User(Base):
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
Enum(UserRole, name="user_role"),
nullable=False,
server_default="user",
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter")
reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user")
moderation_actions_as_moderator = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
moderation_actions_as_target = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
appeals = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
if self.date_of_birth is None:
return False
today = date.today()
age = today.year - self.date_of_birth.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day):
age -= 1
return age >= 18
+49 -3
View File
@@ -1,10 +1,10 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from jose import jwt
from passlib.context import CryptContext
@@ -32,12 +32,24 @@ class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
date_of_birth: str # ISO format date string (YYYY-MM-DD)
@field_validator("date_of_birth")
@classmethod
def validate_date_of_birth(cls, v: str) -> str:
"""Validate that the date string is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError("date_of_birth must be in YYYY-MM-DD format")
return v
class UserResponse(BaseModel):
id: int
username: str
email: str
date_of_birth: str | None = None
is_active: bool
created_at: datetime | None = None
@@ -65,11 +77,44 @@ def hash_password(password: str) -> str:
return pwd_context.hash(password)
def calculate_age(birth_date: date, today: date | None = None) -> int:
"""Calculate age in years from a birth date.
Args:
birth_date: The person's date of birth.
today: The reference date. Defaults to today's date (UTC).
Returns:
Age in whole years as of the reference date.
"""
if today is None:
today = datetime.now(timezone.utc).date()
age = today.year - birth_date.year
# Adjust if birthday hasn't occurred yet this year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token."""
"""Register a new user and return an access token.
Requires date_of_birth and rejects users under 18.
"""
# Parse and validate date_of_birth
dob = datetime.strptime(req.date_of_birth, "%Y-%m-%d").date()
age = calculate_age(dob)
if age < 18:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Must be 18 or older to register (provided date of birth: {req.date_of_birth})",
)
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
@@ -90,6 +135,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
date_of_birth=dob,
)
db.add(user)
await db.commit()
+3
View File
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
View File
+94
View File
@@ -0,0 +1,94 @@
"""Tests for the 18+ age gate on user registration."""
from datetime import date, timedelta
import pytest
from app.routers.auth import calculate_age
class TestCalculateAge:
"""Unit tests for the calculate_age helper."""
def test_exactly_18(self):
"""A person born exactly 18 years ago today is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18)
assert calculate_age(dob, today=today) == 18
def test_just_turned_18(self):
"""A person who turned 18 yesterday is 18."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) - timedelta(days=1)
assert calculate_age(dob, today=today) == 18
def test_one_day_short_of_18(self):
"""A person born one day after their 18th birthday is 17."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 18) + timedelta(days=1)
assert calculate_age(dob, today=today) == 17
def test_17_years_old(self):
"""A person who is 17 years old today is 17."""
today = date(2025, 6, 15)
dob = date(2008, 6, 15)
assert calculate_age(dob, today=today) == 17
def test_65_years_old(self):
"""A person who is 65 is correctly calculated."""
today = date(2025, 6, 15)
dob = today.replace(year=today.year - 65)
assert calculate_age(dob, today=today) == 65
def test_newborn(self):
"""A newborn is 0."""
today = date(2025, 6, 15)
dob = today
assert calculate_age(dob, today=today) == 0
def test_birthday_not_yet_this_year(self):
"""Person born Dec 31, 2000 — on Jan 1, 2025 they are still 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2025, 1, 1)) == 24
def test_birthday_today(self):
"""Person born Dec 31, 2000 — on Dec 31, 2024 they turn 24."""
dob = date(2000, 12, 31)
assert calculate_age(dob, today=date(2024, 12, 31)) == 24
class TestRegisterRequestDateValidation:
"""Tests for the RegisterRequest date_of_birth field validator."""
@pytest.mark.parametrize(
"dob",
[
"2000-01-01",
"1990-12-31",
"2004-02-29", # leap day
],
)
def test_valid_dates(self, dob):
"""Valid YYYY-MM-DD dates pass validation."""
from app.routers.auth import RegisterRequest
req = RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)
assert req.date_of_birth == dob
@pytest.mark.parametrize(
"dob",
[
"01-01-2000",
"2000/01/01",
"2000-13-01",
"2000-00-10",
"not-a-date",
"",
],
)
def test_invalid_dates_rejected(self, dob):
"""Invalid dates are rejected by the validator."""
from app.routers.auth import RegisterRequest
with pytest.raises(ValueError):
RegisterRequest(username="u", email="u@example.com", password="p", date_of_birth=dob)