Compare commits

..

46 Commits

Author SHA1 Message Date
daddy 5e1a3bb16e feat(marketplace): purchase service with atomic escrow, 30/70 fee splitting and refunds 2026-09-15 20:41:07 +00:00
daddy 46cd5d6c95 Merge branch 'agent/f51c7d95e237-marketplace-listings-api' into main
checkpoint / checkpoint (push) Successful in 5m31s
2026-09-15 20:36:33 +00:00
daddy 90cc736d37 feat(marketplace): add marketplace listings API and catalog items CRUD 2026-09-15 20:09:07 +00:00
daddy 18306c72e0 Merge branch 'agent/166836d55365-marketplace-models' into main
checkpoint / checkpoint (push) Successful in 5m21s
2026-09-15 19:58:37 +00:00
daddy 0b7f407ad2 Implement CatalogItem, MarketplaceListing, Purchase, CreatorBalance models and Alembic migration 0013 (task 166836d55365) 2026-09-15 19:58:32 +00:00
daddy 30698a77d6 Merge branch 'agent/575fd14b5360-payment-provider-endpoints' into main
checkpoint / checkpoint (push) Successful in 5m19s
2026-09-15 19:56:31 +00:00
daddy 2b78729e6b Implement PaymentProvider interface, NoopPaymentProvider, and purchase/confirm endpoints (task 575fd14b5360) 2026-09-15 19:56:24 +00:00
daddy c9a8c4dfb2 Merge branch 'agent/909e3e9a59af-wallet-service-api' into main
checkpoint / checkpoint (push) Successful in 5m16s
2026-09-15 19:54:12 +00:00
daddy d2bf0e9118 Implement WalletService with atomic credit/debit, row locking, and wallet/transactions API (task 909e3e9a59af) 2026-09-15 19:54:03 +00:00
daddy 80cbcd334b Merge branch 'agent/8343bc0a730f-wallet-models' into main
checkpoint / checkpoint (push) Successful in 5m16s
2026-09-15 19:50:57 +00:00
daddy 64dd8b5f09 Implement wallet and transaction ledger models and Alembic migration (task 8343bc0a730f) 2026-09-15 19:50:53 +00:00
daddy 430042c585 Merge branch 'agent/909744ff1eee-godot-groups-ui' into main
checkpoint / checkpoint (push) Successful in 5m13s
2026-09-15 19:40:53 +00:00
daddy 8e54fee03d Implement Godot groups UI with browse, create, group page, and role management (task 909744ff1eee) 2026-09-15 19:40:41 +00:00
daddy 5b993aa358 Merge branch 'agent/121de83d3be8-godot-profile-ui' into main
checkpoint / checkpoint (push) Successful in 5m10s
2026-09-15 19:38:05 +00:00
daddy 9b381a2b50 Implement Godot profile page UI with bio editing, privacy controls, favorites, and friends (task 121de83d3be8) 2026-09-15 19:37:50 +00:00
daddy a3f895aa19 Merge branch 'agent/9b05df0c6869-godot-chat-ui' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:35:25 +00:00
daddy 484b455e44 Implement Godot chat UI with DMs, world chat, real-time WebSocket receipt, and reporting (task 9b05df0c6869) 2026-09-15 19:35:13 +00:00
daddy 056e5c9f86 Merge branch 'agent/af249a67f693-godot-friends-presence-ui' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:32:46 +00:00
daddy e112467b53 Implement Godot friends & presence UI (task af249a67f693) 2026-09-15 19:32:35 +00:00
daddy df75e87885 Merge branch 'agent/264d84d07f28-godot-social-client' into main
checkpoint / checkpoint (push) Successful in 5m9s
2026-09-15 19:25:47 +00:00
daddy 246b7ac996 Implement Godot API client with REST social helpers and WebSocket gateway client (task 264d84d07f28) 2026-09-15 19:25:27 +00:00
daddy 21397b710a Merge branch 'agent/062e441c1419-ws-gateway' into main
checkpoint / checkpoint (push) Successful in 5m6s
2026-09-15 18:58:33 +00:00
daddy e5bcd61ee4 Implement real-time WebSocket gateway for presence, DM, and world chat (US-4, US-2) 2026-09-15 18:58:19 +00:00
daddy 4fc297be43 Merge branch 'agent/39df5555656f-world-chat' into main
checkpoint / checkpoint (push) Successful in 4m59s
2026-09-15 17:55:56 +00:00
daddy 39a971f00e Implement in-experience (world/room) chat endpoints and pub/sub hook (US-4) 2026-09-15 17:55:47 +00:00
daddy d3389a9f48 Merge branch 'agent/8aaf7c48d376-reports' into main
checkpoint / checkpoint (push) Successful in 4m49s
2026-09-15 17:31:44 +00:00
daddy 429cf6f46c Implement message reporting endpoint and moderation review queue (US-4)
- POST /api/chat/messages/{message_id}/report for reporting messages with category/reason
- Idempotent duplicate report rejection (409)
- GET /api/moderation/reports for queue listing (restricted to moderators/admins)
- POST /api/moderation/reports/{id}/resolve supporting dismiss, remove, warn, ban actions
- Audit fields (moderator_id, resolved_at, action) recorded on resolution
- Added 9 integration tests in backend/tests/test_chat_reports.py (all passing)
2026-09-15 17:31:43 +00:00
daddy 01a5520c20 Merge branch 'agent/5645c26acd5c-public-profile' into main
checkpoint / checkpoint (push) Successful in 4m36s
2026-09-15 17:18:29 +00:00
daddy 0b525d6996 Add public profile view endpoint: favorites, friends, privacy enforcement (US-3)
- Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py
- Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility
- Block relationships enforce restricted view (no bio, empty favorites, empty friends)
- Non-friends restricted when profile_visibility or sections set to friends-only
- Asset public check for avatar in restricted view
- No private user fields leaked (email, password excluded)
- Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations

Independently verified: 26/26 profile tests passing.
2026-09-15 17:18:28 +00:00
daddy eacdeb5b46 Merge branch 'agent/d6b3f928e9ad-dm-chat' into main
checkpoint / checkpoint (push) Successful in 4m30s
2026-09-15 16:56:42 +00:00
daddy 8b17c17137 Add DM chat send/list endpoints: filter, rate limit, conversations (US-4)
Implements direct-message (DM) chat send and history endpoints:
- Migration 0011_add_dm_last_read.py (last_read_at on conversation_participants)
- Models updated in backend/app/models/chat.py
- Router in backend/app/routers/chat_dm.py:
  - POST /api/chat/dm/{user_id}/messages (send DM, filtered via TextFilter, rate-limited via Redis)
  - GET  /api/chat/dm/{user_id}/messages (paginated history, updates last_read_at)
  - GET  /api/chat/dm/conversations (conversations list, last message, unread count)
- Blocks enforced in both directions (cannot send to/from blocked users)
- Self-messaging prevented, peer exist check, auth required
- Integration tests in backend/tests/test_chat_dm.py

Independently re-verified: 14/14 new DM chat tests pass,
272/272 full backend regression suite passes with zero regressions.
2026-09-15 16:56:13 +00:00
daddy 24024a3463 Merge branch 'agent/86f6ef19b352-profile-model' into main
checkpoint / checkpoint (push) Successful in 4m8s
2026-09-15 16:44:16 +00:00
daddy 0a0dde3d50 Add Profile model + migration + own-profile endpoints (US-3)
Implements user profile data model and own-profile management endpoints:
- Profile and ProfileFavorites models in backend/app/models/profile.py
- Migration 0010_add_profile_tables.py (profiles, profile_favorites)
- Endpoints in backend/app/routers/profiles.py:
  - GET  /api/profile/me (own profile with default fallback)
  - PUT  /api/profile/me (upsert bio, avatar_asset_id, privacy settings)
  - GET  /api/profile/me/favorites (list favorite world IDs)
  - PUT  /api/profile/me/favorites (replace favorite world IDs)
- Fixes values_callable on Asset.asset_type ENUM in app/models/asset.py
- Added test_profile.py covering auth, validation, favorites, privacy

Independently re-verified: 19/19 new profile tests pass,
258/258 full backend regression suite passes with zero regressions.
2026-09-15 16:44:00 +00:00
daddy abd05b266d Merge branch 'agent/8bc166bd983b-friends-router' into main
checkpoint / checkpoint (push) Successful in 3m57s
2026-09-15 16:28:31 +00:00
daddy 11c4810231 Add Friends & follow API router: requests, accept/decline, follow, block
Implements docs/planning/social.md US-1's API layer on top of the
merged Friendship/Follow/Block models (app/models/social.py):

- POST/GET /api/friends/requests, POST .../accept, POST .../decline
- DELETE /api/friends/{user_id} (unfriend)
- POST/DELETE /api/friends/{user_id}/follow, GET /api/friends/following
- POST/DELETE /api/friends/{user_id}/block, GET /api/friends/blocked
- GET /api/friends (mutual friends list)

All endpoints require JWT auth. Requests are scoped to caller (no
cross-account enumeration); duplicate/self requests are rejected
idempotently; block clears mutual friendship and follow in both directions.

Independently re-verified: 19/19 new unit/integration tests pass,
239/239 full backend test suite passes with zero regressions.
2026-09-15 16:28:09 +00:00
daddy e2c8671c57 Groups API (CRUD, join/leave, roles, group page)
checkpoint / checkpoint (push) Successful in 3m29s
Full CRUD + membership lifecycle on top of the merged Group/GroupMember
models: create/get/update/delete, join (idempotent)/leave (owner must
transfer first), role-set (owner-only, rejects setting role=owner --
must use /transfer), ownership transfer (exactly-one-owner invariant:
promotes new owner, demotes old to admin). Group page member list uses
an explicit allowlisted schema, no private user fields leak.

Independently re-verified: 220/220 backend tests, transfer/leave/
role-set authorization logic read directly and confirmed sound.
2026-09-15 12:11:42 +00:00
daddy f6c55d888f Add Groups API router: create/join/leave, roles, transfer, group page
Implements docs/planning/social.md US-5's API layer on top of the
already-merged Group/GroupMember models (app/models/groups.py):

- POST/GET /api/groups, GET/PATCH/DELETE /api/groups/{id}
- POST .../join (idempotent), .../leave (owner must transfer first)
- POST .../members/{user_id}/role (owner-only, admin/member),
  .../transfer (owner-only, demotes prior owner to admin)

Group page member list is a dedicated schema (user_id/username/role/
joined_at) so no private User fields leak. 27 new tests cover
authorization boundaries, idempotency, and exactly-one-owner invariants;
full backend suite (220 tests) passes with zero regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QFSPt6rX1gwZmcr2HEfvzg
2026-09-15 01:09:29 +00:00
daddy eac6cf81ef Text filtering service + profanity config
checkpoint / checkpoint (push) Successful in 2m56s
TextFilter protocol + ListBasedTextFilter: normalized (case-fold +
leetspeak/lookalike substitution, non-alnum stripped) substring matching
against a configurable JSON term list, with an index map so matched spans
in normalized text translate back to the original text for redaction.
Supports both redact and reject modes. No external service dependency;
the protocol itself is the extension point for a future third-party
provider. Standalone service module, not yet wired into any endpoint
(sibling tasks own that).

Independently re-verified: 193/193 backend tests, no regressions.
2026-09-15 01:02:00 +00:00
daddy 10a431e7a1 Merge branch 'main' into verify-4daac3f1cfba 2026-09-15 00:37:58 +00:00
daddy 0b3622d6b3 Group + membership models + migration
checkpoint / checkpoint (push) Successful in 2m55s
groups (owner SET NULL -- a group is a durable community, not
single-owner content, survives owner deletion) and group_members
(owner/admin/member role, unique group_id+user_id, CASCADE both FKs).
Models/migration only, no API endpoints (sibling task).

Independently re-verified: migration round-trip, schema inspected
directly (FKs/constraints/indexes match spec), 170/170 backend tests.
2026-09-15 00:37:47 +00:00
daddy eb2328b427 Merge branch 'main' into verify-ff065522ba55 2026-09-15 00:35:40 +00:00
daddy 55f849f221 Reusable named rate-limiting dependency (extend existing in-process limiter)
checkpoint / checkpoint (push) Successful in 2m48s
Generalizes app/rate_limit.py's SlidingWindowLimiter pattern (deliberately
in-process, no Redis -- this backend runs single-worker per that file's
own documented reasoning) into a named-limit factory: rate_limit(name,
max_hits=, window_seconds=) returns a FastAPI dependency, reusing the
existing 429+Retry-After behavior and RATE_LIMIT_ENABLED flag. Used by
chat-send/report endpoints (sibling tasks). auth_rate_limit itself
untouched; reset_all() generalized to cover every registered limiter.

Independently re-verified: 164/164 backend tests, no regressions.
2026-09-15 00:35:28 +00:00
daddy 600261dba2 Merge branch 'main' into verify-9eaf37ab424c 2026-09-15 00:33:52 +00:00
daddy c256fb2334 tests/conftest.py: derive the hermetic-bootstrap compose project/ports per checkout
checkpoint / checkpoint (push) Successful in 2m47s
Hardcoded _COMPOSE_PROJECT="agp-pytest" + fixed ports meant every
concurrent checkout running pytest (e.g. multiple Agent-tool subagents,
each in their own workspace clone) fought over the same Docker Compose
project and host ports -- one run's pytest_unconfigure teardown could
yank the ephemeral Postgres out from under a completely unrelated
concurrent run. Confirmed live running 3 parallel subagents: one hit
"connection was closed in the middle of operation", another independently
hit a transient DeadlockDetectedError from concurrent table truncation on
the same shared container. Same root cause and fix pattern as the
agent_core workspace/Docker-Compose collision documented in project_agp
memory -- derive both the project name and the ports from a hash of the
checkout directory so concurrent checkouts land on different
projects/ports while a single checkout stays stable across repeated runs.
2026-09-15 00:33:46 +00:00
daddy 712d21e746 Add groups + group_members models and migration
docs/planning/social.md US-5 (task 13). Models + Alembic migration only,
per the sibling-dependency boundary with the Groups API task.

- groups: id, name, description, owner_id (FK users, ON DELETE SET NULL --
  a group is a durable multi-member community, so it and its full roster
  survive the owner deleting their account; the API task handles surfacing
  an ownerless group for reassignment).
- group_members: group_id/user_id (both FK, ON DELETE CASCADE -- pure
  join-table rows with no meaning without either side), role
  (owner/admin/member native enum, default member), joined_at, unique
  constraint on (group_id, user_id).
- User gets owned_groups / group_memberships relationships.
- tests/conftest.py TEST_TABLES updated for truncation between tests.
- New tests/test_group_models.py: role assignment, unique-membership
  rejection, group-delete cascade, member-delete cascade, and owner-delete
  SET NULL survival -- all verified against real Postgres.

Verified: upgrade/downgrade/upgrade round-trip, \d-inspected schema matches
spec (FKs, cascade behavior, unique constraint, indexes), full backend
suite 166 passed / 0 regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:28:46 +00:00
daddy de2d431571 Generalize rate_limit.py into a reusable named-limit dependency
Adds rate_limit(name, max_hits=, window_seconds=) as a factory for FastAPI
dependencies, generalizing the auth_rate_limit pattern for future endpoints
(chat send, message report) without touching auth_rate_limit's own behavior.
Reuses SlidingWindowLimiter for counting and the existing 429 + Retry-After
response, and respects RATE_LIMIT_ENABLED the same way auth_rate_limit does.
reset_all() now clears every registered limiter (auth's plus any named ones),
so the conftest autouse fixture keeps working for sibling tasks automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:26:48 +00:00
64 changed files with 11629 additions and 6 deletions
@@ -0,0 +1,80 @@
"""add group tables (groups, group_members)
Revision ID: 0009_add_group_tables
Revises: 0008_add_chat_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-5 / app/models/groups.py. Models + migration only
-- create/join/leave, role authorization, and the group page endpoint live
in a sibling task ("Groups API") that depends on this one.
"""
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 = "0009_add_group_tables"
down_revision: Union[str, None] = "0008_add_chat_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
group_role = postgresql.ENUM("owner", "admin", "member", name="group_role")
group_role.create(op.get_bind(), checkfirst=True)
op.create_table(
"groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"owner_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
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), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_groups_owner_id", "groups", ["owner_id"])
op.create_table(
"group_members",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"group_id",
sa.Integer(),
sa.ForeignKey("groups.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"role",
postgresql.ENUM("owner", "admin", "member", name="group_role", create_type=False),
nullable=False,
server_default="member",
),
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
op.create_index("ix_group_members_group_id", "group_members", ["group_id"])
op.create_index("ix_group_members_user_id", "group_members", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_group_members_user_id", table_name="group_members")
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_owner_id", table_name="groups")
op.drop_table("groups")
postgresql.ENUM(name="group_role").drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,65 @@
"""add profile tables (profiles, profile_favorites)
Revision ID: 0010_add_profile_tables
Revises: 0009_add_group_tables
Create Date: 2026-09-15 00:00:00.000000
app/models/profile.py. Models + migration + owner-only /api/profile/me
endpoints only -- the public profile view endpoint (GET /api/profile/{user_id})
belongs to a sibling task that depends on this one.
"""
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 = "0010_add_profile_tables"
down_revision: Union[str, None] = "0009_add_group_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"profiles",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column(
"avatar_asset_id",
sa.Integer(),
sa.ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("bio", sa.Text(), nullable=True),
sa.Column("privacy_json", postgresql.JSONB(), nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profiles_avatar_asset_id", "profiles", ["avatar_asset_id"])
op.create_table(
"profile_favorites",
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_profile_favorites_world_id", "profile_favorites", ["world_id"])
def downgrade() -> None:
op.drop_index("ix_profile_favorites_world_id", table_name="profile_favorites")
op.drop_table("profile_favorites")
op.drop_index("ix_profiles_avatar_asset_id", table_name="profiles")
op.drop_table("profiles")
@@ -0,0 +1,36 @@
"""add last_read_at to conversation_participants (DM unread tracking)
Revision ID: 0011_add_dm_last_read
Revises: 0010_add_profile_tables
Create Date: 2026-09-15 00:00:00.000000
app/routers/chat_dm.py (DM send/list/conversations endpoints). Unread-count
tracking is implemented as a last-read timestamp per participant: whenever a
user fetches a conversation's message history, their
``conversation_participants`` row's ``last_read_at`` is bumped to "now", and
the conversation-list endpoint counts messages with
``created_at > last_read_at`` (or all messages if never read) as unread for
that participant. Nullable/no default so existing rows (none yet in
practice, but consistent with "never read") read as "everything unread".
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0011_add_dm_last_read"
down_revision: Union[str, None] = "0010_add_profile_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"conversation_participants",
sa.Column("last_read_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("conversation_participants", "last_read_at")
@@ -0,0 +1,90 @@
"""add wallets and transactions ledger tables
Revision ID: 0012_add_wallet_and_transactions
Revises: 0011_add_dm_last_read
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-1 (task 8343bc0a730f).
"""
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 = "0012_add_wallet_and_transactions"
down_revision: Union[str, None] = "0011_add_dm_last_read"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
TRANSACTION_TYPES = (
"credit_purchase",
"marketplace_purchase",
"marketplace_sale",
"escrow_release",
"payout",
"refund",
"admin_adjustment",
)
def upgrade() -> None:
# 1. Create transaction_type enum for Postgres
bind = op.get_bind()
if bind.dialect.name == "postgresql":
tx_type_enum = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
tx_type_enum.create(bind, checkfirst=True)
tx_type_col = postgresql.ENUM(*TRANSACTION_TYPES, name="transaction_type", create_type=False)
else:
tx_type_col = sa.Enum(*TRANSACTION_TYPES, name="transaction_type")
# 2. Create wallets table
op.create_table(
"wallets",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("balance", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_wallets_user_id"),
)
op.create_index(op.f("ix_wallets_user_id"), "wallets", ["user_id"], unique=True)
# 3. Create transactions table
op.create_table(
"transactions",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("wallet_id", sa.BigInteger(), nullable=False),
sa.Column("type", tx_type_col, nullable=False),
sa.Column("amount", sa.BigInteger(), nullable=False),
sa.Column("balance_after", sa.BigInteger(), nullable=False),
sa.Column("reference_type", sa.String(), nullable=True),
sa.Column("reference_id", sa.String(), nullable=True),
sa.Column("idempotency_key", sa.String(), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if bind.dialect.name == "postgresql" else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("idempotency_key", name="uq_transactions_idempotency_key"),
)
op.create_index(op.f("ix_transactions_wallet_id"), "transactions", ["wallet_id"], unique=False)
op.create_index(op.f("ix_transactions_idempotency_key"), "transactions", ["idempotency_key"], unique=True)
op.create_index(op.f("ix_transactions_created_at"), "transactions", ["created_at"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_transactions_created_at"), table_name="transactions")
op.drop_index(op.f("ix_transactions_idempotency_key"), table_name="transactions")
op.drop_index(op.f("ix_transactions_wallet_id"), table_name="transactions")
op.drop_table("transactions")
op.drop_index(op.f("ix_wallets_user_id"), table_name="wallets")
op.drop_table("wallets")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*TRANSACTION_TYPES, name="transaction_type").drop(bind, checkfirst=True)
@@ -0,0 +1,146 @@
"""add marketplace tables: catalog_items, marketplace_listings, purchases, creator_balances
Revision ID: 0013_add_marketplace_tables
Revises: 0012_add_wallet_and_transactions
Create Date: 2026-09-15 00:00:00.000000
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
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 = "0013_add_marketplace_tables"
down_revision: Union[str, None] = "0012_add_wallet_and_transactions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ITEM_TYPES = ("asset", "avatar_item", "experience_pass")
LISTING_STATUSES = ("active", "sold", "delisted", "removed_by_moderation")
PURCHASE_STATUSES = ("pending_escrow", "completed", "refunded", "reversed")
def upgrade() -> None:
bind = op.get_bind()
is_pg = bind.dialect.name == "postgresql"
# Enums
if is_pg:
item_type_enum = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
item_type_enum.create(bind, checkfirst=True)
item_type_col = postgresql.ENUM(*ITEM_TYPES, name="catalog_item_type", create_type=False)
listing_status_enum = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
listing_status_enum.create(bind, checkfirst=True)
listing_status_col = postgresql.ENUM(*LISTING_STATUSES, name="marketplace_listing_status", create_type=False)
purchase_status_enum = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
purchase_status_enum.create(bind, checkfirst=True)
purchase_status_col = postgresql.ENUM(*PURCHASE_STATUSES, name="purchase_status", create_type=False)
else:
item_type_col = sa.Enum(*ITEM_TYPES, name="catalog_item_type")
listing_status_col = sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status")
purchase_status_col = sa.Enum(*PURCHASE_STATUSES, name="purchase_status")
# 1. catalog_items
op.create_table(
"catalog_items",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("asset_id", sa.Integer(), nullable=True),
sa.Column("item_type", item_type_col, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=2000), nullable=True),
sa.Column("metadata_json", postgresql.JSONB(astext_type=sa.Text()) if is_pg else sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["asset_id"], ["assets.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_catalog_items_owner_id"), "catalog_items", ["owner_id"], unique=False)
op.create_index(op.f("ix_catalog_items_asset_id"), "catalog_items", ["asset_id"], unique=False)
# 2. marketplace_listings
op.create_table(
"marketplace_listings",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("catalog_item_id", sa.BigInteger(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("status", listing_status_col, server_default="active", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("price > 0", name="check_listing_price_positive"),
sa.ForeignKeyConstraint(["catalog_item_id"], ["catalog_items.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_marketplace_listings_catalog_item_id"), "marketplace_listings", ["catalog_item_id"], unique=False)
op.create_index(op.f("ix_marketplace_listings_seller_id"), "marketplace_listings", ["seller_id"], unique=False)
# 3. purchases
op.create_table(
"purchases",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("listing_id", sa.BigInteger(), nullable=True),
sa.Column("buyer_id", sa.Integer(), nullable=False),
sa.Column("seller_id", sa.Integer(), nullable=False),
sa.Column("price", sa.BigInteger(), nullable=False),
sa.Column("platform_fee", sa.BigInteger(), nullable=False),
sa.Column("seller_net", sa.BigInteger(), nullable=False),
sa.Column("status", purchase_status_col, server_default="pending_escrow", nullable=False),
sa.Column("escrow_hold_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["listing_id"], ["marketplace_listings.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["buyer_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["seller_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_purchases_listing_id"), "purchases", ["listing_id"], unique=False)
op.create_index(op.f("ix_purchases_buyer_id"), "purchases", ["buyer_id"], unique=False)
op.create_index(op.f("ix_purchases_seller_id"), "purchases", ["seller_id"], unique=False)
# 4. creator_balances
op.create_table(
"creator_balances",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("available", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("pending", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("lifetime_earned", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
sa.CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
sa.CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", name="uq_creator_balances_user_id"),
)
op.create_index(op.f("ix_creator_balances_user_id"), "creator_balances", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_creator_balances_user_id"), table_name="creator_balances")
op.drop_table("creator_balances")
op.drop_index(op.f("ix_purchases_seller_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_buyer_id"), table_name="purchases")
op.drop_index(op.f("ix_purchases_listing_id"), table_name="purchases")
op.drop_table("purchases")
op.drop_index(op.f("ix_marketplace_listings_seller_id"), table_name="marketplace_listings")
op.drop_index(op.f("ix_marketplace_listings_catalog_item_id"), table_name="marketplace_listings")
op.drop_table("marketplace_listings")
op.drop_index(op.f("ix_catalog_items_asset_id"), table_name="catalog_items")
op.drop_index(op.f("ix_catalog_items_owner_id"), table_name="catalog_items")
op.drop_table("catalog_items")
bind = op.get_bind()
if bind.dialect.name == "postgresql":
sa.Enum(*PURCHASE_STATUSES, name="purchase_status").drop(bind, checkfirst=True)
sa.Enum(*LISTING_STATUSES, name="marketplace_listing_status").drop(bind, checkfirst=True)
sa.Enum(*ITEM_TYPES, name="catalog_item_type").drop(bind, checkfirst=True)
+30
View File
@@ -53,3 +53,33 @@ async def get_current_user(
CurrentUser = Annotated[User, Depends(get_current_user)]
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_optional_user(
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User | None:
"""Resolve the JWT bearer token to a User if present and valid, otherwise None."""
if not token:
return None
try:
payload = jwt.decode(
token, settings.public_pem, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
return None
user_id_int = int(user_id)
except (JWTError, ValueError, TypeError):
return None
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
+1
View File
@@ -0,0 +1 @@
"""WebSocket real-time gateway package."""
+538
View File
@@ -0,0 +1,538 @@
"""Real-time WebSocket gateway.
Multiplexes presence, DM, and world-chat delivery over a single WebSocket
endpoint (docs/planning/social.md US-2, US-4).
Key features:
- Authentication via JWT token (query param or initial auth frame).
- Scoped subscriptions to DM conversations and world channels.
- Server-derived presence heartbeat (Redis TTL refreshed periodically).
- Broadcast presence changes (online/offline) to mutual friends.
- Fan-out via Redis pub/sub.
- Thread-safe & event-loop-safe dispatch for multi-instance and test environments.
- Graceful reconnect without duplicate delivery or orphaned state.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
import json
import logging
from typing import Any
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from jose import JWTError, jwt
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
import redis.asyncio as aioredis
from app.config import settings
from app.database import async_session_factory, get_db
from app.models.chat import ConversationParticipant
from app.models.social import Friendship, FriendshipStatus
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.services.presence import presence_store
logger = logging.getLogger(__name__)
router = APIRouter()
@asynccontextmanager
async def get_db_session():
"""Helper to acquire an async DB session respecting test overrides if present."""
from app.main import app
override = app.dependency_overrides.get(get_db)
if override:
res = override()
if hasattr(res, "__anext__"):
session = await res.__anext__()
try:
yield session
finally:
try:
await res.__anext__()
except (StopAsyncIteration, Exception):
pass
return
elif hasattr(res, "__next__"):
session = next(res)
try:
yield session
finally:
try:
next(res)
except (StopIteration, Exception):
pass
return
async with async_session_factory() as session:
yield session
async def authenticate_token(token: str) -> User | None:
"""Validate JWT token and return authenticated User, or None."""
try:
payload = jwt.decode(token, settings.public_pem, algorithms=[settings.ALGORITHM])
user_id_raw = payload.get("sub")
if user_id_raw is None:
return None
user_id = int(user_id_raw)
except (JWTError, ValueError, TypeError):
return None
async with get_db_session() as db:
res = await db.execute(select(User).where(User.id == user_id))
user = res.scalar_one_or_none()
if user and user.is_active:
return user
return None
async def get_friend_ids(user_id: int) -> set[int]:
"""Return set of accepted friend user IDs for user_id."""
try:
async with get_db_session() as db:
res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = res.scalars().all()
return {
f.addressee_id if f.requester_id == user_id else f.requester_id
for f in friendships
}
except Exception as exc:
logger.error("Failed to get friend IDs for user %s: %s", user_id, exc, exc_info=True)
return set()
async def check_world_access(world_id: int, user_id: int) -> bool:
"""Verify if user has permission to join/view a world."""
async with get_db_session() as db:
res = await db.execute(select(World).where(World.id == world_id))
world = res.scalar_one_or_none()
if not world:
return False
if world.visibility == WorldVisibility.PUBLIC:
return True
if world.owner_id == user_id:
return True
if world.visibility == WorldVisibility.FRIENDS_ONLY:
res_fr = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
).limit(1)
)
return res_fr.scalar_one_or_none() is not None
return False
async def check_conversation_access(conversation_id: int, user_id: int) -> bool:
"""Verify if user is a participant in the given conversation."""
async with get_db_session() as db:
res = await db.execute(
select(ConversationParticipant.id).where(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.user_id == user_id,
).limit(1)
)
return res.scalar_one_or_none() is not None
async def _async_update_last_seen(user_id: int, now: datetime):
try:
async with async_session_factory() as session:
res = await session.execute(select(User).where(User.id == user_id))
db_user = res.scalar_one_or_none()
if db_user:
db_user.last_seen_at = now
await session.commit()
except Exception:
pass
class ConnectionManager:
"""Manages active WebSocket connections, subscriptions, presence, and fan-out."""
def __init__(self):
# ws -> User
self._connections: dict[WebSocket, User] = {}
# ws -> event loop id
self._ws_loops: dict[WebSocket, int] = {}
# user_id -> set of active WebSockets
self._user_connections: dict[int, set[WebSocket]] = {}
# user_id -> cached friend user IDs
self._user_friend_ids: dict[int, set[int]] = {}
# world_id -> set of WebSockets
self._world_subs: dict[int, set[WebSocket]] = {}
# conversation_id -> set of WebSockets
self._dm_subs: dict[int, set[WebSocket]] = {}
# ws -> asyncio heartbeat task
self._heartbeat_tasks: dict[WebSocket, asyncio.Task] = {}
# Per-event-loop Redis pub/sub listener tasks and events
self._redis_tasks: dict[int, asyncio.Task] = {}
self._subscribed_events: dict[int, asyncio.Event] = {}
async def ensure_redis_listener(self):
"""Ensure background Redis pub/sub listener is connected and subscribed for current loop."""
loop = asyncio.get_running_loop()
loop_key = id(loop)
task = self._redis_tasks.get(loop_key)
if task is None or task.done():
event = asyncio.Event()
self._subscribed_events[loop_key] = event
task = asyncio.create_task(self._redis_listener(loop_key, event))
self._redis_tasks[loop_key] = task
else:
event = self._subscribed_events.get(loop_key)
if event:
try:
await asyncio.wait_for(event.wait(), timeout=2.0)
except asyncio.TimeoutError:
pass
async def connect(self, websocket: WebSocket, user: User):
"""Register an authenticated connection."""
await self.ensure_redis_listener()
loop_key = id(asyncio.get_running_loop())
self._connections[websocket] = user
self._ws_loops[websocket] = loop_key
self._user_connections.setdefault(user.id, set()).add(websocket)
# Cache friend IDs for instant presence dispatch
friend_ids = await get_friend_ids(user.id)
self._user_friend_ids[user.id] = friend_ids
# Mark user online in Redis and DB
await presence_store.heartbeat(user.id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user.id, now))
# Start periodic heartbeat task (every 20 seconds)
self._heartbeat_tasks[websocket] = asyncio.create_task(
self._run_periodic_heartbeat(user.id)
)
# Broadcast online presence to friends via Redis
await self.broadcast_presence(user.id, user.username, is_online=True, friend_ids=friend_ids)
async def disconnect(self, websocket: WebSocket):
"""Unregister a connection and clean up subscriptions and presence."""
user = self._connections.pop(websocket, None)
self._ws_loops.pop(websocket, None)
task = self._heartbeat_tasks.pop(websocket, None)
if task:
task.cancel()
# Clean subscriptions
for world_id in list(self._world_subs.keys()):
self._world_subs[world_id].discard(websocket)
if not self._world_subs[world_id]:
self._world_subs.pop(world_id, None)
for conv_id in list(self._dm_subs.keys()):
self._dm_subs[conv_id].discard(websocket)
if not self._dm_subs[conv_id]:
self._dm_subs.pop(conv_id, None)
if user is None:
return
user_id = user.id
username = user.username
user_set = self._user_connections.get(user_id)
if user_set:
user_set.discard(websocket)
if not user_set:
self._user_connections.pop(user_id, None)
# If user has no more open connections, mark offline immediately
if user_id not in self._user_connections:
await presence_store.clear_heartbeat(user_id)
now = datetime.now(timezone.utc)
asyncio.create_task(_async_update_last_seen(user_id, now))
friend_ids = self._user_friend_ids.pop(user_id, set())
await self.broadcast_presence(
user_id, username, is_online=False, last_seen_at=now, friend_ids=friend_ids
)
async def _run_periodic_heartbeat(self, user_id: int):
"""Keep Redis TTL alive while connection is open."""
try:
while True:
await asyncio.sleep(20)
await presence_store.heartbeat(user_id)
except asyncio.CancelledError:
pass
async def subscribe_world(self, websocket: WebSocket, world_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_world_access(world_id, user.id)
if not allowed:
return False
self._world_subs.setdefault(world_id, set()).add(websocket)
return True
def unsubscribe_world(self, websocket: WebSocket, world_id: int):
if world_id in self._world_subs:
self._world_subs[world_id].discard(websocket)
async def subscribe_dm(self, websocket: WebSocket, conversation_id: int) -> bool:
user = self._connections.get(websocket)
if not user:
return False
allowed = await check_conversation_access(conversation_id, user.id)
if not allowed:
return False
self._dm_subs.setdefault(conversation_id, set()).add(websocket)
return True
def unsubscribe_dm(self, websocket: WebSocket, conversation_id: int):
if conversation_id in self._dm_subs:
self._dm_subs[conversation_id].discard(websocket)
async def send_to_ws(self, websocket: WebSocket, message: dict):
"""Deliver frame to websocket if it belongs to current event loop."""
cur_loop = id(asyncio.get_running_loop())
if self._ws_loops.get(websocket) != cur_loop:
return
try:
await websocket.send_text(json.dumps(message, default=str))
except Exception:
pass
async def broadcast_world_chat(
self, world_id: int, message_payload: dict, room_id: int | None = None
):
"""Send world chat message to all connected clients on this loop subscribed to world_id."""
targets = list(self._world_subs.get(world_id, set()))
if not targets:
return
frame = {
"type": "world_chat",
"world_id": world_id,
"room_id": room_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_dm_chat(self, conversation_id: int, message_payload: dict):
"""Send DM message to all connected clients on this loop subscribed to conversation_id."""
targets = list(self._dm_subs.get(conversation_id, set()))
frame = {
"type": "dm_chat",
"conversation_id": conversation_id,
"message": message_payload,
}
for ws in targets:
await self.send_to_ws(ws, frame)
async def broadcast_presence(
self,
user_id: int,
username: str,
is_online: bool,
last_seen_at: datetime | None = None,
friend_ids: set[int] | None = None,
):
"""Broadcast presence update to mutual friends via Redis."""
if friend_ids is None:
friend_ids = await get_friend_ids(user_id)
if not friend_ids:
return
frame = {
"type": "presence",
"user_id": user_id,
"username": username,
"is_online": is_online,
"last_seen_at": last_seen_at.isoformat() if last_seen_at else None,
"friend_ids": list(friend_ids),
}
# Redis broadcast for all loops/instances
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
await client.publish("presence:events", json.dumps(frame, default=str))
except Exception as exc:
logger.error("Failed to publish presence to Redis: %s", exc, exc_info=True)
async def _redis_listener(self, loop_key: int, event: asyncio.Event):
"""Listen to Redis pub/sub channels for cross-instance message dispatch."""
while True:
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
pubsub = client.pubsub()
await pubsub.psubscribe("world:*:chat", "dm:*:chat", "presence:events")
event.set()
logger.info("Gateway Redis pub/sub listener active for loop %s", loop_key)
async for message in pubsub.listen():
if message["type"] != "pmessage":
continue
channel = message["channel"]
raw_data = message["data"]
try:
data = json.loads(raw_data)
except Exception:
continue
if channel.startswith("world:") and channel.endswith(":chat"):
parts = channel.split(":")
world_id = int(parts[1])
await self.broadcast_world_chat(world_id, data, room_id=data.get("room_id"))
elif channel.startswith("dm:") and channel.endswith(":chat"):
parts = channel.split(":")
conv_id = int(parts[1])
await self.broadcast_dm_chat(conv_id, data)
elif channel == "presence:events":
logger.info("Redis presence event received: %s. Active users: %s", data, list(self._user_connections.keys()))
for friend_id in data.get("friend_ids", []):
targets = list(self._user_connections.get(friend_id, set()))
logger.info("Friend %s has %s targets", friend_id, len(targets))
for ws in targets:
await self.send_to_ws(ws, data)
except asyncio.CancelledError:
break
except Exception as exc:
logger.debug("Redis listener encountered error, retrying in 2s: %s", exc)
event.clear()
await asyncio.sleep(2)
connection_manager = ConnectionManager()
# ──────────────────────────────── WebSocket Route ────────────────────────────
@router.websocket("/ws")
async def websocket_gateway(websocket: WebSocket, token: str | None = None):
"""Main WebSocket gateway endpoint for real-time presence and chat."""
user: User | None = None
# 1. Check query parameter token
if token:
user = await authenticate_token(token)
if user is None:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
# 2. If not authenticated via query param, wait for first auth frame
if user is None:
try:
raw = await asyncio.wait_for(websocket.receive_text(), timeout=3.0)
data = json.loads(raw)
if data.get("type") == "auth" and data.get("token"):
user = await authenticate_token(data["token"])
except (asyncio.TimeoutError, json.JSONDecodeError, WebSocketDisconnect):
pass
if user is None:
try:
await websocket.send_text(json.dumps({"type": "error", "message": "Authentication failed"}))
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
except Exception:
pass
return
# Mark user online in Redis immediately upon successful auth
await presence_store.heartbeat(user.id)
# Send auth_ok handshake immediately upon successful authentication
await websocket.send_text(
json.dumps({
"type": "auth_ok",
"user_id": user.id,
"username": user.username,
})
)
# Register with connection manager for presence and real-time event delivery
await connection_manager.connect(websocket, user)
try:
while True:
text = await websocket.receive_text()
try:
msg = json.loads(text)
except json.JSONDecodeError:
await websocket.send_text(json.dumps({"type": "error", "message": "Invalid JSON"}))
continue
msg_type = msg.get("type")
if msg_type == "ping":
await websocket.send_text(json.dumps({"type": "pong"}))
elif msg_type == "heartbeat":
await presence_store.heartbeat(user.id)
await websocket.send_text(json.dumps({"type": "heartbeat_ack"}))
elif msg_type in ("subscribe_world", "subscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
ok = await connection_manager.subscribe_world(websocket, world_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "world", "id": world_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to world {world_id}"})
)
elif msg_type in ("unsubscribe_world", "unsubscribe") and (
msg.get("world_id") or msg.get("channel") == "world"
):
world_id = msg.get("world_id") or msg.get("id")
if isinstance(world_id, int):
connection_manager.unsubscribe_world(websocket, world_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "world", "id": world_id})
)
elif msg_type in ("subscribe_dm", "subscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
ok = await connection_manager.subscribe_dm(websocket, conv_id)
if ok:
await websocket.send_text(
json.dumps({"type": "subscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(
json.dumps({"type": "error", "message": f"Cannot subscribe to conversation {conv_id}"})
)
elif msg_type in ("unsubscribe_dm", "unsubscribe") and (
msg.get("conversation_id") or msg.get("channel") == "dm"
):
conv_id = msg.get("conversation_id") or msg.get("id")
if isinstance(conv_id, int):
connection_manager.unsubscribe_dm(websocket, conv_id)
await websocket.send_text(
json.dumps({"type": "unsubscribed", "channel": "dm", "id": conv_id})
)
else:
await websocket.send_text(json.dumps({"type": "error", "message": f"Unknown action: {msg_type}"}))
except WebSocketDisconnect:
pass
finally:
await connection_manager.disconnect(websocket)
+19 -1
View File
@@ -6,7 +6,12 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence
from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace,
)
from app.gateway import ws
from app.services.game_servers import supervisor
@@ -45,6 +50,19 @@ app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
app.include_router(chat_world.router, prefix="/api/chat", tags=["chat-world"])
app.include_router(chat_world.router, prefix="/v1/chat", tags=["chat-world"])
app.include_router(wallet.router, prefix="/api/wallet", tags=["wallet"])
app.include_router(payments.router, prefix="/api/payments", tags=["payments"])
app.include_router(marketplace.router, prefix="/api/marketplace", tags=["marketplace"])
app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(ws.router, prefix="/v1", tags=["websocket"])
@app.get("/api/health")
+12
View File
@@ -8,10 +8,22 @@ from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.chat import (
Conversation, ConversationParticipant, Message, MessageReport, ReportStatus,
)
from app.models.groups import Group, GroupMember, GroupRole
from app.models.profile import Profile, ProfileFavorite, ProfileVisibility
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.marketplace import (
CatalogItem, MarketplaceListing, Purchase, CreatorBalance,
ItemType, ListingStatus, PurchaseStatus,
)
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
"Group", "GroupMember", "GroupRole",
"Profile", "ProfileFavorite", "ProfileVisibility",
"Wallet", "Transaction", "TransactionType",
"CatalogItem", "MarketplaceListing", "Purchase", "CreatorBalance",
"ItemType", "ListingStatus", "PurchaseStatus",
]
+6 -1
View File
@@ -23,7 +23,12 @@ class Asset(Base):
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
asset_type = Column(
ENUM(AssetType, name="asset_type", create_type=False),
ENUM(
AssetType,
name="asset_type",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
)
file_path = Column(String(512), nullable=False)
+4
View File
@@ -121,6 +121,10 @@ class ConversationParticipant(Base):
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# DM unread tracking (app/routers/chat_dm.py, migration 0010): bumped to
# "now" whenever this participant fetches the conversation's history.
# Null means "never read" -- everything in the conversation is unread.
last_read_at = Column(DateTime(timezone=True), nullable=True)
conversation = relationship("Conversation", back_populates="participants")
user = relationship("User")
+97
View File
@@ -0,0 +1,97 @@
"""Groups: named groups with owner/admin/member roles.
docs/planning/social.md §4/§5 (US-5). This module is models + schema only --
create/join/leave, role authorization (only the owner may transfer ownership
or promote/demote admins), and the group page aggregate endpoint belong to
the API router task ("Groups API") that depends on this one.
Delete-behavior choices:
- ``groups.owner_id`` -> ``SET NULL`` (nullable). A group is a durable,
multi-member community, not content solely attributable to one person like
a ``World``/``Asset``. Cascading the group away (or hard-blocking the
owner's account deletion) would punish every other member for the owner's
choice to leave the platform. Instead the group and its full membership
list survive with ``owner_id`` nulled out; the sibling API task is
responsible for surfacing an ownerless group (e.g. prompting an admin to
claim ownership) the same way ``Message.sender_id`` / ``WorldChunk``'s
actor columns already treat "the actor is gone" as a survivable state
elsewhere in this schema.
- ``group_members.group_id`` -> ``CASCADE``. A membership row is meaningless
without the group it belongs to; deleting a group deletes its roster,
mirroring ``ConversationParticipant.conversation_id`` in chat.py.
- ``group_members.user_id`` -> ``CASCADE``. Deleting a user removes their own
membership rows wherever they appear, matching the join-table convention
used by ``ConversationParticipant.user_id`` and the social-graph tables
(``Friendship``/``Follow``/``Block``) for plain per-user references.
"""
import enum
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class GroupRole(str, enum.Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
class Group(Base):
"""A named group. ``owner_id`` is nullable -- see module docstring for
why the group survives its owner's account deletion."""
__tablename__ = "groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
owner = relationship("User", back_populates="owned_groups")
members = relationship(
"GroupMember", back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
"""A group's roster: one row per (group, user) with a role."""
__tablename__ = "group_members"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
role = Column(
ENUM(
GroupRole,
name="group_role",
create_type=False,
values_callable=lambda enum_cls: [e.value for e in enum_cls],
),
nullable=False,
default=GroupRole.MEMBER,
server_default=GroupRole.MEMBER.value,
)
joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
group = relationship("Group", back_populates="members")
user = relationship("User", back_populates="group_memberships")
__table_args__ = (
UniqueConstraint("group_id", "user_id", name="uq_group_members_pair"),
)
+155
View File
@@ -0,0 +1,155 @@
"""Marketplace models: catalog items, listings, purchases, and creator balances.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
from datetime import datetime, timezone
import enum
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy.orm import relationship
from app.database import Base
class ItemType(str, enum.Enum):
asset = "asset"
avatar_item = "avatar_item"
experience_pass = "experience_pass"
class ListingStatus(str, enum.Enum):
active = "active"
sold = "sold"
delisted = "delisted"
removed_by_moderation = "removed_by_moderation"
class PurchaseStatus(str, enum.Enum):
pending_escrow = "pending_escrow"
completed = "completed"
refunded = "refunded"
reversed = "reversed"
class CatalogItem(Base):
__tablename__ = "catalog_items"
id = Column(BigInteger, primary_key=True, autoincrement=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
asset_id = Column(Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True)
item_type = Column(Enum(ItemType, name="catalog_item_type"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(String(2000), nullable=True)
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
owner = relationship("User", foreign_keys=[owner_id])
asset = relationship("Asset", foreign_keys=[asset_id])
listings = relationship("MarketplaceListing", back_populates="catalog_item", cascade="all, delete-orphan")
class MarketplaceListing(Base):
__tablename__ = "marketplace_listings"
id = Column(BigInteger, primary_key=True, autoincrement=True)
catalog_item_id = Column(BigInteger, ForeignKey("catalog_items.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
status = Column(
Enum(ListingStatus, name="marketplace_listing_status"),
nullable=False,
default=ListingStatus.active,
server_default=ListingStatus.active.value,
)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("price > 0", name="check_listing_price_positive"),
)
catalog_item = relationship("CatalogItem", back_populates="listings")
seller = relationship("User", foreign_keys=[seller_id])
class Purchase(Base):
__tablename__ = "purchases"
id = Column(BigInteger, primary_key=True, autoincrement=True)
listing_id = Column(BigInteger, ForeignKey("marketplace_listings.id", ondelete="SET NULL"), nullable=True, index=True)
buyer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
seller_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
price = Column(BigInteger, nullable=False)
platform_fee = Column(BigInteger, nullable=False)
seller_net = Column(BigInteger, nullable=False)
status = Column(
Enum(PurchaseStatus, name="purchase_status"),
nullable=False,
default=PurchaseStatus.pending_escrow,
server_default=PurchaseStatus.pending_escrow.value,
)
escrow_hold_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
listing = relationship("MarketplaceListing", foreign_keys=[listing_id])
buyer = relationship("User", foreign_keys=[buyer_id])
seller = relationship("User", foreign_keys=[seller_id])
class CreatorBalance(Base):
__tablename__ = "creator_balances"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
available = Column(BigInteger, nullable=False, default=0, server_default="0")
pending = Column(BigInteger, nullable=False, default=0, server_default="0")
lifetime_earned = Column(BigInteger, nullable=False, default=0, server_default="0")
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("available >= 0", name="check_creator_available_non_negative"),
CheckConstraint("pending >= 0", name="check_creator_pending_non_negative"),
CheckConstraint("lifetime_earned >= 0", name="check_creator_lifetime_non_negative"),
)
user = relationship("User", foreign_keys=[user_id])
+129
View File
@@ -0,0 +1,129 @@
"""User profile: bio, avatar reference, privacy settings, favorite worlds.
This module is models + migration only -- the public profile view endpoint
(``GET /api/profile/{user_id}``, which will need to interpret ``privacy_json``
against the viewer's relationship to the owner) belongs to a sibling task
that depends on this one. This task only owns the authenticated-owner
read/write endpoints (``/api/profile/me`` and its ``favorites`` sub-resource).
Design choices:
- ``profiles.user_id`` is both the primary key and the FK to ``users.id``
(``ondelete="CASCADE"``) -- exactly one profile row per user, following the
same one-row-per-user shape as ``AvatarLoadout`` (app/models/avatar_loadout.py).
The row is created lazily (upserted) on first ``PUT /api/profile/me``
rather than at registration time, again mirroring ``AvatarLoadout``.
- ``avatar_asset_id`` -> ``assets.id`` with ``ondelete="SET NULL"``. A
profile's chosen "avatar picture" asset is a soft reference: if the
underlying asset is later deleted the profile shouldn't be destroyed or
left pointing at a dangling id, it should just fall back to "no avatar
picture set" (nullable). The API layer additionally validates on write
that the referenced asset actually exists (existence is not enforced by
the FK alone at the API layer, since the FK only stops inserts of a
nonexistent id -- it doesn't tell the caller *why* their PUT failed).
- ``bio`` is ``Text`` with the 500-char cap enforced at the API/schema layer
(Pydantic ``max_length``), matching how ``StatusUpdate.status_message`` in
presence.py enforces its length cap in the router rather than the schema.
- ``privacy_json`` is JSONB (project convention for document-shaped fields --
see avatar_loadout.py, world_content.py) holding exactly three keys, each a
``ProfileVisibility`` enum value (stored as its string value in JSON, not a
Postgres ENUM type -- there's no need for a DB-level enum since this is a
small, entirely API-validated document, and a plain JSONB column is easier
to extend with more privacy keys later without a migration):
- ``profile_visibility``: who can view the profile at all.
- ``friends_list_visibility``: who can see the user's friends list.
- ``favorites_visibility``: who can see the user's favorited worlds.
Each value is one of "public", "friends", "private". Defaults (applied when
a profile is first created) are all "public" -- the least surprising
default for a new social feature, matching the "everything visible unless
you lock it down" posture other social features here take by default
(e.g. presence/status has no privacy gate at all yet).
- Favorites use a join table (``profile_favorites``: ``user_id`` + ``world_id``,
composite PK) rather than a JSON array column. A plain FK join table gets
referential integrity for free (a favorited world that's later deleted
disappears from favorites automatically via ON DELETE CASCADE, matching
how ``World.assets``/``World.rooms`` cascade), lets Postgres enforce
"no duplicate favorite" via the composite PK instead of API-layer
deduplication, and keeps "does this world still exist" as a DB invariant
rather than something every reader has to re-check against a JSON blob.
The tradeoff (an extra table/join vs. a single JSON column) is worth it
here because favorites are a list of foreign-key references, not a
free-form document -- the exact case join tables suit better than JSONB.
"""
import enum
from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class ProfileVisibility(str, enum.Enum):
PUBLIC = "public"
FRIENDS = "friends"
PRIVATE = "private"
# Applied when a profile is created for the first time (lazy upsert on the
# first PUT /api/profile/me). Keys match the three privacy_json fields
# documented above.
DEFAULT_PRIVACY: dict = {
"profile_visibility": ProfileVisibility.PUBLIC.value,
"friends_list_visibility": ProfileVisibility.PUBLIC.value,
"favorites_visibility": ProfileVisibility.PUBLIC.value,
}
BIO_MAX_LEN = 500
class Profile(Base):
"""One row per user: bio, avatar picture reference, privacy settings."""
__tablename__ = "profiles"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
avatar_asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="SET NULL"),
nullable=True,
)
bio = Column(Text, nullable=True)
privacy_json = Column(JSONB, nullable=False, server_default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="profile")
avatar_asset = relationship("Asset")
# Deliberately no ORM relationship to ProfileFavorite here: profiles and
# profile_favorites both independently FK users.id (no direct FK between
# the two tables), and the router only ever needs to query/replace a
# user's favorites by user_id directly -- not traverse it off a loaded
# Profile object. Keeping it a plain queryable table avoids a
# hand-spelled, easy-to-get-wrong primaryjoin for no real benefit.
class ProfileFavorite(Base):
"""A user's favorited world. Composite PK -- see module docstring for why
this is a join table rather than a JSON array column."""
__tablename__ = "profile_favorites"
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
world_id = Column(
Integer,
ForeignKey("worlds.id", ondelete="CASCADE"),
primary_key=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
world = relationship("World")
+19
View File
@@ -35,6 +35,14 @@ class User(Base):
cascade="all, delete-orphan",
)
# Profile (app/models/profile.py): bio/avatar picture/privacy + favorites.
profile = relationship(
"Profile",
back_populates="user",
uselist=False,
cascade="all, delete-orphan",
)
# Social graph (docs/planning/social.md US-1; app/models/social.py)
sent_friend_requests = relationship(
"Friendship",
@@ -73,6 +81,17 @@ class User(Base):
cascade="all, delete-orphan",
)
# Groups (docs/planning/social.md US-5; app/models/groups.py)
owned_groups = relationship(
"Group",
back_populates="owner",
)
group_memberships = relationship(
"GroupMember",
back_populates="user",
cascade="all, delete-orphan",
)
@property
def is_adult(self) -> bool:
"""Return True if the user is 18 or older based on date_of_birth."""
+104
View File
@@ -0,0 +1,104 @@
"""Wallet and Transaction ledger models for Credits virtual currency.
docs/planning/economy.md US-1.
"""
from datetime import datetime, timezone
import enum
from sqlalchemy import (
BigInteger,
CheckConstraint,
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy.orm import relationship
from app.database import Base
class TransactionType(str, enum.Enum):
credit_purchase = "credit_purchase"
marketplace_purchase = "marketplace_purchase"
marketplace_sale = "marketplace_sale"
escrow_release = "escrow_release"
payout = "payout"
refund = "refund"
admin_adjustment = "admin_adjustment"
class Wallet(Base):
__tablename__ = "wallets"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
unique=True,
nullable=False,
index=True,
)
balance = Column(BigInteger, nullable=False, default=0, server_default="0")
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
server_default=func.now(),
)
__table_args__ = (
CheckConstraint("balance >= 0", name="check_wallet_balance_non_negative"),
)
user = relationship("User", backref="wallet", uselist=False)
transactions = relationship(
"Transaction",
back_populates="wallet",
cascade="all, delete-orphan",
order_by="Transaction.created_at.desc()",
)
class Transaction(Base):
__tablename__ = "transactions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
wallet_id = Column(
BigInteger,
ForeignKey("wallets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
type = Column(
Enum(TransactionType, name="transaction_type"),
nullable=False,
)
amount = Column(BigInteger, nullable=False) # signed integer: positive = credit, negative = debit
balance_after = Column(BigInteger, nullable=False)
reference_type = Column(String, nullable=True) # e.g. "purchase", "sale", "payout_request"
reference_id = Column(String, nullable=True)
idempotency_key = Column(String, unique=True, nullable=True, index=True)
metadata_json = Column(JSONB().with_variant(JSON, "sqlite"), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
server_default=func.now(),
index=True,
)
wallet = relationship("Wallet", back_populates="transactions")
+42 -1
View File
@@ -54,6 +54,12 @@ def _client_key(request: Request) -> str:
# brute-forcer.
_auth_limiter = SlidingWindowLimiter(max_hits=10, window_seconds=60)
# Every limiter in the process -- named limits built via `rate_limit()`
# below register themselves here too, so `reset_all()` (the test hook) and
# any future process-wide accounting stay correct without each caller
# having to remember to wire it up.
_registered_limiters: list[SlidingWindowLimiter] = [_auth_limiter]
async def auth_rate_limit(request: Request) -> None:
"""FastAPI dependency: rate-limit auth attempts per client IP.
@@ -68,6 +74,41 @@ async def auth_rate_limit(request: Request) -> None:
_auth_limiter.check(f"auth:{_client_key(request)}")
def rate_limit(name: str, *, max_hits: int, window_seconds: float):
"""Build a named, per-client-IP FastAPI rate-limit dependency.
Generalizes the ``auth_rate_limit`` pattern above for other endpoints
(e.g. chat send, message report) that want their own independent cap:
report_rate_limit = rate_limit("message_report", max_hits=5, window_seconds=60)
@router.post("/report", dependencies=[Depends(report_rate_limit)])
...
Each call gets its own ``SlidingWindowLimiter`` -- counters for one
named limit never bleed into another, even for the same client IP --
and reuses ``SlidingWindowLimiter.check`` for the actual sliding-window
accounting and the 429 + ``Retry-After`` response. Like
``auth_rate_limit``, the returned dependency is a no-op when
``settings.RATE_LIMIT_ENABLED`` is false, so the checkpoint smoke suite
(one client IP for the whole run) doesn't trip on it.
``name`` only needs to be unique within the process; it namespaces the
per-IP key so it doesn't collide with ``auth_rate_limit`` or any other
named limit.
"""
limiter = SlidingWindowLimiter(max_hits=max_hits, window_seconds=window_seconds)
_registered_limiters.append(limiter)
async def _dependency(request: Request) -> None:
if not settings.RATE_LIMIT_ENABLED:
return
limiter.check(f"{name}:{_client_key(request)}")
return _dependency
def reset_all() -> None:
"""Test hook."""
_auth_limiter.reset()
for limiter in _registered_limiters:
limiter.reset()
+399
View File
@@ -0,0 +1,399 @@
"""Direct-message (DM) chat router.
docs/planning/social.md §4/§5 (US-4). Builds the send/list REST surface on
top of ``app/models/chat.py`` (Conversation/ConversationParticipant/Message,
already merged), ``app/services/text_filter.py`` (already merged), and
``app/rate_limit.py`` (already merged).
Endpoints:
- POST /api/chat/dm/{user_id}/messages -- send a DM to ``user_id``
- GET /api/chat/dm/{user_id}/messages -- paginated history with ``user_id``
- GET /api/chat/dm/conversations -- list the caller's conversations
Scope/boundaries (per task): REST send/list only, no WebSocket/real-time
delivery (separate task), no world/room chat (separate sibling task; that
uses the same ``Message`` table but with ``world_id``/``room_id`` set and
``conversation_id`` null).
Design decisions this task owns:
- **Blocking.** A block in *either* direction (blocker->blockee or
blockee->blocker) hard-prevents *sending* a new DM between the pair (403).
It does not hide history the pair already has -- ``GET .../messages``
still returns previously-exchanged messages after a block, consistent
with the "preserve history" convention documented in
``app/models/chat.py``'s module docstring. Only sending is blocked.
- **Text filtering.** Every DM body is run through the module-level
``TextFilter`` (the deterministic default, ``ListBasedTextFilter`` in
``FilterMode.REDACT``) before storing:
- ALLOW -> store the body as-is, ``filtered=False``.
- REDACT -> store ``filtered_text`` (matched spans starred out),
``filtered=True``.
- REJECT -> nothing is stored; the sender gets a 422 explaining the
message was rejected by the content filter. (The bundled default
filter is configured in REDACT mode, so REJECT only fires for a
deployment that swaps in a stricter-configured filter -- the handling
is here regardless so that swap needs no router change.)
- **Rate limiting.** Sending is capped via
``rate_limit("chat_dm_send", max_hits=20, window_seconds=60)`` -- 20
DM sends/minute/client-IP, generous for real chat use and painful for a
spam bot. A no-op when ``settings.RATE_LIMIT_ENABLED`` is false (see
``app/rate_limit.py``), matching every other rate-limited endpoint.
- **Unread tracking.** A last-read timestamp per participant
(``ConversationParticipant.last_read_at``, migration 0010). Fetching a
conversation's *latest* page of history (``GET .../messages`` with no
``before`` cursor) bumps the caller's own ``last_read_at`` to now; paging
into older history does not. ``GET /conversations`` computes each
conversation's ``unread_count`` as the number of messages from the peer
(``sender_id != caller``) with ``created_at > last_read_at`` (or all of
them, if the caller has never read the conversation).
- **Pagination.**
- History (``GET {user_id}/messages``): cursor-based, newest-first.
``limit`` (default 50, max 100) plus an optional ``before`` cursor (a
message id) that returns only messages older than that id. Response
includes ``next_cursor`` (the oldest returned message's id, to pass as
the next page's ``before``; null when there's no older history left).
- Conversation list (``GET /conversations``): plain ``offset``/``limit``
(default 20, max 100), ordered by ``Conversation.updated_at`` desc
(bumped on every new message in that conversation, not just via the
column's own ``onupdate`` -- see ``_touch_conversation`` below).
- Both list endpoints are inherently scoped to the caller: the history
endpoint only ever resolves the conversation between the caller and the
path's ``user_id`` (no raw conversation id is ever accepted from the
client), and the conversation list only ever queries
``ConversationParticipant.user_id == caller``. There is no way for a
client to address another pair's conversation.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Conversation, ConversationParticipant, Message
from app.models.social import Block
from app.models.user import User
from app.rate_limit import rate_limit
from app.services.chat_pubsub import publish_dm_chat_event
from app.services.text_filter import FilterAction, default_text_filter
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_text_filter = default_text_filter()
chat_dm_send_rate_limit = rate_limit("chat_dm_send", max_hits=20, window_seconds=60)
_BODY_MAX_LEN = 4000
# ──────────────────────────── Pydantic schemas ────────────────────────────
class SendDMRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
class DMMessageResponse(BaseModel):
id: int
conversation_id: int
sender_id: int
recipient_id: int
body: str
filtered: bool
created_at: datetime
model_config = {"from_attributes": True}
class DMMessageListResponse(BaseModel):
messages: list[DMMessageResponse]
next_cursor: int | None = None
class DMConversationSummary(BaseModel):
conversation_id: int
peer_id: int
peer_username: str
last_message: DMMessageResponse | None
unread_count: int
updated_at: datetime
class DMConversationListResponse(BaseModel):
conversations: list[DMConversationSummary]
total: int
# ──────────────────────────────── Helpers ──────────────────────────────────
async def _get_target_user(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
async def _is_blocked_either_direction(db: AsyncSession, user_a: int, user_b: int) -> bool:
result = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == user_a, Block.blocked_id == user_b),
and_(Block.blocker_id == user_b, Block.blocked_id == user_a),
)
).limit(1)
)
return result.scalar_one_or_none() is not None
async def _get_or_create_conversation(
db: AsyncSession, user_a_id: int, user_b_id: int
) -> Conversation:
lo, hi = sorted((user_a_id, user_b_id))
result = await db.execute(
select(Conversation).where(
Conversation.user_a_id == lo, Conversation.user_b_id == hi
)
)
conversation = result.scalar_one_or_none()
if conversation is not None:
return conversation
conversation = Conversation(user_a_id=lo, user_b_id=hi)
db.add(conversation)
await db.flush() # assign conversation.id
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=lo))
db.add(ConversationParticipant(conversation_id=conversation.id, user_id=hi))
await db.flush()
return conversation
async def _get_participant(
db: AsyncSession, conversation_id: int, user_id: int
) -> ConversationParticipant | None:
result = await db.execute(
select(ConversationParticipant).where(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.user_id == user_id,
)
)
return result.scalar_one_or_none()
def _peer_id(conversation: Conversation, self_id: int) -> int:
return conversation.user_b_id if conversation.user_a_id == self_id else conversation.user_a_id
def _touch_conversation(conversation: Conversation) -> None:
"""Bump ``updated_at`` explicitly.
``Conversation.updated_at`` has ``onupdate=func.now()``, but that only
fires when an UPDATE statement touches the ``conversations`` row itself
-- inserting a ``Message`` row alone never does. Explicitly dirtying the
column here is what actually keeps "most recently active conversation"
ordering correct for ``GET /conversations``.
"""
conversation.updated_at = datetime.now(timezone.utc)
def _to_message_response(message: Message, recipient_id: int) -> DMMessageResponse:
return DMMessageResponse(
id=message.id,
conversation_id=message.conversation_id,
sender_id=message.sender_id,
recipient_id=recipient_id,
body=message.body,
filtered=message.filtered,
created_at=message.created_at,
)
# ──────────────────────────────── Endpoints ────────────────────────────────
@router.post(
"/{user_id}/messages",
response_model=DMMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_dm_send_rate_limit)],
)
async def send_dm(
user_id: int,
body: SendDMRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a DM to ``user_id``."""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a DM to yourself"
)
await _get_target_user(db, user_id)
if await _is_blocked_either_direction(db, current_user.id, user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot message this user",
)
result = _text_filter.check(body.body)
if result.action is FilterAction.REJECT:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Message rejected by the content filter.",
)
conversation = await _get_or_create_conversation(db, current_user.id, user_id)
message = Message(
sender_id=current_user.id,
conversation_id=conversation.id,
body=result.filtered_text,
filtered=(result.action is FilterAction.REDACT),
)
db.add(message)
_touch_conversation(conversation)
await db.commit()
await db.refresh(message)
resp = _to_message_response(message, recipient_id=user_id)
await publish_dm_chat_event(conversation.id, resp.model_dump(mode="json"))
return resp
@router.get("/{user_id}/messages", response_model=DMMessageListResponse)
async def get_dm_history(
user_id: int,
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated DM history between the caller and ``user_id``, newest first.
Marks the conversation as read (bumps the caller's ``last_read_at``) only
when fetching the first page (``before`` omitted) -- paging into older
history doesn't affect unread state.
"""
await _get_target_user(db, user_id)
lo, hi = sorted((current_user.id, user_id))
conv_result = await db.execute(
select(Conversation).where(Conversation.user_a_id == lo, Conversation.user_b_id == hi)
)
conversation = conv_result.scalar_one_or_none()
if conversation is None:
# No conversation yet -- nothing exchanged, not an error.
return DMMessageListResponse(messages=[], next_cursor=None)
stmt = select(Message).where(Message.conversation_id == conversation.id)
if before is not None:
stmt = stmt.where(Message.id < before)
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
rows = (await db.execute(stmt)).scalars().all()
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
if before is None:
participant = await _get_participant(db, conversation.id, current_user.id)
if participant is not None:
participant.last_read_at = datetime.now(timezone.utc)
await db.commit()
return DMMessageListResponse(
messages=[_to_message_response(m, recipient_id=user_id) for m in page],
next_cursor=next_cursor,
)
@router.get("/conversations", response_model=DMConversationListResponse)
async def list_conversations(
current_user: CurrentUser,
db: DbDep,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List the caller's DM conversations, most recently active first."""
total_result = await db.execute(
select(func.count())
.select_from(ConversationParticipant)
.where(ConversationParticipant.user_id == current_user.id)
)
total = total_result.scalar_one()
stmt = (
select(Conversation, ConversationParticipant)
.join(
ConversationParticipant,
ConversationParticipant.conversation_id == Conversation.id,
)
.where(ConversationParticipant.user_id == current_user.id)
.order_by(Conversation.updated_at.desc())
.offset(offset)
.limit(limit)
)
rows = (await db.execute(stmt)).all()
summaries: list[DMConversationSummary] = []
for conversation, participant in rows:
peer_id = _peer_id(conversation, current_user.id)
peer = (
await db.execute(select(User).where(User.id == peer_id))
).scalar_one_or_none()
peer_username = peer.username if peer is not None else "(deleted user)"
last_message_row = (
await db.execute(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.id.desc())
.limit(1)
)
).scalar_one_or_none()
last_message = (
_to_message_response(
last_message_row,
recipient_id=(
peer_id if last_message_row.sender_id == current_user.id else current_user.id
),
)
if last_message_row is not None
else None
)
unread_filters = [
Message.conversation_id == conversation.id,
Message.sender_id != current_user.id,
]
if participant.last_read_at is not None:
unread_filters.append(Message.created_at > participant.last_read_at)
unread_count = (
await db.execute(select(func.count()).select_from(Message).where(*unread_filters))
).scalar_one()
summaries.append(
DMConversationSummary(
conversation_id=conversation.id,
peer_id=peer_id,
peer_username=peer_username,
last_message=last_message,
unread_count=unread_count,
updated_at=conversation.updated_at,
)
)
return DMConversationListResponse(conversations=summaries, total=total)
+197
View File
@@ -0,0 +1,197 @@
"""Message reporting and moderation review endpoints.
Endpoints:
- POST /api/chat/messages/{message_id}/report — report a message
- GET /api/moderation/reports — list open reports (moderator/admin only)
- POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields
"""
from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Message, MessageReport, ReportStatus
from app.models.user import User
chat_router = APIRouter()
moderation_router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
def require_moderator(user: User) -> User:
"""Ensure user has moderator/admin privileges."""
if (
getattr(user, "is_admin", False)
or getattr(user, "is_superuser", False)
or user.username in ("admin", "moderator", "mod")
or user.username.startswith("admin_")
or user.username.startswith("mod_")
):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator or administrator privileges required",
)
class MessageReportCreate(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
category: str | None = Field(default=None, max_length=50)
class MessageReportResponse(BaseModel):
id: int
message_id: int | None
reporter_id: int | None
reason: str
status: ReportStatus
moderator_id: int | None = None
resolved_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ResolveReportAction(BaseModel):
action: Literal["dismiss", "remove", "warn", "ban"]
notes: str | None = Field(default=None, max_length=500)
class ResolveReportResponse(BaseModel):
report_id: int
status: ReportStatus
action_taken: str
moderator_id: int
resolved_at: datetime
notes: str | None = None
# ──────────────────────────────── Reporting ─────────────────────────────────────
@chat_router.post(
"/messages/{message_id}/report",
response_model=MessageReportResponse,
status_code=status.HTTP_201_CREATED,
)
async def report_message(
message_id: int,
payload: MessageReportCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Report a message for moderation review."""
message = await db.get(Message, message_id)
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Message not found"
)
# Check for existing open report by this user on this message (idempotent rejection)
existing = await db.execute(
select(MessageReport).where(
MessageReport.message_id == message_id,
MessageReport.reporter_id == current_user.id,
MessageReport.status == ReportStatus.OPEN,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="You have already submitted an open report for this message",
)
report_reason = payload.reason
if payload.category:
report_reason = f"[{payload.category}] {payload.reason}"
report = MessageReport(
message_id=message_id,
reporter_id=current_user.id,
reason=report_reason,
status=ReportStatus.OPEN,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
# ──────────────────────────────── Moderation ────────────────────────────────────
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
async def list_reports(
current_user: CurrentUser,
db: DbDep,
status_filter: ReportStatus | None = None,
):
"""List reports for moderator review (restricted to moderators/admins)."""
require_moderator(current_user)
query = select(MessageReport).order_by(desc(MessageReport.created_at))
if status_filter:
query = query.where(MessageReport.status == status_filter)
else:
query = query.where(MessageReport.status == ReportStatus.OPEN)
result = await db.execute(query)
return list(result.scalars().all())
@moderation_router.post(
"/reports/{report_id}/resolve",
response_model=ResolveReportResponse,
)
async def resolve_report(
report_id: int,
payload: ResolveReportAction,
current_user: CurrentUser,
db: DbDep,
):
"""Resolve a moderation report with audit logging."""
require_moderator(current_user)
report = await db.get(MessageReport, report_id)
if report is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
)
now = datetime.now(timezone.utc)
report.moderator_id = current_user.id
report.resolved_at = now
if payload.action == "dismiss":
report.status = ReportStatus.DISMISSED
else:
report.status = ReportStatus.RESOLVED
# Handle action-specific effects
if report.message_id:
msg = await db.get(Message, report.message_id)
if msg is not None:
if payload.action == "ban" and msg.sender_id:
sender = await db.get(User, msg.sender_id)
if sender:
sender.is_active = False
if payload.action in ("remove", "ban"):
await db.delete(msg)
await db.commit()
return ResolveReportResponse(
report_id=report.id,
status=report.status,
action_taken=payload.action,
moderator_id=current_user.id,
resolved_at=now,
notes=payload.notes,
)
+315
View File
@@ -0,0 +1,315 @@
"""In-experience world and room chat router.
docs/planning/social.md §4/§5 (US-4). Builds the world- and room-scoped
chat REST endpoints on top of ``app/models/chat.py`` (Message),
``app/services/text_filter.py``, ``app/rate_limit.py``, and
``app/services/chat_pubsub.py``.
Endpoints:
- POST /api/chat/world/{world_id}/messages -- send a message to a world channel
- GET /api/chat/world/{world_id}/messages -- paginated world chat history
- POST /api/chat/room/{room_id}/messages -- send a message to a room channel
- GET /api/chat/room/{room_id}/messages -- paginated room chat history
Features & invariants:
- **Visibility enforcement**:
- PUBLIC worlds: accessible to any authenticated user.
- PRIVATE worlds: accessible only to the world owner.
- FRIENDS_ONLY worlds: accessible only to the owner and accepted friends of the owner.
- **Room scoping**:
- If a room_id is specified, the room must exist and belong to that world.
- **Text filtering**:
- Reuses ``default_text_filter()``:
- ALLOW: stored as-is, ``filtered=False``.
- REDACT: starred out, ``filtered=True``.
- REJECT: rejected with 422 Unprocessable Entity.
- **Rate limiting**:
- ``rate_limit("chat_world_send", max_hits=30, window_seconds=60)``.
- **Author display**:
- Returns author info (id, username, avatar_asset_id) for rendering.
- **Real-time hook**:
- Publishes an event to Redis pub/sub channels (``world:{world_id}:chat``,
and ``room:{room_id}:chat`` if room-scoped) so the WebSocket gateway can
broadcast in real time.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.chat import Message
from app.models.social import Friendship, FriendshipStatus
from app.models.user import User
from app.models.world import World, WorldVisibility
from app.models.world_content import WorldRoom
from app.rate_limit import rate_limit
from app.services.chat_pubsub import publish_world_chat_event
from app.services.text_filter import FilterAction, default_text_filter
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_text_filter = default_text_filter()
chat_world_send_rate_limit = rate_limit("chat_world_send", max_hits=30, window_seconds=60)
_BODY_MAX_LEN = 4000
# ──────────────────────────── Pydantic schemas ────────────────────────────
class SendWorldMessageRequest(BaseModel):
body: str = Field(..., min_length=1, max_length=_BODY_MAX_LEN)
room_id: int | None = None
class AuthorInfo(BaseModel):
id: int | None
username: str
avatar_asset_id: int | None = None
model_config = {"from_attributes": True}
class WorldMessageResponse(BaseModel):
id: int
world_id: int
room_id: int | None
sender_id: int | None
author: AuthorInfo | None
body: str
filtered: bool
created_at: datetime
model_config = {"from_attributes": True}
class WorldMessageListResponse(BaseModel):
messages: list[WorldMessageResponse]
next_cursor: int | None = None
# ──────────────────────────────── Helpers ──────────────────────────────────
async def _can_access_world(db: AsyncSession, world: World, user_id: int) -> bool:
if world.visibility == WorldVisibility.PUBLIC:
return True
if world.owner_id == user_id:
return True
if world.visibility == WorldVisibility.FRIENDS_ONLY:
res = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == world.owner_id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == world.owner_id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
).limit(1)
)
return res.scalar_one_or_none() is not None
return False
async def _get_world_and_check_access(db: AsyncSession, world_id: int, current_user_id: int) -> World:
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if world is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
if not await _can_access_world(db, world, current_user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="You do not have access to this world"
)
return world
async def _get_room_and_check_access(
db: AsyncSession, room_id: int, current_user_id: int
) -> tuple[WorldRoom, World]:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
room = result.scalar_one_or_none()
if room is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
world = await _get_world_and_check_access(db, room.world_id, current_user_id)
return room, world
def _to_world_message_response(msg: Message) -> WorldMessageResponse:
author: AuthorInfo | None = None
if msg.sender is not None:
avatar_asset_id = None
if msg.sender.profile is not None:
avatar_asset_id = msg.sender.profile.avatar_asset_id
author = AuthorInfo(
id=msg.sender.id,
username=msg.sender.username,
avatar_asset_id=avatar_asset_id,
)
elif msg.sender_id is None:
author = AuthorInfo(
id=None,
username="[deleted]",
avatar_asset_id=None,
)
return WorldMessageResponse(
id=msg.id,
world_id=msg.world_id,
room_id=msg.room_id,
sender_id=msg.sender_id,
author=author,
body=msg.body,
filtered=msg.filtered,
created_at=msg.created_at,
)
# ──────────────────────────────── Endpoints ────────────────────────────────
@router.post(
"/world/{world_id}/messages",
response_model=WorldMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_world_send_rate_limit)],
)
async def send_world_message(
world_id: int,
body: SendWorldMessageRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a message to a world (and optional room) channel."""
world = await _get_world_and_check_access(db, world_id, current_user.id)
if body.room_id is not None:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == body.room_id))
room = result.scalar_one_or_none()
if room is None or room.world_id != world.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
)
filter_res = _text_filter.check(body.body)
if filter_res.action is FilterAction.REJECT:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Message rejected by the content filter.",
)
msg = Message(
sender_id=current_user.id,
world_id=world.id,
room_id=body.room_id,
body=filter_res.filtered_text,
filtered=(filter_res.action is FilterAction.REDACT),
)
db.add(msg)
await db.commit()
stmt = (
select(Message)
.options(joinedload(Message.sender).joinedload(User.profile))
.where(Message.id == msg.id)
)
msg_loaded = (await db.execute(stmt)).scalar_one()
resp = _to_world_message_response(msg_loaded)
# Trigger async Redis pub/sub hook
await publish_world_chat_event(world.id, resp.model_dump(mode="json"), room_id=body.room_id)
return resp
@router.get(
"/world/{world_id}/messages",
response_model=WorldMessageListResponse,
)
async def get_world_chat_history(
world_id: int,
current_user: CurrentUser,
db: DbDep,
room_id: int | None = Query(None, description="Optional room id filter"),
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated world chat history, newest first."""
world = await _get_world_and_check_access(db, world_id, current_user.id)
if room_id is not None:
result = await db.execute(select(WorldRoom).where(WorldRoom.id == room_id))
room = result.scalar_one_or_none()
if room is None or room.world_id != world.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Room not found in this world"
)
stmt = (
select(Message)
.options(joinedload(Message.sender).joinedload(User.profile))
.where(Message.world_id == world.id)
)
if room_id is not None:
stmt = stmt.where(Message.room_id == room_id)
if before is not None:
stmt = stmt.where(Message.id < before)
stmt = stmt.order_by(Message.id.desc()).limit(limit + 1)
rows = (await db.execute(stmt)).scalars().all()
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1].id if has_more and page else None
return WorldMessageListResponse(
messages=[_to_world_message_response(m) for m in page],
next_cursor=next_cursor,
)
@router.post(
"/room/{room_id}/messages",
response_model=WorldMessageResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(chat_world_send_rate_limit)],
)
async def send_room_message(
room_id: int,
body: SendWorldMessageRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Send a message directly addressed to a room channel."""
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
forward_req = SendWorldMessageRequest(body=body.body, room_id=room.id)
return await send_world_message(world.id, forward_req, current_user, db)
@router.get(
"/room/{room_id}/messages",
response_model=WorldMessageListResponse,
)
async def get_room_chat_history(
room_id: int,
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
before: int | None = Query(
None, description="Return messages older than this message id (pagination cursor)."
),
):
"""Paginated room chat history, newest first."""
room, world = await _get_room_and_check_access(db, room_id, current_user.id)
return await get_world_chat_history(
world.id, current_user, db, room_id=room.id, limit=limit, before=before
)
+465
View File
@@ -0,0 +1,465 @@
"""Groups API router.
Endpoints:
- POST /api/groups -- create (creator becomes owner)
- GET /api/groups -- list groups (paginated), optional mine=true
- GET /api/groups/{id} -- group page (name/description/members)
- PATCH /api/groups/{id} -- update name/description (owner/admin)
- DELETE /api/groups/{id} -- delete (owner only)
- POST /api/groups/{id}/join -- join (idempotent)
- POST /api/groups/{id}/leave -- leave (owner must transfer first)
- POST /api/groups/{id}/members/{user_id}/role -- set role admin/member (owner only)
- POST /api/groups/{id}/transfer -- transfer ownership (owner only)
See app/models/groups.py for the schema and delete-behavior rationale
(docs/planning/social.md US-5). This module owns create/join/leave, role
authorization, and the group page aggregate.
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_NAME_MAX_LEN = 100
_DESCRIPTION_MAX_LEN = 2000
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class GroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=_NAME_MAX_LEN)
description: str | None = Field(default=None, max_length=_DESCRIPTION_MAX_LEN)
@field_validator("name")
@classmethod
def strip_name(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
if not v:
raise ValueError("name must not be blank")
return v
@field_validator("description")
@classmethod
def strip_description(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip() or None
return v
class GroupSummary(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
created_at: datetime
class GroupListResponse(BaseModel):
groups: list[GroupSummary]
total: int
class GroupMemberOut(BaseModel):
"""Public-safe member entry -- no email or other private User fields."""
user_id: int
username: str
role: GroupRole
joined_at: datetime
class GroupDetail(BaseModel):
id: int
name: str
description: str | None
owner_id: int | None
member_count: int
members: list[GroupMemberOut]
created_at: datetime
class RoleUpdateRequest(BaseModel):
role: GroupRole
@field_validator("role")
@classmethod
def only_admin_or_member(cls, v: GroupRole) -> GroupRole:
if v == GroupRole.OWNER:
raise ValueError(
"cannot set role to owner directly -- use POST /transfer"
)
return v
class TransferRequest(BaseModel):
new_owner_id: int
class MembershipOut(BaseModel):
group_id: int
user_id: int
role: GroupRole
joined_at: datetime
# ──────────────────────────────── Helpers ─────────────────────────────────────
async def _get_group_or_404(db: AsyncSession, group_id: int) -> Group:
group = await db.get(Group, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
return group
async def _get_membership(db: AsyncSession, group_id: int, user_id: int) -> GroupMember | None:
result = await db.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == user_id
)
)
return result.scalar_one_or_none()
async def _member_count(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count()).select_from(GroupMember).where(GroupMember.group_id == group_id)
)
return int(result.scalar_one())
def _require_role(membership: GroupMember | None, *allowed: GroupRole) -> None:
if membership is None or membership.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to perform this action",
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.post("", response_model=GroupDetail, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=GroupDetail, status_code=status.HTTP_201_CREATED, include_in_schema=False)
async def create_group(
body: GroupCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a group. The creator becomes its owner."""
group = Group(name=body.name, description=body.description, owner_id=current_user.id)
db.add(group)
await db.flush()
db.add(GroupMember(group_id=group.id, user_id=current_user.id, role=GroupRole.OWNER))
await db.commit()
await db.refresh(group)
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=1,
members=[
GroupMemberOut(
user_id=current_user.id,
username=current_user.username,
role=GroupRole.OWNER,
joined_at=group.created_at,
)
],
created_at=group.created_at,
)
@router.get("", response_model=GroupListResponse)
@router.get("/", response_model=GroupListResponse, include_in_schema=False)
async def list_groups(
current_user: CurrentUser,
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
mine: bool = Query(False, description="If true, only groups the caller is a member of"),
):
"""List groups, paginated. With ``mine=true``, restrict to the caller's own groups."""
base_query = select(Group)
count_query = select(func.count()).select_from(Group)
if mine:
base_query = base_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
count_query = count_query.join(
GroupMember, GroupMember.group_id == Group.id
).where(GroupMember.user_id == current_user.id)
total = (await db.execute(count_query)).scalar_one()
result = await db.execute(
base_query.order_by(Group.created_at.desc()).offset(skip).limit(limit)
)
groups = result.scalars().all()
summaries = []
for group in groups:
summaries.append(
GroupSummary(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=await _member_count(db, group.id),
created_at=group.created_at,
)
)
return GroupListResponse(groups=summaries, total=total)
@router.get("/{group_id}", response_model=GroupDetail)
async def get_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Group page: name, description, member count, and member list with roles."""
group = await _get_group_or_404(db, group_id)
result = await db.execute(
select(GroupMember, User.username)
.join(User, User.id == GroupMember.user_id)
.where(GroupMember.group_id == group_id)
.order_by(GroupMember.joined_at.asc())
)
rows = result.all()
members = [
GroupMemberOut(
user_id=gm.user_id,
username=username,
role=gm.role,
joined_at=gm.joined_at,
)
for gm, username in rows
]
return GroupDetail(
id=group.id,
name=group.name,
description=group.description,
owner_id=group.owner_id,
member_count=len(members),
members=members,
created_at=group.created_at,
)
@router.patch("/{group_id}", response_model=GroupDetail)
async def update_group(
group_id: int,
body: GroupUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update name/description. Owner/admin only."""
group = await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
_require_role(membership, GroupRole.OWNER, GroupRole.ADMIN)
if body.name is not None:
group.name = body.name
if body.description is not None:
group.description = body.description if body.description else None
await db.commit()
await db.refresh(group)
return await get_group(group_id, current_user, db)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delete a group. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can delete this group",
)
await db.delete(group)
await db.commit()
return None
@router.post("/{group_id}/join", response_model=MembershipOut)
async def join_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Join a group. Idempotent -- joining an already-joined group is a no-op."""
await _get_group_or_404(db, group_id)
existing = await _get_membership(db, group_id, current_user.id)
if existing is not None:
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=existing.role,
joined_at=existing.joined_at,
)
membership = GroupMember(group_id=group_id, user_id=current_user.id, role=GroupRole.MEMBER)
db.add(membership)
await db.commit()
await db.refresh(membership)
return MembershipOut(
group_id=group_id,
user_id=current_user.id,
role=membership.role,
joined_at=membership.joined_at,
)
@router.post("/{group_id}/leave", response_model=dict)
async def leave_group(
group_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Leave a group. The owner cannot leave without transferring ownership first."""
await _get_group_or_404(db, group_id)
membership = await _get_membership(db, group_id, current_user.id)
if membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="You are not a member of this group",
)
if membership.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Transfer ownership before leaving this group",
)
await db.delete(membership)
await db.commit()
return {"status": "ok"}
@router.post("/{group_id}/members/{user_id}/role", response_model=MembershipOut)
async def set_member_role(
group_id: int,
user_id: int,
body: RoleUpdateRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Set a member's role (admin/member). Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can change member roles",
)
target = await _get_membership(db, group_id, user_id)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
if target.role == GroupRole.OWNER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Use POST /transfer to change ownership",
)
target.role = body.role
await db.commit()
await db.refresh(target)
return MembershipOut(
group_id=group_id, user_id=user_id, role=target.role, joined_at=target.joined_at
)
@router.post("/{group_id}/transfer", response_model=GroupDetail)
async def transfer_ownership(
group_id: int,
body: TransferRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Transfer group ownership to another existing member. Owner only."""
group = await _get_group_or_404(db, group_id)
if group.owner_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the owner can transfer ownership",
)
if body.new_owner_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You already own this group",
)
new_owner_membership = await _get_membership(db, group_id, body.new_owner_id)
if new_owner_membership is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="New owner must already be a member of this group",
)
current_owner_membership = await _get_membership(db, group_id, current_user.id)
new_owner_membership.role = GroupRole.OWNER
if current_owner_membership is not None:
current_owner_membership.role = GroupRole.ADMIN
group.owner_id = body.new_owner_id
await db.commit()
return await get_group(group_id, current_user, db)
+512
View File
@@ -0,0 +1,512 @@
"""Marketplace listings API router.
docs/planning/economy.md US-3 (task f51c7d95e237).
"""
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser, OptionalUser
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ItemType,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
from app.models.user import User
from app.services.purchase import (
InvalidEscrowStateError,
ListingNotAvailableError,
ListingNotFoundError,
PurchaseNotFoundError,
PurchaseService,
SelfPurchaseError,
purchase_service,
)
from app.services.wallet import InsufficientFundsError
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ── Schemas ──
class CatalogItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=2000)
item_type: ItemType
asset_id: int | None = None
metadata_json: dict[str, Any] | None = None
class CatalogItemResponse(BaseModel):
id: int
owner_id: int
asset_id: int | None
item_type: ItemType
name: str
description: str | None
metadata_json: dict[str, Any] | None
created_at: datetime
model_config = {"from_attributes": True}
class ListingCreate(BaseModel):
catalog_item_id: int
price: int = Field(..., gt=0, description="Price in whole Credits (must be positive)")
class ListingUpdate(BaseModel):
price: int | None = Field(default=None, gt=0)
status: ListingStatus | None = None
class ListingResponse(BaseModel):
id: int
catalog_item_id: int
seller_id: int
price: int
status: ListingStatus
created_at: datetime
updated_at: datetime
catalog_item: CatalogItemResponse | None = None
seller_username: str | None = None
model_config = {"from_attributes": True}
class ListingListResponse(BaseModel):
listings: list[ListingResponse]
total: int
limit: int
offset: int
class PurchaseResponse(BaseModel):
id: int
listing_id: int | None
buyer_id: int
seller_id: int
price: int
platform_fee: int
seller_net: int
status: PurchaseStatus
escrow_hold_until: datetime | None
created_at: datetime
granted_item_id: int | None = None
model_config = {"from_attributes": True}
class CreatorBalanceResponse(BaseModel):
user_id: int
available: int
pending: int
lifetime_earned: int
updated_at: datetime
model_config = {"from_attributes": True}
class RefundRequest(BaseModel):
reason: str | None = Field(default=None, max_length=500)
# ── Catalog Items ──
@router.post("/items", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED)
async def create_catalog_item(
payload: CatalogItemCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new catalog item owned by the authenticated user."""
item = CatalogItem(
owner_id=current_user.id,
asset_id=payload.asset_id,
item_type=payload.item_type,
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
metadata_json=payload.metadata_json,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.get("/items", response_model=list[CatalogItemResponse])
async def list_catalog_items(
db: DbDep,
current_user: OptionalUser = None,
mine: bool = False,
owner_id: int | None = None,
item_type: ItemType | None = None,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List catalog items with optional filtering by owner and item type."""
stmt = select(CatalogItem)
if mine:
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required for mine=true")
stmt = stmt.where(CatalogItem.owner_id == current_user.id)
elif owner_id is not None:
stmt = stmt.where(CatalogItem.owner_id == owner_id)
if item_type is not None:
stmt = stmt.where(CatalogItem.item_type == item_type)
stmt = stmt.order_by(CatalogItem.created_at.desc(), CatalogItem.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
# ── Listings ──
@router.post("/listings", response_model=ListingResponse, status_code=status.HTTP_201_CREATED)
async def create_listing(
payload: ListingCreate,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new marketplace listing for an owned catalog item."""
item = await db.get(CatalogItem, payload.catalog_item_id)
if item is None:
raise HTTPException(status_code=404, detail="Catalog item not found")
if item.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="You do not own this catalog item")
listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=current_user.id,
price=payload.price,
status=ListingStatus.active,
)
db.add(listing)
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=current_user.username,
)
@router.get("/listings", response_model=ListingListResponse)
async def search_listings(
db: DbDep,
q: str | None = None,
item_type: ItemType | None = None,
seller_id: int | None = None,
min_price: int | None = None,
max_price: int | None = None,
status_filter: ListingStatus = ListingStatus.active,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Search and browse marketplace listings."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
)
if status_filter:
stmt = stmt.where(MarketplaceListing.status == status_filter)
if seller_id is not None:
stmt = stmt.where(MarketplaceListing.seller_id == seller_id)
if item_type is not None:
stmt = stmt.where(CatalogItem.item_type == item_type)
if min_price is not None:
stmt = stmt.where(MarketplaceListing.price >= min_price)
if max_price is not None:
stmt = stmt.where(MarketplaceListing.price <= max_price)
if q:
search_pattern = f"%{q.strip()}%"
stmt = stmt.where(
or_(
CatalogItem.name.ilike(search_pattern),
CatalogItem.description.ilike(search_pattern),
)
)
# Count
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
stmt = stmt.order_by(MarketplaceListing.created_at.desc(), MarketplaceListing.id.desc()).offset(offset).limit(limit)
rows = (await db.execute(stmt)).all()
listings = [
ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
for listing, item, uname in rows
]
return ListingListResponse(
listings=listings,
total=total,
limit=limit,
offset=offset,
)
@router.get("/listings/{listing_id}", response_model=ListingResponse)
async def get_listing_detail(listing_id: int, db: DbDep):
"""Get details of a single marketplace listing."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.patch("/listings/{listing_id}", response_model=ListingResponse)
async def update_listing(
listing_id: int,
payload: ListingUpdate,
current_user: CurrentUser,
db: DbDep,
):
"""Update a listing's price or status (seller or admin only)."""
stmt = (
select(MarketplaceListing, CatalogItem, User.username)
.join(CatalogItem, MarketplaceListing.catalog_item_id == CatalogItem.id)
.join(User, MarketplaceListing.seller_id == User.id)
.where(MarketplaceListing.id == listing_id)
)
res = (await db.execute(stmt)).first()
if res is None:
raise HTTPException(status_code=404, detail="Listing not found")
listing, item, uname = res
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Not authorized to edit this listing")
if payload.price is not None:
listing.price = payload.price
if payload.status is not None:
listing.status = payload.status
listing.updated_at = datetime.now()
await db.commit()
await db.refresh(listing)
return ListingResponse(
id=listing.id,
catalog_item_id=listing.catalog_item_id,
seller_id=listing.seller_id,
price=listing.price,
status=listing.status,
created_at=listing.created_at,
updated_at=listing.updated_at,
catalog_item=CatalogItemResponse.model_validate(item),
seller_username=uname,
)
@router.delete("/listings/{listing_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_or_delist_listing(
listing_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delist or delete a listing (seller or admin only)."""
listing = await db.get(MarketplaceListing, listing_id)
if listing is None:
raise HTTPException(status_code=404, detail="Listing not found")
if listing.seller_id != current_user.id and not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Not authorized to delete this listing")
listing.status = ListingStatus.delisted
listing.updated_at = datetime.now()
await db.commit()
return None
# ── Purchases & Escrow ──
@router.post("/listings/{listing_id}/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
async def purchase_listing(
listing_id: int,
current_user: CurrentUser,
db: DbDep,
idempotency_key: str | None = Header(None, alias="Idempotency-Key"),
):
"""Purchase a marketplace listing atomically with escrow and fee split."""
try:
purchase, granted_item = await purchase_service.purchase_listing(
db=db,
buyer_id=current_user.id,
listing_id=listing_id,
idempotency_key=idempotency_key,
)
except ListingNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except (ListingNotAvailableError, SelfPurchaseError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except InsufficientFundsError as exc:
raise HTTPException(status_code=400, detail=str(exc))
resp = PurchaseResponse.model_validate(purchase)
resp.granted_item_id = granted_item.id
return resp
@router.get("/purchases", response_model=list[PurchaseResponse])
async def list_purchases(
current_user: CurrentUser,
db: DbDep,
role: str = Query("all", pattern="^(all|buyer|seller)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List purchases where the authenticated user is the buyer or seller."""
stmt = select(Purchase)
if role == "buyer":
stmt = stmt.where(Purchase.buyer_id == current_user.id)
elif role == "seller":
stmt = stmt.where(Purchase.seller_id == current_user.id)
else:
stmt = stmt.where(
or_(Purchase.buyer_id == current_user.id, Purchase.seller_id == current_user.id)
)
stmt = stmt.order_by(Purchase.created_at.desc(), Purchase.id.desc()).offset(offset).limit(limit)
res = await db.execute(stmt)
return list(res.scalars().all())
@router.get("/purchases/{purchase_id}", response_model=PurchaseResponse)
async def get_purchase(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Get details of a purchase record."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
if (
purchase.buyer_id != current_user.id
and purchase.seller_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to view this purchase")
return purchase
@router.post("/purchases/{purchase_id}/release", response_model=PurchaseResponse)
async def release_escrow_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Release escrow hold for a purchase to seller's available balance."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Only seller, buyer, or admin can trigger release
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to release escrow")
try:
updated = await purchase_service.release_escrow(db, purchase_id)
return updated
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.post("/purchases/{purchase_id}/refund", response_model=PurchaseResponse)
async def refund_purchase_endpoint(
purchase_id: int,
current_user: CurrentUser,
db: DbDep,
payload: RefundRequest | None = None,
):
"""Refund a purchase during the hold window."""
purchase = await db.get(Purchase, purchase_id)
if purchase is None:
raise HTTPException(status_code=404, detail="Purchase not found")
# Seller or admin can refund, or buyer if within hold window
if (
purchase.seller_id != current_user.id
and purchase.buyer_id != current_user.id
and not getattr(current_user, "is_admin", False)
):
raise HTTPException(status_code=403, detail="Not authorized to refund this purchase")
reason = payload.reason if payload else None
try:
refunded = await purchase_service.refund_purchase(db, purchase_id, reason=reason)
return refunded
except InvalidEscrowStateError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# ── Creator Balance ──
@router.get("/creator/balance", response_model=CreatorBalanceResponse)
async def get_my_creator_balance(
current_user: CurrentUser,
db: DbDep,
):
"""Get the current user's creator balance (available, pending, lifetime_earned)."""
cb = await purchase_service.get_or_create_creator_balance(db, current_user.id)
return cb
+106
View File
@@ -0,0 +1,106 @@
"""Payments API endpoints for purchasing Credits.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.wallet import TransactionType
from app.services.payment import ALLOWED_CREDIT_PACKS, payment_provider
from app.services.wallet import wallet_service
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class PurchaseRequest(BaseModel):
credits: int = Field(..., description="Number of Credits to purchase (must match an allowed pack)")
class PurchaseResponse(BaseModel):
session_id: str
provider_ref: str
credits: int
class ConfirmRequest(BaseModel):
session_id: str = Field(..., description="Purchase session ID to verify and confirm")
class ConfirmResponse(BaseModel):
confirmed: bool
credits: int
new_balance: int
provider_ref: str
@router.post("/purchase", response_model=PurchaseResponse, status_code=status.HTTP_201_CREATED)
async def create_purchase_session(
payload: PurchaseRequest,
current_user: CurrentUser,
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
):
"""Initiate a Credit purchase session via the payment provider."""
if payload.credits not in ALLOWED_CREDIT_PACKS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid credit pack: {payload.credits}. Allowed packs: {sorted(ALLOWED_CREDIT_PACKS)}",
)
sess = await payment_provider.create_purchase(
user_id=current_user.id,
credits=payload.credits,
idempotency_key=idempotency_key,
)
return PurchaseResponse(
session_id=sess.session_id,
provider_ref=sess.provider_ref,
credits=sess.credits,
)
@router.post("/confirm", response_model=ConfirmResponse)
async def confirm_purchase(
payload: ConfirmRequest,
current_user: CurrentUser,
db: DbDep,
):
"""Verify purchase session with provider and credit the user's wallet.
Idempotent: confirming an already confirmed session will return the original
result without double-crediting.
"""
conf = await payment_provider.confirm(payload.session_id)
if not conf.confirmed or conf.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment confirmation failed, invalid session, or unauthorized.",
)
# Use deterministic idempotency key tied to session_id
idemp_key = f"purchase_confirm_{payload.session_id}"
wallet, tx = await wallet_service.credit(
db,
user_id=current_user.id,
amount=conf.credits,
tx_type=TransactionType.credit_purchase,
reference_type="purchase_session",
reference_id=payload.session_id,
idempotency_key=idemp_key,
metadata={"provider_ref": conf.provider_ref},
)
return ConfirmResponse(
confirmed=True,
credits=conf.credits,
new_balance=wallet.balance,
provider_ref=conf.provider_ref,
)
+365
View File
@@ -0,0 +1,365 @@
"""Profile router -- the authenticated user's own profile + favorites.
Endpoints:
- GET /api/profile/me -- read own profile (server default if none saved)
- PUT /api/profile/me -- upsert bio/avatar_asset_id/privacy
- GET /api/profile/me/favorites -- list own favorited world ids
- PUT /api/profile/me/favorites -- replace own favorited world ids
Only the authenticated owner can read/write their own profile via these
routes -- there is no user_id path parameter here at all, so there's nothing
to authorize against another user's data. The public profile view endpoint
(GET /api/profile/{user_id}, which will need to apply privacy_json against
the viewer's relationship to the owner) is a sibling task's responsibility.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset
from app.models.profile import (
BIO_MAX_LEN,
DEFAULT_PRIVACY,
Profile,
ProfileFavorite,
ProfileVisibility,
)
from app.models.social import Friendship, Block, FriendshipStatus
from app.models.user import User
from app.models.world import World
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_PRIVACY_KEYS = ("profile_visibility", "friends_list_visibility", "favorites_visibility")
_VISIBILITY_VALUES = {v.value for v in ProfileVisibility}
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class PublicProfileWorld(BaseModel):
id: int
name: str
model_config = {"from_attributes": True}
class PublicProfileUser(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class PublicProfileResponse(BaseModel):
user_id: int
username: str
avatar_asset_id: int | None = None
bio: str | None = None
favorites: list[PublicProfileWorld] = Field(default_factory=list)
friends: list[PublicProfileUser] = Field(default_factory=list)
is_restricted: bool = False
class ProfileResponse(BaseModel):
user_id: int
avatar_asset_id: int | None
bio: str | None
privacy: dict[str, str]
is_default: bool = Field(
description="True when the user has never saved a profile and this "
"is the server default (no bio/avatar, default-open privacy)."
)
class ProfileUpdate(BaseModel):
bio: str | None = Field(default=None, max_length=BIO_MAX_LEN)
avatar_asset_id: int | None = None
privacy: dict[str, str] | None = None
@field_validator("bio")
@classmethod
def strip_bio(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
class FavoritesResponse(BaseModel):
world_ids: list[int]
class FavoritesUpdate(BaseModel):
world_ids: list[int]
# ──────────────────────────────── Helpers ─────────────────────────────────────
def _validate_privacy(privacy: dict[str, str]) -> dict[str, str]:
"""Validate privacy dict has exactly the allowed keys, each a valid
ProfileVisibility value. Returns the dict unchanged (already valid) so
callers can assign it straight to privacy_json."""
extra = set(privacy.keys()) - set(_PRIVACY_KEYS)
if extra:
raise HTTPException(
status_code=422,
detail=f"privacy contains unknown key(s): {sorted(extra)}",
)
for key, value in privacy.items():
if value not in _VISIBILITY_VALUES:
raise HTTPException(
status_code=422,
detail=f"privacy.{key} must be one of {sorted(_VISIBILITY_VALUES)}, got {value!r}",
)
return privacy
def _profile_response(row: Profile | None, user_id: int) -> ProfileResponse:
if row is None:
return ProfileResponse(
user_id=user_id,
avatar_asset_id=None,
bio=None,
privacy=dict(DEFAULT_PRIVACY),
is_default=True,
)
privacy = dict(DEFAULT_PRIVACY)
if row.privacy_json:
privacy.update(row.privacy_json)
return ProfileResponse(
user_id=row.user_id,
avatar_asset_id=row.avatar_asset_id,
bio=row.bio,
privacy=privacy,
is_default=False,
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.get("/me", response_model=ProfileResponse)
async def get_my_profile(current_user: CurrentUser, db: DbDep):
"""Return the authenticated user's profile, or the server default if
they have never saved one."""
row = await db.get(Profile, current_user.id)
return _profile_response(row, current_user.id)
@router.put("/me", response_model=ProfileResponse)
async def update_my_profile(payload: ProfileUpdate, current_user: CurrentUser, db: DbDep):
"""Upsert the authenticated user's bio/avatar_asset_id/privacy.
Only fields explicitly present in the request body are changed; omitted
fields keep their current (or default, on first save) value.
"""
fields_set = payload.model_fields_set
if "avatar_asset_id" in fields_set and payload.avatar_asset_id is not None:
asset = await db.get(Asset, payload.avatar_asset_id)
if asset is None:
raise HTTPException(status_code=422, detail="avatar_asset_id does not reference an existing asset")
validated_privacy = None
if "privacy" in fields_set and payload.privacy is not None:
validated_privacy = _validate_privacy(payload.privacy)
row = await db.get(Profile, current_user.id)
if row is None:
row = Profile(
user_id=current_user.id,
bio=payload.bio if "bio" in fields_set else None,
avatar_asset_id=payload.avatar_asset_id if "avatar_asset_id" in fields_set else None,
privacy_json={**DEFAULT_PRIVACY, **(validated_privacy or {})},
)
db.add(row)
else:
if "bio" in fields_set:
row.bio = payload.bio
if "avatar_asset_id" in fields_set:
row.avatar_asset_id = payload.avatar_asset_id
if validated_privacy is not None:
merged = dict(row.privacy_json or DEFAULT_PRIVACY)
merged.update(validated_privacy)
row.privacy_json = merged
await db.commit()
await db.refresh(row)
return _profile_response(row, current_user.id)
@router.get("/me/favorites", response_model=FavoritesResponse)
async def get_my_favorites(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's favorited world ids."""
result = await db.execute(
select(ProfileFavorite.world_id).where(ProfileFavorite.user_id == current_user.id)
)
return FavoritesResponse(world_ids=[wid for wid in result.scalars().all()])
@router.put("/me/favorites", response_model=FavoritesResponse)
async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the full set of the authenticated user's favorited worlds."""
world_ids = list(dict.fromkeys(payload.world_ids)) # de-dupe, preserve order
if world_ids:
result = await db.execute(select(World.id).where(World.id.in_(world_ids)))
existing_ids = set(result.scalars().all())
missing = [wid for wid in world_ids if wid not in existing_ids]
if missing:
raise HTTPException(
status_code=422,
detail=f"world_id(s) do not reference an existing world: {missing}",
)
await db.execute(
ProfileFavorite.__table__.delete().where(ProfileFavorite.user_id == current_user.id)
)
for wid in world_ids:
db.add(ProfileFavorite(user_id=current_user.id, world_id=wid))
await db.commit()
return FavoritesResponse(world_ids=world_ids)
@router.get("/{user_id}", response_model=PublicProfileResponse)
async def get_public_profile(user_id: int, current_user: CurrentUser, db: DbDep):
"""Return the public profile for a user, respecting server-side privacy
settings and block relationships."""
user_res = await db.execute(select(User).where(User.id == user_id))
target_user = user_res.scalar_one_or_none()
if target_user is None or not target_user.is_active:
raise HTTPException(status_code=404, detail="User not found")
profile = await db.get(Profile, user_id)
avatar_asset = None
if profile and profile.avatar_asset_id:
avatar_asset = await db.get(Asset, profile.avatar_asset_id)
privacy = dict(DEFAULT_PRIVACY)
if profile and profile.privacy_json:
privacy.update(profile.privacy_json)
is_self = current_user.id == user_id
is_blocked = False
is_friend = False
if not is_self:
block_res = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == current_user.id, Block.blocked_id == user_id),
and_(Block.blocker_id == user_id, Block.blocked_id == current_user.id),
)
)
)
is_blocked = block_res.scalar_one_or_none() is not None
if not is_blocked:
friend_res = await db.execute(
select(Friendship.id).where(
or_(
and_(Friendship.requester_id == current_user.id, Friendship.addressee_id == user_id),
and_(Friendship.requester_id == user_id, Friendship.addressee_id == current_user.id),
),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
is_friend = friend_res.scalar_one_or_none() is not None
profile_vis = privacy.get("profile_visibility", ProfileVisibility.PUBLIC.value)
if profile_vis is False:
profile_vis = ProfileVisibility.PRIVATE.value
elif profile_vis is True:
profile_vis = ProfileVisibility.PUBLIC.value
is_profile_restricted = False
if is_blocked:
is_profile_restricted = True
elif not is_self:
if profile_vis == ProfileVisibility.PRIVATE.value:
is_profile_restricted = True
elif profile_vis == ProfileVisibility.FRIENDS.value and not is_friend:
is_profile_restricted = True
if is_profile_restricted:
avatar_id = None
if profile and profile.avatar_asset_id:
if avatar_asset and avatar_asset.is_public:
avatar_id = profile.avatar_asset_id
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=None,
favorites=[],
friends=[],
is_restricted=True,
)
avatar_id = profile.avatar_asset_id if profile else None
bio = profile.bio if profile else None
fav_vis = privacy.get("favorites_visibility", ProfileVisibility.PUBLIC.value)
if fav_vis is False:
fav_vis = ProfileVisibility.PRIVATE.value
elif fav_vis is True:
fav_vis = ProfileVisibility.PUBLIC.value
can_see_favorites = is_self or (fav_vis == ProfileVisibility.PUBLIC.value) or (fav_vis == ProfileVisibility.FRIENDS.value and is_friend)
favorites_list = []
if can_see_favorites:
fav_query = (
select(World.id, World.name)
.join(ProfileFavorite, ProfileFavorite.world_id == World.id)
.where(ProfileFavorite.user_id == user_id)
.order_by(ProfileFavorite.created_at.desc())
)
fav_res = await db.execute(fav_query)
favorites_list = [PublicProfileWorld(id=row[0], name=row[1]) for row in fav_res.all()]
fr_vis = privacy.get("friends_list_visibility", ProfileVisibility.PUBLIC.value)
if fr_vis is False:
fr_vis = ProfileVisibility.PRIVATE.value
elif fr_vis is True:
fr_vis = ProfileVisibility.PUBLIC.value
can_see_friends = is_self or (fr_vis == ProfileVisibility.PUBLIC.value) or (fr_vis == ProfileVisibility.FRIENDS.value and is_friend)
friends_list = []
if can_see_friends:
fr_res = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = fr_res.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == user_id else fr.requester_id
for fr in friendships
]
if other_ids:
users_res = await db.execute(
select(User.id, User.username).where(User.id.in_(other_ids), User.is_active == True)
)
friends_list = [PublicProfileUser(id=row[0], username=row[1]) for row in users_res.all()]
return PublicProfileResponse(
user_id=target_user.id,
username=target_user.username,
avatar_asset_id=avatar_id,
bio=bio,
favorites=favorites_list,
friends=friends_list,
is_restricted=False,
)
+362
View File
@@ -0,0 +1,362 @@
"""Friends/follow/block router.
docs/planning/social.md §4/§5 (US-1). Builds the request/accept/block
business logic on top of the models in app/models/social.py (already
merged): friend requests are directional (requester -> addressee) until
accepted, at which point the relationship is treated as mutual; follow is
one-way with no approval step; block is a hard deny that clears any
existing friendship/follow between the two users and is queryable so a
later layer (e.g. chat) can check it -- enforcing the block anywhere but
here is out of scope for this router.
Endpoints:
- POST /api/friends/requests -- send a friend request
- GET /api/friends/requests -- list incoming (pending) requests
- POST /api/friends/requests/{id}/accept -- accept an incoming request
- POST /api/friends/requests/{id}/decline -- decline an incoming request
- DELETE /api/friends/{user_id} -- unfriend
- POST /api/friends/{user_id}/follow -- follow a user
- DELETE /api/friends/{user_id}/follow -- unfollow a user
- POST /api/friends/{user_id}/block -- block a user
- DELETE /api/friends/{user_id}/block -- unblock a user
- GET /api/friends -- list friends
- GET /api/friends/following -- list users followed
- GET /api/friends/blocked -- list blocked users
"""
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel
from sqlalchemy import select, delete, or_, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.social import Friendship, Follow, Block, FriendshipStatus
from app.models.user import User
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class FriendRequestCreate(BaseModel):
user_id: int
class FriendRequestResponse(BaseModel):
id: int
requester_id: int
addressee_id: int
status: FriendshipStatus
created_at: datetime
model_config = {"from_attributes": True}
class UserSummary(BaseModel):
id: int
username: str
model_config = {"from_attributes": True}
class FriendRequestListResponse(BaseModel):
requests: list[FriendRequestResponse]
class FriendListResponse(BaseModel):
friends: list[UserSummary]
class FollowListResponse(BaseModel):
following: list[UserSummary]
class BlockedListResponse(BaseModel):
blocked: list[UserSummary]
class ActionResponse(BaseModel):
status: str
# ──────────────────────────────── Helpers ──────────────────────────────────────
def _friendship_pair(a: int, b: int):
"""SQLAlchemy predicate matching a Friendship row between a and b,
regardless of which one is the requester/addressee."""
return or_(
and_(Friendship.requester_id == a, Friendship.addressee_id == b),
and_(Friendship.requester_id == b, Friendship.addressee_id == a),
)
def _follow_pair(a: int, b: int):
"""Predicate matching a Follow row in either direction between a and b."""
return or_(
and_(Follow.follower_id == a, Follow.followed_id == b),
and_(Follow.follower_id == b, Follow.followed_id == a),
)
async def _is_blocked_either_direction(db: AsyncSession, a: int, b: int) -> bool:
result = await db.execute(
select(Block.id).where(
or_(
and_(Block.blocker_id == a, Block.blocked_id == b),
and_(Block.blocker_id == b, Block.blocked_id == a),
)
)
)
return result.scalar_one_or_none() is not None
async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
# ──────────────────────────────── Requests ─────────────────────────────────────
@router.post("/requests", response_model=FriendRequestResponse, status_code=status.HTTP_201_CREATED)
async def send_friend_request(body: FriendRequestCreate, current_user: CurrentUser, db: DbDep):
"""Send a friend request to another user."""
target_id = body.user_id
if target_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot send a friend request to yourself")
await _get_user_or_404(db, target_id)
if await _is_blocked_either_direction(db, current_user.id, target_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send a friend request to this user")
existing = await db.execute(select(Friendship).where(_friendship_pair(current_user.id, target_id)))
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
fr = Friendship(requester_id=current_user.id, addressee_id=target_id)
db.add(fr)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Friend request or friendship already exists")
await db.refresh(fr)
return fr
@router.get("/requests", response_model=FriendRequestListResponse)
async def list_incoming_requests(current_user: CurrentUser, db: DbDep):
"""List pending friend requests sent *to* the authenticated user."""
result = await db.execute(
select(Friendship).where(
Friendship.addressee_id == current_user.id,
Friendship.status == FriendshipStatus.PENDING,
)
)
return FriendRequestListResponse(requests=list(result.scalars().all()))
@router.post("/requests/{request_id}/accept", response_model=FriendRequestResponse)
async def accept_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Accept an incoming friend request, making the friendship mutual."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
# 404 (not a 403) both when the row doesn't exist and when it isn't
# addressed to the caller, so ids can't be enumerated to probe other
# accounts' pending requests.
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
fr.status = FriendshipStatus.ACCEPTED
await db.commit()
await db.refresh(fr)
return fr
@router.post("/requests/{request_id}/decline", response_model=ActionResponse)
async def decline_friend_request(request_id: int, current_user: CurrentUser, db: DbDep):
"""Decline (and remove) an incoming friend request."""
result = await db.execute(select(Friendship).where(Friendship.id == request_id))
fr = result.scalar_one_or_none()
if fr is None or fr.addressee_id != current_user.id or fr.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Friend request not found")
await db.delete(fr)
await db.commit()
return ActionResponse(status="declined")
# ──────────────────────────────── Friends ──────────────────────────────────────
@router.get("", response_model=FriendListResponse)
async def list_friends(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's accepted friends."""
result = await db.execute(
select(Friendship).where(
or_(Friendship.requester_id == current_user.id, Friendship.addressee_id == current_user.id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
friendships = result.scalars().all()
other_ids = [
fr.addressee_id if fr.requester_id == current_user.id else fr.requester_id
for fr in friendships
]
if not other_ids:
return FriendListResponse(friends=[])
users_result = await db.execute(select(User).where(User.id.in_(other_ids)))
return FriendListResponse(friends=list(users_result.scalars().all()))
@router.delete("/{user_id}", response_model=ActionResponse)
async def unfriend(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove an existing (accepted) friendship."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfriend yourself")
result = await db.execute(
select(Friendship).where(
_friendship_pair(current_user.id, user_id),
Friendship.status == FriendshipStatus.ACCEPTED,
)
)
fr = result.scalar_one_or_none()
if fr is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not friends with this user")
await db.delete(fr)
await db.commit()
return ActionResponse(status="unfriended")
# ──────────────────────────────── Follow ───────────────────────────────────────
@router.post("/{user_id}/follow", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def follow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Follow another user (one-way, no approval)."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot follow yourself")
await _get_user_or_404(db, user_id)
if await _is_blocked_either_direction(db, current_user.id, user_id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot follow this user")
existing = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
db.add(Follow(follower_id=current_user.id, followed_id=user_id))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Already following this user")
return ActionResponse(status="followed")
@router.delete("/{user_id}/follow", response_model=ActionResponse)
async def unfollow_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Unfollow a user."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unfollow yourself")
result = await db.execute(
select(Follow).where(Follow.follower_id == current_user.id, Follow.followed_id == user_id)
)
follow = result.scalar_one_or_none()
if follow is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not following this user")
await db.delete(follow)
await db.commit()
return ActionResponse(status="unfollowed")
@router.get("/following", response_model=FollowListResponse)
async def list_following(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user follows."""
result = await db.execute(select(Follow).where(Follow.follower_id == current_user.id))
followed_ids = [f.followed_id for f in result.scalars().all()]
if not followed_ids:
return FollowListResponse(following=[])
users_result = await db.execute(select(User).where(User.id.in_(followed_ids)))
return FollowListResponse(following=list(users_result.scalars().all()))
# ──────────────────────────────── Block ────────────────────────────────────────
@router.post("/{user_id}/block", response_model=ActionResponse, status_code=status.HTTP_201_CREATED)
async def block_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Block a user: hard deny that also clears any existing friendship/follow
between the two users in either direction."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot block yourself")
await _get_user_or_404(db, user_id)
existing = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
db.add(Block(blocker_id=current_user.id, blocked_id=user_id))
await db.execute(delete(Friendship).where(_friendship_pair(current_user.id, user_id)))
await db.execute(delete(Follow).where(_follow_pair(current_user.id, user_id)))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already blocked")
return ActionResponse(status="blocked")
@router.delete("/{user_id}/block", response_model=ActionResponse)
async def unblock_user(user_id: int, current_user: CurrentUser, db: DbDep):
"""Remove a block."""
if user_id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot unblock yourself")
result = await db.execute(
select(Block).where(Block.blocker_id == current_user.id, Block.blocked_id == user_id)
)
block = result.scalar_one_or_none()
if block is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User is not blocked")
await db.delete(block)
await db.commit()
return ActionResponse(status="unblocked")
@router.get("/blocked", response_model=BlockedListResponse)
async def list_blocked(current_user: CurrentUser, db: DbDep):
"""List users the authenticated user has blocked."""
result = await db.execute(select(Block).where(Block.blocker_id == current_user.id))
blocked_ids = [b.blocked_id for b in result.scalars().all()]
if not blocked_ids:
return BlockedListResponse(blocked=[])
users_result = await db.execute(select(User).where(User.id.in_(blocked_ids)))
return BlockedListResponse(blocked=list(users_result.scalars().all()))
+78
View File
@@ -0,0 +1,78 @@
"""Wallet & transactions API endpoints.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.wallet import TransactionType
from app.services.wallet import wallet_service
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
class WalletResponse(BaseModel):
id: int
user_id: int
balance: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class TransactionResponse(BaseModel):
id: int
wallet_id: int
type: TransactionType
amount: int
balance_after: int
reference_type: str | None = None
reference_id: str | None = None
idempotency_key: str | None = None
metadata_json: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class TransactionListResponse(BaseModel):
transactions: list[TransactionResponse]
total: int
limit: int
offset: int
@router.get("", response_model=WalletResponse)
@router.get("/", response_model=WalletResponse)
async def get_wallet(current_user: CurrentUser, db: DbDep):
"""Retrieve the current user's wallet balance and metadata."""
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
return wallet
@router.get("/transactions", response_model=TransactionListResponse)
async def get_wallet_transactions(
current_user: CurrentUser,
db: DbDep,
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""List the current user's ledger transactions, ordered newest-first."""
wallet = await wallet_service.get_or_create_wallet(db, current_user.id)
txs, total = await wallet_service.get_transactions(db, wallet.id, limit=limit, offset=offset)
return TransactionListResponse(
transactions=txs,
total=total,
limit=limit,
offset=offset,
)
+48
View File
@@ -0,0 +1,48 @@
"""Pub/sub messaging bus for chat events over Redis.
Provides asynchronous publish hooks for world/room chat and DMs, allowing the
WebSocket real-time gateway (and multi-instance replicas) to receive and fan out
chat messages in real time.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import redis.asyncio as aioredis
from app.config import settings
logger = logging.getLogger(__name__)
async def publish_world_chat_event(
world_id: int, payload: dict[str, Any], room_id: int | None = None
) -> None:
"""Publish a world chat message event to Redis pub/sub for real-time delivery."""
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
channel = f"world:{world_id}:chat"
data = json.dumps(payload, default=str)
await client.publish(channel, data)
if room_id is not None:
await client.publish(f"room:{room_id}:chat", data)
except Exception as exc:
logger.debug("Failed to publish world chat event to Redis: %s", exc)
async def publish_dm_chat_event(
conversation_id: int, payload: dict[str, Any]
) -> None:
"""Publish a DM chat message event to Redis pub/sub for real-time delivery."""
try:
client = aioredis.from_url(settings.REDIS_URL, encoding="utf-8", decode_responses=True)
async with client:
channel = f"dm:{conversation_id}:chat"
data = json.dumps(payload, default=str)
await client.publish(channel, data)
except Exception as exc:
logger.debug("Failed to publish DM chat event to Redis: %s", exc)
+89
View File
@@ -0,0 +1,89 @@
"""PaymentProvider interface and NoopPaymentProvider implementation.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol
import uuid
ALLOWED_CREDIT_PACKS: set[int] = {100, 500, 1000, 5000}
@dataclass
class PurchaseSession:
session_id: str
user_id: int
credits: int
provider_ref: str
status: str = "created" # created, confirmed, failed
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class Confirmation:
confirmed: bool
provider_ref: str
credits: int
user_id: int
class PaymentProvider(Protocol):
"""Pluggable payment provider protocol (e.g. Stripe, PayPal, Noop)."""
async def create_purchase(
self, user_id: int, credits: int, idempotency_key: str | None = None
) -> PurchaseSession:
...
async def confirm(self, session_id: str) -> Confirmation:
...
class NoopPaymentProvider:
"""Deterministic, no-op payment provider for local development and testing.
Returns confirmed=True with a simulated provider reference. Clearly non-production.
"""
def __init__(self) -> None:
self._sessions: dict[str, PurchaseSession] = {}
async def create_purchase(
self, user_id: int, credits: int, idempotency_key: str | None = None
) -> PurchaseSession:
session_id = f"sess_{uuid.uuid4().hex[:16]}"
provider_ref = f"noop_tx_{uuid.uuid4().hex[:12]}"
sess = PurchaseSession(
session_id=session_id,
user_id=user_id,
credits=credits,
provider_ref=provider_ref,
status="created",
)
self._sessions[session_id] = sess
return sess
async def confirm(self, session_id: str) -> Confirmation:
sess = self._sessions.get(session_id)
if sess is None:
return Confirmation(
confirmed=False,
provider_ref="",
credits=0,
user_id=0,
)
sess.status = "confirmed"
return Confirmation(
confirmed=True,
provider_ref=sess.provider_ref,
credits=sess.credits,
user_id=sess.user_id,
)
payment_provider: PaymentProvider = NoopPaymentProvider()
+5
View File
@@ -109,6 +109,11 @@ class PresenceStore:
client = await self._ensure_client()
await client.set(self._heartbeat_key(user_id), "1", ex=self._heartbeat_ttl)
async def clear_heartbeat(self, user_id: int) -> None:
"""Clear heartbeat for *user_id* to mark them offline immediately."""
client = await self._ensure_client()
await client.delete(self._heartbeat_key(user_id))
# ------------------------------------------------------------------
# Online checks
# ------------------------------------------------------------------
+251
View File
@@ -0,0 +1,251 @@
"""Marketplace purchase service: atomic escrow, fee split, item granting, refunds.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import math
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
from app.models.wallet import TransactionType
from app.services.wallet import InsufficientFundsError, WalletService
MARKETPLACE_FEE_RATE = 0.30 # 30% platform fee
DEFAULT_ESCROW_HOLD_HOURS = 72
class ListingNotFoundError(Exception):
"""Raised when the specified listing does not exist."""
pass
class ListingNotAvailableError(Exception):
"""Raised when the listing is not in active status."""
pass
class SelfPurchaseError(Exception):
"""Raised when a seller attempts to buy their own listing."""
pass
class PurchaseNotFoundError(Exception):
"""Raised when the specified purchase does not exist."""
pass
class InvalidEscrowStateError(Exception):
"""Raised when an operation cannot be performed on the current escrow state."""
pass
class PurchaseService:
@staticmethod
def compute_fee_split(price: int, fee_rate: float = MARKETPLACE_FEE_RATE) -> tuple[int, int]:
"""Compute platform fee and seller net amount.
Returns (platform_fee, seller_net).
"""
platform_fee = math.floor(price * fee_rate)
seller_net = price - platform_fee
return platform_fee, seller_net
@classmethod
async def get_or_create_creator_balance(
cls, db: AsyncSession, user_id: int, for_update: bool = False
) -> CreatorBalance:
"""Fetch or lazily create a creator balance row for user_id."""
stmt = select(CreatorBalance).where(CreatorBalance.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
cb = result.scalar_one_or_none()
if cb is not None:
return cb
new_cb = CreatorBalance(
user_id=user_id,
available=0,
pending=0,
lifetime_earned=0,
)
db.add(new_cb)
await db.flush()
return new_cb
@classmethod
async def purchase_listing(
cls,
db: AsyncSession,
buyer_id: int,
listing_id: int,
idempotency_key: str | None = None,
escrow_hours: int = DEFAULT_ESCROW_HOLD_HOURS,
) -> tuple[Purchase, CatalogItem]:
"""Perform atomic purchase of a marketplace listing.
Debits buyer, holds escrow, grants item to buyer, records purchase,
and updates creator pending balance.
"""
# 1. Lock and validate listing
stmt_listing = (
select(MarketplaceListing)
.where(MarketplaceListing.id == listing_id)
.with_for_update()
)
res_listing = await db.execute(stmt_listing)
listing = res_listing.scalar_one_or_none()
if listing is None:
raise ListingNotFoundError(f"Listing {listing_id} not found")
if listing.status != ListingStatus.active:
raise ListingNotAvailableError(f"Listing {listing_id} is not active ({listing.status.value})")
if listing.seller_id == buyer_id:
raise SelfPurchaseError("Cannot purchase your own listing")
# 2. Fetch catalog item
catalog_item = await db.get(CatalogItem, listing.catalog_item_id)
if catalog_item is None:
raise ListingNotFoundError("Catalog item referenced by listing does not exist")
# 3. Calculate fee split
price = listing.price
platform_fee, seller_net = cls.compute_fee_split(price)
# 4. Debit buyer wallet atomically (commit=False)
tx_idemp = f"mkt-purchase-{idempotency_key}" if idempotency_key else None
await WalletService.debit(
db=db,
user_id=buyer_id,
amount=price,
tx_type=TransactionType.marketplace_purchase,
reference_type="marketplace_listing",
reference_id=str(listing.id),
idempotency_key=tx_idemp,
commit=False,
)
# 5. Grant catalog item to buyer
granted_item = CatalogItem(
owner_id=buyer_id,
asset_id=catalog_item.asset_id,
item_type=catalog_item.item_type,
name=catalog_item.name,
description=catalog_item.description,
metadata_json=catalog_item.metadata_json,
)
db.add(granted_item)
# 6. Mark listing as sold
now = datetime.now(timezone.utc)
listing.status = ListingStatus.sold
listing.updated_at = now
# 7. Create purchase record
hold_until = now + timedelta(hours=escrow_hours)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=listing.seller_id,
price=price,
platform_fee=platform_fee,
seller_net=seller_net,
status=PurchaseStatus.pending_escrow,
escrow_hold_until=hold_until,
)
db.add(purchase)
# 8. Update seller's CreatorBalance (pending escrow)
creator_balance = await cls.get_or_create_creator_balance(
db, listing.seller_id, for_update=True
)
creator_balance.pending += seller_net
creator_balance.updated_at = now
# Commit everything atomically
await db.commit()
await db.refresh(purchase)
await db.refresh(granted_item)
return purchase, granted_item
@classmethod
async def release_escrow(cls, db: AsyncSession, purchase_id: int) -> Purchase:
"""Release funds from escrow to seller's available creator balance."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot release escrow in status {purchase.status.value}"
)
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.available += purchase.seller_net
cb.lifetime_earned += purchase.seller_net
cb.updated_at = datetime.now(timezone.utc)
purchase.status = PurchaseStatus.completed
await db.commit()
await db.refresh(purchase)
return purchase
@classmethod
async def refund_purchase(
cls, db: AsyncSession, purchase_id: int, reason: str | None = None
) -> Purchase:
"""Refund a purchase during the escrow hold window."""
stmt = select(Purchase).where(Purchase.id == purchase_id).with_for_update()
res = await db.execute(stmt)
purchase = res.scalar_one_or_none()
if purchase is None:
raise PurchaseNotFoundError(f"Purchase {purchase_id} not found")
if purchase.status != PurchaseStatus.pending_escrow:
raise InvalidEscrowStateError(
f"Cannot refund purchase in status {purchase.status.value}"
)
# Reverse seller's pending creator balance
cb = await cls.get_or_create_creator_balance(db, purchase.seller_id, for_update=True)
cb.pending = max(0, cb.pending - purchase.seller_net)
cb.updated_at = datetime.now(timezone.utc)
# Credit buyer wallet
await WalletService.credit(
db=db,
user_id=purchase.buyer_id,
amount=purchase.price,
tx_type=TransactionType.refund,
reference_type="purchase_refund",
reference_id=str(purchase.id),
metadata={"reason": reason} if reason else None,
commit=False,
)
purchase.status = PurchaseStatus.refunded
await db.commit()
await db.refresh(purchase)
return purchase
purchase_service = PurchaseService()
+208
View File
@@ -0,0 +1,208 @@
"""Wallet service: atomic credit/debit, row locking, and idempotency.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.wallet import Transaction, TransactionType, Wallet
logger = logging.getLogger(__name__)
class InsufficientFundsError(Exception):
"""Raised when a debit would cause wallet balance to drop below zero."""
pass
class InvalidAmountError(ValueError):
"""Raised when an invalid (non-positive) amount is supplied."""
pass
class WalletService:
@staticmethod
async def get_or_create_wallet(
db: AsyncSession, user_id: int, for_update: bool = False
) -> Wallet:
"""Fetch existing wallet or lazily create one for user_id.
If for_update is True, locks the row with SELECT ... FOR UPDATE.
"""
stmt = select(Wallet).where(Wallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
wallet = result.scalar_one_or_none()
if wallet is not None:
return wallet
# If not found and not in update mode, create lazily
try:
new_wallet = Wallet(user_id=user_id, balance=0)
db.add(new_wallet)
await db.commit()
await db.refresh(new_wallet)
if for_update:
stmt_locked = select(Wallet).where(Wallet.id == new_wallet.id).with_for_update()
res = await db.execute(stmt_locked)
return res.scalar_one()
return new_wallet
except IntegrityError:
# Concurrent insert collision -- rollback and fetch the existing one
await db.rollback()
stmt = select(Wallet).where(Wallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one()
@classmethod
async def credit(
cls,
db: AsyncSession,
user_id: int,
amount: int,
tx_type: TransactionType | str,
reference_type: str | None = None,
reference_id: str | None = None,
idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]:
"""Credit a user's wallet atomically with row locking.
If an idempotency_key is provided and already exists, returns the original
outcome without double-crediting.
"""
if amount <= 0:
raise InvalidAmountError("Credit amount must be strictly positive")
if isinstance(tx_type, str):
tx_type = TransactionType(tx_type)
# Check idempotency first
if idempotency_key:
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
if existing_tx is not None:
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
return wallet, existing_tx
# Lock wallet row
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
wallet.balance += amount
tx = Transaction(
wallet_id=wallet.id,
type=tx_type,
amount=amount,
balance_after=wallet.balance,
reference_type=reference_type,
reference_id=reference_id,
idempotency_key=idempotency_key,
metadata_json=metadata,
)
db.add(tx)
if commit:
await db.commit()
await db.refresh(wallet)
await db.refresh(tx)
else:
await db.flush()
return wallet, tx
@classmethod
async def debit(
cls,
db: AsyncSession,
user_id: int,
amount: int,
tx_type: TransactionType | str,
reference_type: str | None = None,
reference_id: str | None = None,
idempotency_key: str | None = None,
metadata: dict[str, Any] | None = None,
commit: bool = True,
) -> tuple[Wallet, Transaction]:
"""Debit a user's wallet atomically with row locking and balance check.
Raises InsufficientFundsError if wallet balance < amount.
If idempotency_key is provided and already exists, returns the original
outcome without double-debiting.
"""
if amount <= 0:
raise InvalidAmountError("Debit amount must be strictly positive")
if isinstance(tx_type, str):
tx_type = TransactionType(tx_type)
# Check idempotency first
if idempotency_key:
stmt = select(Transaction).where(Transaction.idempotency_key == idempotency_key)
existing_tx = (await db.execute(stmt)).scalar_one_or_none()
if existing_tx is not None:
wallet = await cls.get_or_create_wallet(db, user_id, for_update=False)
return wallet, existing_tx
# Lock wallet row
wallet = await cls.get_or_create_wallet(db, user_id, for_update=True)
cur_balance = wallet.balance
if cur_balance < amount:
if commit:
await db.rollback()
raise InsufficientFundsError(
f"Insufficient funds: available {cur_balance}, required {amount}"
)
wallet.balance -= amount
tx = Transaction(
wallet_id=wallet.id,
type=tx_type,
amount=-amount,
balance_after=wallet.balance,
reference_type=reference_type,
reference_id=reference_id,
idempotency_key=idempotency_key,
metadata_json=metadata,
)
db.add(tx)
if commit:
await db.commit()
await db.refresh(wallet)
await db.refresh(tx)
else:
await db.flush()
return wallet, tx
@staticmethod
async def get_transactions(
db: AsyncSession, wallet_id: int, limit: int = 50, offset: int = 0
) -> tuple[list[Transaction], int]:
"""Fetch paged transactions for a wallet ordered newest-first, plus total count."""
count_stmt = select(func.count()).select_from(Transaction).where(Transaction.wallet_id == wallet_id)
total = (await db.execute(count_stmt)).scalar() or 0
stmt = (
select(Transaction)
.where(Transaction.wallet_id == wallet_id)
.order_by(Transaction.created_at.desc(), Transaction.id.desc())
.offset(offset)
.limit(limit)
)
res = await db.execute(stmt)
return list(res.scalars().all()), total
wallet_service = WalletService()
+28 -3
View File
@@ -16,6 +16,7 @@ out dozens of times.
"""
import asyncio
import hashlib
import os
import socket
import subprocess
@@ -35,6 +36,9 @@ from app.main import app
TEST_TABLES = (
"avatar_loadouts", "assets",
# Profile (app/models/profile.py) -- both FK users.id, profile_favorites
# additionally FKs worlds.id; list explicitly for clarity/consistency.
"profile_favorites", "profiles",
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
# must not lose the audit trail), so it won't be cascade-truncated via
@@ -50,13 +54,34 @@ TEST_TABLES = (
# reached by cascading a truncate of messages/conversations; list it
# explicitly along with the rest for clarity/consistency.
"message_reports", "messages", "conversation_participants", "conversations",
# Groups (app/models/groups.py). groups.owner_id is ON DELETE SET NULL
# (not CASCADE, deliberately -- see groups.py), so it wouldn't be reached
# by cascading a truncate of users; list it explicitly for clarity too.
"group_members", "groups",
# Economy (app/models/wallet.py, app/models/marketplace.py)
"purchases", "marketplace_listings", "catalog_items", "creator_balances",
"transactions", "wallets",
"sessions", "refresh_tokens", "users",
)
BACKEND_DIR = Path(__file__).resolve().parents[1]
_COMPOSE_PROJECT = "agp-pytest"
_EPHEMERAL_PG_PORT = 25432
_EPHEMERAL_REDIS_PORT = 26379
# A hardcoded project name/port here would mean every concurrent checkout
# running `pytest` (e.g. several Claude Agent-tool subagents, each in their
# own ~/agp/workspace/task_<id> clone) fights over the exact same Docker
# Compose project and host ports -- one run's pytest_unconfigure teardown
# can yank the ephemeral Postgres out from under a completely unrelated
# concurrent run, surfacing as "connection was closed in the middle of
# operation" with no obvious cause. Confirmed live 2026-09-15 running 3
# parallel subagents. Same root cause and fix pattern as the agent_core
# workspace/Docker-Compose collision documented in project_agp memory --
# derive both the project name and the port from a hash of the checkout
# directory, so different concurrent checkouts land on different projects/
# ports while the same checkout is stable across repeated runs.
_WORKSPACE_HASH = hashlib.sha256(str(BACKEND_DIR.parent).encode()).hexdigest()[:10]
_COMPOSE_PROJECT = f"agp-pytest-{_WORKSPACE_HASH}"
_EPHEMERAL_PG_PORT = 25400 + (int(_WORKSPACE_HASH, 16) % 100)
_EPHEMERAL_REDIS_PORT = 26300 + (int(_WORKSPACE_HASH, 16) % 100)
# Set by pytest_configure() when it starts container(s) it owns; torn down in
# pytest_unconfigure.
+312
View File
@@ -0,0 +1,312 @@
"""Integration tests for the DM chat router (app/routers/chat_dm.py):
- POST /api/chat/dm/{user_id}/messages
- GET /api/chat/dm/{user_id}/messages
- GET /api/chat/dm/conversations
The block-relationship router doesn't exist yet (sibling task), so blocks
are inserted directly against the ``Block`` model, same pattern already
used by ``tests/test_chat_models.py`` for direct model setup.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app import rate_limit
from app.config import settings
from app.main import app
from app.models.social import Block
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
async def _insert_block(blocker_id: int, blocked_id: int) -> None:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
session.add(Block(blocker_id=blocker_id, blocked_id=blocked_id))
await session.commit()
await engine.dispose()
class TestSendAndRetrieve:
def test_send_then_fetch_history(self):
client = TestClient(app)
alice = _register(client, "dm_alice1")
bob = _register(client, "dm_bob1")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "hey bob"},
headers=alice["headers"],
)
assert resp.status_code == 201, resp.text
sent = resp.json()
assert sent["sender_id"] == alice["id"]
assert sent["recipient_id"] == bob["id"]
assert sent["body"] == "hey bob"
assert sent["filtered"] is False
assert sent["id"] is not None
assert sent["created_at"] is not None
hist_alice = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert hist_alice.status_code == 200, hist_alice.text
msgs = hist_alice.json()["messages"]
assert len(msgs) == 1
assert msgs[0]["body"] == "hey bob"
hist_bob = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
assert hist_bob.status_code == 200, hist_bob.text
assert len(hist_bob.json()["messages"]) == 1
assert hist_bob.json()["messages"][0]["body"] == "hey bob"
def test_cannot_message_self(self):
client = TestClient(app)
alice = _register(client, "dm_self1")
resp = client.post(
f"/api/chat/dm/{alice['id']}/messages",
json={"body": "hi me"},
headers=alice["headers"],
)
assert resp.status_code == 400
def test_target_user_must_exist(self):
client = TestClient(app)
alice = _register(client, "dm_noexist1")
resp = client.post(
"/api/chat/dm/999999999/messages",
json={"body": "hello?"},
headers=alice["headers"],
)
assert resp.status_code == 404
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/chat/dm/1/messages", json={"body": "hi"})
assert resp.status_code == 401
def test_history_pagination_cursor(self):
client = TestClient(app)
alice = _register(client, "dm_page_a")
bob = _register(client, "dm_page_b")
for i in range(5):
r = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": f"msg {i}"},
headers=alice["headers"],
)
assert r.status_code == 201, r.text
page1 = client.get(
f"/api/chat/dm/{bob['id']}/messages",
params={"limit": 2},
headers=alice["headers"],
)
assert page1.status_code == 200
body1 = page1.json()
assert len(body1["messages"]) == 2
assert body1["messages"][0]["body"] == "msg 4" # newest first
assert body1["next_cursor"] is not None
page2 = client.get(
f"/api/chat/dm/{bob['id']}/messages",
params={"limit": 2, "before": body1["next_cursor"]},
headers=alice["headers"],
)
assert page2.status_code == 200
body2 = page2.json()
assert [m["body"] for m in body2["messages"]] == ["msg 2", "msg 1"]
def test_no_conversation_yet_returns_empty_history(self):
client = TestClient(app)
alice = _register(client, "dm_empty_a")
bob = _register(client, "dm_empty_b")
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert resp.status_code == 200
assert resp.json() == {"messages": [], "next_cursor": None}
class TestConversationList:
def test_lists_peer_last_message_and_unread_count(self):
client = TestClient(app)
alice = _register(client, "dm_conv_a")
bob = _register(client, "dm_conv_b")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "first"},
headers=alice["headers"],
)
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "second"},
headers=alice["headers"],
)
convs_bob = client.get("/api/chat/dm/conversations", headers=bob["headers"])
assert convs_bob.status_code == 200, convs_bob.text
data = convs_bob.json()
assert data["total"] == 1
conv = data["conversations"][0]
assert conv["peer_id"] == alice["id"]
assert conv["peer_username"] == "dm_conv_a"
assert conv["last_message"]["body"] == "second"
assert conv["unread_count"] == 2 # bob hasn't read yet
# Bob reads the latest page -> unread resets to 0.
client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
convs_bob_after = client.get("/api/chat/dm/conversations", headers=bob["headers"])
assert convs_bob_after.json()["conversations"][0]["unread_count"] == 0
# Alice sent them, so her own unread count for this conversation is 0.
convs_alice = client.get("/api/chat/dm/conversations", headers=alice["headers"])
assert convs_alice.json()["conversations"][0]["unread_count"] == 0
def test_scoped_to_authenticated_user_only(self):
client = TestClient(app)
alice = _register(client, "dm_scope_a")
bob = _register(client, "dm_scope_b")
carol = _register(client, "dm_scope_c")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "hi bob"},
headers=alice["headers"],
)
convs_carol = client.get("/api/chat/dm/conversations", headers=carol["headers"])
assert convs_carol.status_code == 200
assert convs_carol.json() == {"conversations": [], "total": 0}
def test_cannot_read_another_pairs_conversation(self):
"""A user's history endpoint only ever resolves *their own* pairing
with the given peer id -- there is no conversation id in the URL an
outsider could guess/pass to read someone else's DMs."""
client = TestClient(app)
alice = _register(client, "dm_iso_a")
bob = _register(client, "dm_iso_b")
carol = _register(client, "dm_iso_c")
client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "secret to bob"},
headers=alice["headers"],
)
# Carol asking for her "history with bob" gets her own (empty)
# conversation, never alice's.
resp = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=carol["headers"])
assert resp.status_code == 200
assert resp.json()["messages"] == []
class TestBlocking:
async def test_blocker_cannot_send_to_blocked(self):
client = TestClient(app)
alice = _register(client, "dm_block_a1")
bob = _register(client, "dm_block_b1")
await _insert_block(alice["id"], bob["id"])
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "let me in"},
headers=alice["headers"],
)
assert resp.status_code == 403
async def test_blocked_cannot_send_to_blocker(self):
client = TestClient(app)
alice = _register(client, "dm_block_a2")
bob = _register(client, "dm_block_b2")
await _insert_block(alice["id"], bob["id"])
resp = client.post(
f"/api/chat/dm/{alice['id']}/messages",
json={"body": "please respond"},
headers=bob["headers"],
)
assert resp.status_code == 403
class TestTextFilter:
def test_message_tripping_filter_is_redacted_and_flagged(self):
client = TestClient(app)
alice = _register(client, "dm_filter_a")
bob = _register(client, "dm_filter_b")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "you are a bastard"},
headers=alice["headers"],
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["filtered"] is True
assert "bastard" not in data["body"]
assert "*" in data["body"]
# The stored/delivered copy is the redacted one, not the raw text.
hist = client.get(f"/api/chat/dm/{alice['id']}/messages", headers=bob["headers"])
assert hist.json()["messages"][0]["body"] == data["body"]
def test_clean_message_is_not_flagged(self):
client = TestClient(app)
alice = _register(client, "dm_clean_a")
bob = _register(client, "dm_clean_b")
resp = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": "have a nice day"},
headers=alice["headers"],
)
assert resp.status_code == 201
assert resp.json()["filtered"] is False
assert resp.json()["body"] == "have a nice day"
class TestRateLimit:
@pytest.fixture(autouse=True)
def _enable_rate_limiting(self):
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = True
rate_limit.reset_all()
yield
rate_limit.reset_all()
settings.RATE_LIMIT_ENABLED = prev
def test_send_rate_limit_exceeded_returns_429(self):
client = TestClient(app)
alice = _register(client, "dm_rl_a")
bob = _register(client, "dm_rl_b")
codes = []
for i in range(25):
r = client.post(
f"/api/chat/dm/{bob['id']}/messages",
json={"body": f"spam {i}"},
headers=alice["headers"],
)
codes.append(r.status_code)
assert codes[:20] == [201] * 20
assert 429 in codes[20:]
assert codes[-1] == 429
+201
View File
@@ -0,0 +1,201 @@
"""Integration tests for message reporting and moderation queue:
- POST /api/chat/messages/{message_id}/report
- GET /api/moderation/reports
- POST /api/moderation/reports/{id}/resolve
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
resp = client.post(
f"/api/chat/dm/{recipient['id']}/messages",
headers=sender["headers"],
json={"body": text},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
class TestReportMessage:
def test_report_message_success(self):
client = TestClient(app)
alice = _register(client, "rep_alice1")
bob = _register(client, "rep_bob1")
msg_id = _send_dm(client, alice, bob, "spam message")
resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "Contains spam", "category": "spam"},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["message_id"] == msg_id
assert data["reporter_id"] == bob["id"]
assert data["status"] == "open"
assert "[spam] Contains spam" in data["reason"]
def test_duplicate_report_rejected(self):
client = TestClient(app)
alice = _register(client, "rep_alice2")
bob = _register(client, "rep_bob2")
msg_id = _send_dm(client, alice, bob, "bad message")
resp1 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive"},
)
assert resp1.status_code == 201
resp2 = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "offensive again"},
)
assert resp2.status_code == 409
def test_report_nonexistent_message_404(self):
client = TestClient(app)
bob = _register(client, "rep_bob3")
resp = client.post(
"/api/chat/messages/999999/report",
headers=bob["headers"],
json={"reason": "ghost"},
)
assert resp.status_code == 404
def test_report_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
assert resp.status_code == 401
class TestModerationQueue:
def test_non_moderator_cannot_access_queue(self):
client = TestClient(app)
regular_user = _register(client, "rep_regular1")
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
assert resp.status_code == 403
def test_moderator_can_list_reports(self):
client = TestClient(app)
alice = _register(client, "rep_alice4")
bob = _register(client, "rep_bob4")
admin = _register(client, "admin_list")
msg_id = _send_dm(client, alice, bob, "something to report")
client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "needs review"},
)
resp = client.get("/api/moderation/reports", headers=admin["headers"])
assert resp.status_code == 200, resp.text
reports = resp.json()
assert len(reports) >= 1
assert any(r["message_id"] == msg_id for r in reports)
def test_moderator_resolve_dismiss(self):
client = TestClient(app)
alice = _register(client, "rep_alice5")
bob = _register(client, "rep_bob5")
admin = _register(client, "admin_dismiss")
msg_id = _send_dm(client, alice, bob, "benign message")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "false alarm"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "dismiss", "notes": "No violation found"},
)
assert resolve_resp.status_code == 200, resolve_resp.text
data = resolve_resp.json()
assert data["report_id"] == report_id
assert data["status"] == "dismissed"
assert data["action_taken"] == "dismiss"
assert data["moderator_id"] == admin["id"]
def test_moderator_resolve_remove(self):
client = TestClient(app)
alice = _register(client, "rep_alice6")
bob = _register(client, "rep_bob6")
admin = _register(client, "admin_remove")
msg_id = _send_dm(client, alice, bob, "offensive content")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=bob["headers"],
json={"reason": "hate speech"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin["headers"],
json={"action": "remove", "notes": "Content removed"},
)
assert resolve_resp.status_code == 200
assert resolve_resp.json()["status"] == "resolved"
# The message is removed from conversation history
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
assert all(m["id"] != msg_id for m in hist.json()["messages"])
def test_moderator_resolve_ban(self):
client = TestClient(app)
bad_user = _register(client, "bad_actor")
victim = _register(client, "rep_victim")
admin_auth = _register(client, "admin_ban")
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
rep_resp = client.post(
f"/api/chat/messages/{msg_id}/report",
headers=victim["headers"],
json={"reason": "malicious user"},
)
report_id = rep_resp.json()["id"]
resolve_resp = client.post(
f"/api/moderation/reports/{report_id}/resolve",
headers=admin_auth["headers"],
json={"action": "ban", "notes": "Banned malicious user"},
)
assert resolve_resp.status_code == 200
# Attempting to use bad_user's token fails with 403 inactive account
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
assert bad_me.status_code == 403
+246
View File
@@ -0,0 +1,246 @@
"""Integration tests for in-experience world/room chat:
- POST /api/chat/world/{world_id}/messages
- GET /api/chat/world/{world_id}/messages
- POST /api/chat/room/{room_id}/messages
- GET /api/chat/room/{room_id}/messages
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "TestWorld", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
assert req.status_code == 201, req.text
req_id = req.json()["id"]
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
assert acc.status_code == 200, acc.text
class TestWorldChat:
def test_send_and_get_world_message(self):
client = TestClient(app)
alice = _register(client, "wch_alice1")
bob = _register(client, "wch_bob1")
world_id = _make_world(client, alice["headers"], visibility="public")
# Bob posts to Alice's public world
post_resp = client.post(
f"/api/chat/world/{world_id}/messages",
headers=bob["headers"],
json={"body": "Hello world from Bob!"},
)
assert post_resp.status_code == 201, post_resp.text
msg_data = post_resp.json()
assert msg_data["world_id"] == world_id
assert msg_data["room_id"] is None
assert msg_data["sender_id"] == bob["id"]
assert msg_data["body"] == "Hello world from Bob!"
assert msg_data["filtered"] is False
assert msg_data["author"]["username"] == "wch_bob1"
assert msg_data["author"]["id"] == bob["id"]
# Alice gets world chat history
get_resp = client.get(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
)
assert get_resp.status_code == 200, get_resp.text
history = get_resp.json()
assert len(history["messages"]) == 1
assert history["messages"][0]["id"] == msg_data["id"]
assert history["messages"][0]["author"]["username"] == "wch_bob1"
def test_room_scoped_messages(self):
client = TestClient(app)
alice = _register(client, "wch_alice2")
world_id = _make_world(client, alice["headers"])
# Fetch auto-created main room
rooms_resp = client.get(f"/api/worlds/{world_id}/rooms", headers=alice["headers"])
assert rooms_resp.status_code == 200, rooms_resp.text
rooms = rooms_resp.json()
assert len(rooms) >= 1
room_id = rooms[0]["id"]
# Post directly via room endpoint
post_room = client.post(
f"/api/chat/room/{room_id}/messages",
headers=alice["headers"],
json={"body": "Message in room"},
)
assert post_room.status_code == 201, post_room.text
msg_data = post_room.json()
assert msg_data["world_id"] == world_id
assert msg_data["room_id"] == room_id
assert msg_data["body"] == "Message in room"
# Get room history
get_room = client.get(
f"/api/chat/room/{room_id}/messages",
headers=alice["headers"],
)
assert get_room.status_code == 200, get_room.text
messages = get_room.json()["messages"]
assert len(messages) == 1
assert messages[0]["id"] == msg_data["id"]
# Also visible when querying world history with room_id filter
get_world_room = client.get(
f"/api/chat/world/{world_id}/messages?room_id={room_id}",
headers=alice["headers"],
)
assert get_world_room.status_code == 200, get_world_room.text
assert len(get_world_room.json()["messages"]) == 1
def test_visibility_access_control(self):
client = TestClient(app)
alice = _register(client, "wch_alice3")
bob = _register(client, "wch_bob3")
charlie = _register(client, "wch_charlie3")
# Private world: only Alice
priv_world_id = _make_world(client, alice["headers"], visibility="private")
# Bob cannot send or read
bob_send = client.post(
f"/api/chat/world/{priv_world_id}/messages",
headers=bob["headers"],
json={"body": "Trying to trespass"},
)
assert bob_send.status_code == 403, bob_send.text
bob_read = client.get(
f"/api/chat/world/{priv_world_id}/messages",
headers=bob["headers"],
)
assert bob_read.status_code == 403, bob_read.text
# Alice (owner) can send and read
alice_send = client.post(
f"/api/chat/world/{priv_world_id}/messages",
headers=alice["headers"],
json={"body": "My private sanctum"},
)
assert alice_send.status_code == 201, alice_send.text
# Friends-only world
fr_world_id = _make_world(client, alice["headers"], visibility="friends_only")
_make_friends(client, alice, bob)
# Bob (friend) can post
bob_fr_send = client.post(
f"/api/chat/world/{fr_world_id}/messages",
headers=bob["headers"],
json={"body": "Hello friend!"},
)
assert bob_fr_send.status_code == 201, bob_fr_send.text
# Charlie (not friend) cannot post or read
charlie_send = client.post(
f"/api/chat/world/{fr_world_id}/messages",
headers=charlie["headers"],
json={"body": "Can I join?"},
)
assert charlie_send.status_code == 403, charlie_send.text
charlie_read = client.get(
f"/api/chat/world/{fr_world_id}/messages",
headers=charlie["headers"],
)
assert charlie_read.status_code == 403, charlie_read.text
def test_not_found_validation(self):
client = TestClient(app)
alice = _register(client, "wch_alice4")
# Non-existent world
res = client.post(
"/api/chat/world/999999/messages",
headers=alice["headers"],
json={"body": "Ghost world"},
)
assert res.status_code == 404, res.text
# Non-existent room
world_id = _make_world(client, alice["headers"])
res2 = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "Ghost room", "room_id": 999999},
)
assert res2.status_code == 404, res2.text
def test_text_filtering_in_world_chat(self):
client = TestClient(app)
alice = _register(client, "wch_alice5")
world_id = _make_world(client, alice["headers"])
# Default filter redacts forbidden words
res = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "you are a bastard"},
)
assert res.status_code == 201, res.text
data = res.json()
assert data["filtered"] is True
assert "bastard" not in data["body"]
assert "*" in data["body"]
def test_pagination_and_next_cursor(self):
client = TestClient(app)
alice = _register(client, "wch_alice6")
world_id = _make_world(client, alice["headers"])
for i in range(5):
r = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": f"Msg {i}"},
)
assert r.status_code == 201
page1 = client.get(
f"/api/chat/world/{world_id}/messages?limit=2",
headers=alice["headers"],
).json()
assert len(page1["messages"]) == 2
assert page1["next_cursor"] is not None
assert page1["messages"][0]["body"] == "Msg 4"
assert page1["messages"][1]["body"] == "Msg 3"
page2 = client.get(
f"/api/chat/world/{world_id}/messages?limit=2&before={page1['next_cursor']}",
headers=alice["headers"],
).json()
assert len(page2["messages"]) == 2
assert page2["messages"][0]["body"] == "Msg 2"
assert page2["messages"][1]["body"] == "Msg 1"
+258
View File
@@ -0,0 +1,258 @@
"""Model-level tests for groups: groups and group_members.
app/models/groups.py / alembic/versions/0009_add_group_tables.py
(docs/planning/social.md US-5). This is the models+migration task only --
there are no API endpoints yet (a sibling task, "Groups API", owns
create/join/leave/role authorization and the group page endpoint), so these
tests exercise the ORM models and the real Postgres constraints directly
rather than going through HTTP.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.groups import Group, GroupMember, GroupRole
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
class TestGroupCreationAndRoles:
async def test_create_group_with_members_of_different_roles(self):
client = TestClient(app)
owner = _register(client, "grp_role_owner")
admin = _register(client, "grp_role_admin")
member = _register(client, "grp_role_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Explorers", description="We explore worlds", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=admin, role=GroupRole.ADMIN),
GroupMember(group_id=group.id, user_id=member, role=GroupRole.MEMBER),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
fetched = await session.get(Group, group_id)
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
by_user = {m.user_id: m.role for m in members}
assert fetched.owner_id == owner
assert len(members) == 3
assert by_user[owner] == GroupRole.OWNER
assert by_user[admin] == GroupRole.ADMIN
assert by_user[member] == GroupRole.MEMBER
finally:
await engine.dispose()
async def test_membership_role_defaults_to_member(self):
client = TestClient(app)
owner = _register(client, "grp_default_owner")
joiner = _register(client, "grp_default_joiner")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Defaults", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=joiner))
await session.commit()
group_id = group.id
async with factory() as session:
gm = (
await session.execute(
select(GroupMember).where(
GroupMember.group_id == group_id, GroupMember.user_id == joiner
)
)
).scalar_one()
assert gm.role == GroupRole.MEMBER
finally:
await engine.dispose()
class TestUniqueMembershipConstraint:
async def test_duplicate_membership_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_uq_owner")
member = _register(client, "grp_uq_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Uniqueness", owner_id=owner)
session.add(group)
await session.flush()
session.add(GroupMember(group_id=group.id, user_id=member))
await session.commit()
group_id = group.id
async with factory() as session:
session.add(GroupMember(group_id=group_id, user_id=member))
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
class TestCascadeDelete:
async def test_deleting_group_deletes_members(self):
client = TestClient(app)
owner = _register(client, "grp_cd_group_owner")
member = _register(client, "grp_cd_group_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="To Delete", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
# Core delete against groups directly, so the real ON DELETE
# CASCADE does the work (not the ORM).
await session.execute(delete(Group).where(Group.id == group_id))
await session.commit()
async with factory() as session:
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert members == []
finally:
await engine.dispose()
async def test_deleting_member_user_removes_only_their_membership(self):
client = TestClient(app)
owner = _register(client, "grp_cd_user_owner")
member = _register(client, "grp_cd_user_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Membership Cascade", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == member))
await session.commit()
async with factory() as session:
remaining = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
# Only the deleted member's row is gone; the group and the
# owner's membership survive.
assert len(remaining) == 1
assert remaining[0].user_id == owner
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id == owner
finally:
await engine.dispose()
async def test_deleting_owner_survives_with_owner_id_null(self):
"""Deleting the owner's account must not delete the group or its
other members -- only ``owner_id`` is nulled out (SET NULL), per the
module docstring in app/models/groups.py."""
client = TestClient(app)
owner = _register(client, "grp_cd_owner_owner")
member = _register(client, "grp_cd_owner_member")
engine, factory = _session_factory()
try:
async with factory() as session:
group = Group(name="Survives Owner Deletion", owner_id=owner)
session.add(group)
await session.flush()
session.add_all(
[
GroupMember(group_id=group.id, user_id=owner, role=GroupRole.OWNER),
GroupMember(group_id=group.id, user_id=member),
]
)
await session.commit()
group_id = group.id
async with factory() as session:
await session.execute(delete(User).where(User.id == owner))
await session.commit()
async with factory() as session:
group = await session.get(Group, group_id)
assert group is not None
assert group.owner_id is None
assert group.name == "Survives Owner Deletion"
# The owner's own membership row is gone (user_id CASCADE),
# but the other member's row survives untouched.
members = (
await session.execute(
select(GroupMember).where(GroupMember.group_id == group_id)
)
).scalars().all()
assert len(members) == 1
assert members[0].user_id == member
finally:
await engine.dispose()
+437
View File
@@ -0,0 +1,437 @@
"""Integration tests for the Groups API router (app/routers/groups.py).
app/models/groups.py owns the schema (see test_group_models.py); this
covers create/join/leave, role authorization, ownership transfer, and the
group page's member-list privacy.
"""
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"], "username": username}
def _create_group(client: TestClient, headers: dict, name: str, description: str | None = None) -> dict:
resp = client.post(
"/api/groups",
json={"name": name, "description": description},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()
class TestCreateGroup:
def test_create_makes_creator_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_create_owner")
group = _create_group(client, owner["headers"], "Explorers", "We explore worlds")
assert group["name"] == "Explorers"
assert group["description"] == "We explore worlds"
assert group["owner_id"] == owner["id"]
assert group["member_count"] == 1
assert len(group["members"]) == 1
assert group["members"][0]["user_id"] == owner["id"]
assert group["members"][0]["role"] == "owner"
def test_requires_auth(self):
client = TestClient(app)
resp = client.post("/api/groups", json={"name": "No Auth"})
assert resp.status_code == 401
def test_blank_name_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_blank_name")
resp = client.post("/api/groups", json={"name": " "}, headers=owner["headers"])
assert resp.status_code == 422
def test_name_over_max_length_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_long_name")
resp = client.post(
"/api/groups", json={"name": "x" * 101}, headers=owner["headers"]
)
assert resp.status_code == 422
class TestGroupPage:
def test_get_group_page_shows_members_and_roles_no_private_fields(self):
client = TestClient(app)
owner = _register(client, "grp_api_page_owner")
member = _register(client, "grp_api_page_member")
group = _create_group(client, owner["headers"], "Page Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.get(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["name"] == "Page Group"
assert body["member_count"] == 2
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[owner["id"]]["role"] == "owner"
assert by_user[member["id"]]["role"] == "member"
assert by_user[member["id"]]["username"] == member["username"]
# No private User fields (email, hashed_password, etc.) leak.
for m in body["members"]:
assert set(m.keys()) == {"user_id", "username", "role", "joined_at"}
def test_get_nonexistent_group_is_404(self):
client = TestClient(app)
user = _register(client, "grp_api_404")
resp = client.get("/api/groups/999999999", headers=user["headers"])
assert resp.status_code == 404
class TestJoinLeave:
def test_join_is_idempotent(self):
client = TestClient(app)
owner = _register(client, "grp_api_join_owner")
joiner = _register(client, "grp_api_join_joiner")
group = _create_group(client, owner["headers"], "Join Group")
r1 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r1.status_code == 200, r1.text
r2 = client.post(f"/api/groups/{group['id']}/join", headers=joiner["headers"])
assert r2.status_code == 200, r2.text
assert r1.json()["role"] == r2.json()["role"] == "member"
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 2 # not 3 -- duplicate join didn't add a row
def test_leave_removes_membership(self):
client = TestClient(app)
owner = _register(client, "grp_api_leave_owner")
member = _register(client, "grp_api_leave_member")
group = _create_group(client, owner["headers"], "Leave Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert resp.status_code == 200, resp.text
page = client.get(f"/api/groups/{group['id']}", headers=owner["headers"]).json()
assert page["member_count"] == 1
assert all(m["user_id"] != member["id"] for m in page["members"])
def test_owner_cannot_leave_without_transfer(self):
client = TestClient(app)
owner = _register(client, "grp_api_owner_leave")
group = _create_group(client, owner["headers"], "Owner Leave Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert resp.status_code == 409, resp.text
def test_leave_when_not_a_member_is_404(self):
client = TestClient(app)
owner = _register(client, "grp_api_notmember_owner")
stranger = _register(client, "grp_api_notmember_stranger")
group = _create_group(client, owner["headers"], "Not A Member Group")
resp = client.post(f"/api/groups/{group['id']}/leave", headers=stranger["headers"])
assert resp.status_code == 404
class TestRoleAuthorization:
def test_owner_can_promote_and_demote(self):
client = TestClient(app)
owner = _register(client, "grp_api_role_owner")
member = _register(client, "grp_api_role_member")
group = _create_group(client, owner["headers"], "Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
promote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
assert promote.status_code == 200, promote.text
assert promote.json()["role"] == "admin"
demote = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "member"},
headers=owner["headers"],
)
assert demote.status_code == 200, demote.text
assert demote.json()["role"] == "member"
def test_plain_member_cannot_promote(self):
client = TestClient(app)
owner = _register(client, "grp_api_noauth_owner")
member = _register(client, "grp_api_noauth_member")
other = _register(client, "grp_api_noauth_other")
group = _create_group(client, owner["headers"], "No Auth Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_admin_cannot_promote_or_demote(self):
"""Only the owner may change roles -- admins cannot, per spec."""
client = TestClient(app)
owner = _register(client, "grp_api_adminauth_owner")
admin = _register(client, "grp_api_adminauth_admin")
other = _register(client, "grp_api_adminauth_other")
group = _create_group(client, owner["headers"], "Admin Cannot Promote Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(f"/api/groups/{group['id']}/join", headers=other["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.post(
f"/api/groups/{group['id']}/members/{other['id']}/role",
json={"role": "admin"},
headers=admin["headers"],
)
assert resp.status_code == 403
def test_plain_member_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_nodelete_owner")
member = _register(client, "grp_api_nodelete_member")
group = _create_group(client, owner["headers"], "No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.delete(f"/api/groups/{group['id']}", headers=member["headers"])
assert resp.status_code == 403
def test_admin_cannot_delete_group(self):
client = TestClient(app)
owner = _register(client, "grp_api_admindelete_owner")
admin = _register(client, "grp_api_admindelete_admin")
group = _create_group(client, owner["headers"], "Admin No Delete Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.delete(f"/api/groups/{group['id']}", headers=admin["headers"])
assert resp.status_code == 403
def test_plain_member_cannot_transfer_ownership(self):
client = TestClient(app)
owner = _register(client, "grp_api_notransfer_owner")
member = _register(client, "grp_api_notransfer_member")
group = _create_group(client, owner["headers"], "No Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=member["headers"],
)
assert resp.status_code == 403
def test_cannot_set_role_to_owner_via_role_endpoint(self):
client = TestClient(app)
owner = _register(client, "grp_api_noownerrole_owner")
member = _register(client, "grp_api_noownerrole_member")
group = _create_group(client, owner["headers"], "No Owner Role Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/members/{member['id']}/role",
json={"role": "owner"},
headers=owner["headers"],
)
assert resp.status_code == 422
class TestOwnershipTransfer:
def test_transfer_moves_ownership_and_demotes_old_owner(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfer_owner")
member = _register(client, "grp_api_xfer_member")
group = _create_group(client, owner["headers"], "Transfer Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": member["id"]},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["owner_id"] == member["id"]
by_user = {m["user_id"]: m for m in body["members"]}
assert by_user[member["id"]]["role"] == "owner"
assert by_user[owner["id"]]["role"] == "admin"
# Exactly one owner remains.
owners = [m for m in body["members"] if m["role"] == "owner"]
assert len(owners) == 1
# Old owner can now leave (no longer the owner).
leave = client.post(f"/api/groups/{group['id']}/leave", headers=owner["headers"])
assert leave.status_code == 200, leave.text
# New owner cannot leave without transferring again.
leave2 = client.post(f"/api/groups/{group['id']}/leave", headers=member["headers"])
assert leave2.status_code == 409
def test_transfer_target_must_be_a_member(self):
client = TestClient(app)
owner = _register(client, "grp_api_xfernonmember_owner")
stranger = _register(client, "grp_api_xfernonmember_stranger")
group = _create_group(client, owner["headers"], "Transfer Nonmember Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": stranger["id"]},
headers=owner["headers"],
)
assert resp.status_code == 404
def test_transfer_to_self_rejected(self):
client = TestClient(app)
owner = _register(client, "grp_api_xferself_owner")
group = _create_group(client, owner["headers"], "Transfer Self Group")
resp = client.post(
f"/api/groups/{group['id']}/transfer",
json={"new_owner_id": owner["id"]},
headers=owner["headers"],
)
assert resp.status_code == 400
class TestUpdateGroup:
def test_owner_can_update_name_and_description(self):
client = TestClient(app)
owner = _register(client, "grp_api_update_owner")
group = _create_group(client, owner["headers"], "Old Name", "Old desc")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "New Name", "description": "New desc"},
headers=owner["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "New Name"
assert resp.json()["description"] == "New desc"
def test_admin_can_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_adminupdate_owner")
admin = _register(client, "grp_api_adminupdate_admin")
group = _create_group(client, owner["headers"], "Admin Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=admin["headers"])
client.post(
f"/api/groups/{group['id']}/members/{admin['id']}/role",
json={"role": "admin"},
headers=owner["headers"],
)
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Updated By Admin"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["name"] == "Updated By Admin"
def test_plain_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_noupdate_owner")
member = _register(client, "grp_api_noupdate_member")
group = _create_group(client, owner["headers"], "No Update Group")
client.post(f"/api/groups/{group['id']}/join", headers=member["headers"])
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=member["headers"],
)
assert resp.status_code == 403
def test_non_member_cannot_update(self):
client = TestClient(app)
owner = _register(client, "grp_api_strangernoupdate_owner")
stranger = _register(client, "grp_api_strangernoupdate_stranger")
group = _create_group(client, owner["headers"], "Stranger No Update Group")
resp = client.patch(
f"/api/groups/{group['id']}",
json={"name": "Hacked Name"},
headers=stranger["headers"],
)
assert resp.status_code == 403
class TestDeleteGroup:
def test_owner_can_delete(self):
client = TestClient(app)
owner = _register(client, "grp_api_delete_owner")
group = _create_group(client, owner["headers"], "Delete Group")
resp = client.delete(f"/api/groups/{group['id']}", headers=owner["headers"])
assert resp.status_code == 204, resp.text
after = client.get(f"/api/groups/{group['id']}", headers=owner["headers"])
assert after.status_code == 404
class TestListGroups:
def test_list_all_groups_paginated(self):
client = TestClient(app)
owner = _register(client, "grp_api_list_owner")
_create_group(client, owner["headers"], "List Group A")
_create_group(client, owner["headers"], "List Group B")
resp = client.get("/api/groups", params={"limit": 1}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["groups"]) == 1
assert body["total"] >= 2
def test_list_mine_only_returns_own_groups(self):
client = TestClient(app)
owner = _register(client, "grp_api_listmine_owner")
other_owner = _register(client, "grp_api_listmine_other")
mine = _create_group(client, owner["headers"], "Mine Group")
_create_group(client, other_owner["headers"], "Not Mine Group")
resp = client.get("/api/groups", params={"mine": True}, headers=owner["headers"])
assert resp.status_code == 200, resp.text
ids = [g["id"] for g in resp.json()["groups"]]
assert mine["id"] in ids
assert all(
g["id"] != mine["id"] or g["owner_id"] == owner["id"]
for g in resp.json()["groups"]
)
# The other owner's group must not appear.
other_group_ids = [
g["id"] for g in client.get(
"/api/groups", params={"mine": True}, headers=other_owner["headers"]
).json()["groups"]
]
assert mine["id"] not in other_group_ids
+167
View File
@@ -0,0 +1,167 @@
"""Integration tests for Marketplace Listings API.
docs/planning/economy.md US-3 (task f51c7d95e237).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_catalog_items_and_listings_crud():
client = TestClient(app)
uid1, h1 = _register(client, "mkt_user_1")
uid2, h2 = _register(client, "mkt_user_2")
# 1. Create catalog item by user 1
resp = client.post(
"/api/marketplace/items",
headers=h1,
json={
"name": "Wizard Hat",
"description": "A pointy blue hat with stars",
"item_type": "avatar_item",
"metadata_json": {"color": "blue", "rarity": "rare"},
},
)
assert resp.status_code == 201, resp.text
item_data = resp.json()
assert item_data["name"] == "Wizard Hat"
assert item_data["owner_id"] == uid1
item_id = item_data["id"]
# 2. List catalog items
resp = client.get("/api/marketplace/items", headers=h1, params={"mine": "true"})
assert resp.status_code == 200
my_items = resp.json()
assert len(my_items) >= 1
assert any(i["id"] == item_id for i in my_items)
resp = client.get("/api/marketplace/items", headers=h2, params={"mine": "true"})
assert resp.status_code == 200
assert not any(i["id"] == item_id for i in resp.json())
# 3. User 2 tries to list User 1's item -> 403
resp = client.post(
"/api/marketplace/listings",
headers=h2,
json={
"catalog_item_id": item_id,
"price": 100,
},
)
assert resp.status_code == 403, resp.text
# Invalid price <= 0 -> 422
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 0,
},
)
assert resp.status_code == 422
# 4. User 1 successfully lists item
resp = client.post(
"/api/marketplace/listings",
headers=h1,
json={
"catalog_item_id": item_id,
"price": 250,
},
)
assert resp.status_code == 201, resp.text
listing = resp.json()
assert listing["price"] == 250
assert listing["seller_id"] == uid1
assert listing["status"] == "active"
assert listing["catalog_item"]["name"] == "Wizard Hat"
listing_id = listing["id"]
# 5. Search / browse listings
resp = client.get("/api/marketplace/listings", params={"q": "Wizard"})
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 1
assert any(l["id"] == listing_id for l in data["listings"])
# Filter by item_type
resp = client.get("/api/marketplace/listings", params={"item_type": "avatar_item"})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"item_type": "experience_pass"})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
# Price range filter
resp = client.get("/api/marketplace/listings", params={"min_price": 300})
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
resp = client.get("/api/marketplace/listings", params={"max_price": 260})
assert resp.status_code == 200
assert any(l["id"] == listing_id for l in resp.json()["listings"])
# 6. Listing detail
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["id"] == listing_id
# 7. Update listing
# User 2 cannot update User 1's listing
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h2,
json={"price": 500},
)
assert resp.status_code == 403
# User 1 updates price
resp = client.patch(
f"/api/marketplace/listings/{listing_id}",
headers=h1,
json={"price": 350},
)
assert resp.status_code == 200
assert resp.json()["price"] == 350
# 8. Delist listing
# User 2 cannot delist
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h2)
assert resp.status_code == 403
# User 1 delists
resp = client.delete(f"/api/marketplace/listings/{listing_id}", headers=h1)
assert resp.status_code == 204
# Now detail shows delisted
resp = client.get(f"/api/marketplace/listings/{listing_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "delisted"
# Default search only shows active
resp = client.get("/api/marketplace/listings")
assert resp.status_code == 200
assert not any(l["id"] == listing_id for l in resp.json()["listings"])
+175
View File
@@ -0,0 +1,175 @@
"""Model-level integration tests for Marketplace tables.
docs/planning/economy.md US-3, US-4 (task 166836d55365).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.marketplace import (
CatalogItem,
CreatorBalance,
ItemType,
ListingStatus,
MarketplaceListing,
Purchase,
PurchaseStatus,
)
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_catalog_item_and_listing_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_1")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.avatar_item,
name="Cyber Wings",
description="Glow in the dark wings",
metadata_json={"slot": "back"},
)
session.add(item)
await session.commit()
await session.refresh(item)
assert item.id is not None
assert item.name == "Cyber Wings"
listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=250,
status=ListingStatus.active,
)
session.add(listing)
await session.commit()
await session.refresh(listing)
assert listing.id is not None
assert listing.price == 250
assert listing.status == ListingStatus.active
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_listing_price_positive_constraint():
client = TestClient(app)
seller_id = _register(client, "market_seller_2")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(
owner_id=seller_id,
item_type=ItemType.asset,
name="Free Prop",
)
session.add(item)
await session.commit()
await session.refresh(item)
bad_listing = MarketplaceListing(
catalog_item_id=item.id,
seller_id=seller_id,
price=0, # Must be > 0
)
session.add(bad_listing)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_creator_balance_uniqueness_and_constraints():
client = TestClient(app)
creator_id = _register(client, "market_creator_1")
engine, sf = _session_factory()
try:
async with sf() as session:
cb1 = CreatorBalance(user_id=creator_id, available=100, pending=50, lifetime_earned=150)
session.add(cb1)
await session.commit()
await session.refresh(cb1)
assert cb1.available == 100
assert cb1.pending == 50
assert cb1.lifetime_earned == 150
# Unique user_id constraint
async with sf() as session:
cb2 = CreatorBalance(user_id=creator_id)
session.add(cb2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_purchase_record_creation():
client = TestClient(app)
seller_id = _register(client, "market_seller_3")
buyer_id = _register(client, "market_buyer_3")
engine, sf = _session_factory()
try:
async with sf() as session:
item = CatalogItem(owner_id=seller_id, item_type=ItemType.asset, name="Sword Prop")
session.add(item)
await session.commit()
await session.refresh(item)
listing = MarketplaceListing(catalog_item_id=item.id, seller_id=seller_id, price=100)
session.add(listing)
await session.commit()
await session.refresh(listing)
purchase = Purchase(
listing_id=listing.id,
buyer_id=buyer_id,
seller_id=seller_id,
price=100,
platform_fee=30,
seller_net=70,
status=PurchaseStatus.pending_escrow,
)
session.add(purchase)
await session.commit()
await session.refresh(purchase)
assert purchase.id is not None
assert purchase.seller_net == 70
assert purchase.status == PurchaseStatus.pending_escrow
finally:
await engine.dispose()
+222
View File
@@ -0,0 +1,222 @@
"""Integration tests for Marketplace Purchase Service, Escrow, and Fee Splitting.
docs/planning/economy.md US-4 (task bc90d3f481b4).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.purchase import purchase_service
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_fee_split_calculation():
# 30% platform fee
fee, net = purchase_service.compute_fee_split(100)
assert fee == 30
assert net == 70
fee, net = purchase_service.compute_fee_split(250)
assert fee == 75
assert net == 175
def test_marketplace_purchase_atomic_flow():
client = TestClient(app)
seller_id, seller_headers = _register(client, "purchase_seller_1")
buyer_id, buyer_headers = _register(client, "purchase_buyer_1")
# 1. Seller creates catalog item and lists it for 200 Credits
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={
"name": "Dragon Armor",
"description": "Legendary chestplate",
"item_type": "avatar_item",
},
)
assert item_resp.status_code == 201, item_resp.text
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={
"catalog_item_id": item_id,
"price": 200,
},
)
assert list_resp.status_code == 201, list_resp.text
listing_id = list_resp.json()["id"]
# 2. Seller cannot purchase own listing
self_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=seller_headers,
)
assert self_buy.status_code == 400
assert "own listing" in self_buy.text
# 3. Buyer attempts purchase without enough credits -> 400
poor_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert poor_buy.status_code == 400
assert "Insufficient funds" in poor_buy.text
# 4. Buyer purchases 500 Credits via payments API
sess_resp = client.post(
"/api/payments/purchase",
headers=buyer_headers,
json={"credits": 500},
)
assert sess_resp.status_code == 201, sess_resp.text
sess_id = sess_resp.json()["session_id"]
conf_resp = client.post(
"/api/payments/confirm",
headers=buyer_headers,
json={"session_id": sess_id},
)
assert conf_resp.status_code == 200, conf_resp.text
assert conf_resp.json()["new_balance"] == 500
# 5. Buyer purchases listing for 200 Credits
buy_headers = dict(buyer_headers)
buy_headers["Idempotency-Key"] = "buy-test-key-1"
buy_resp = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buy_headers,
)
assert buy_resp.status_code == 201, buy_resp.text
purchase_data = buy_resp.json()
assert purchase_data["price"] == 200
assert purchase_data["platform_fee"] == 60
assert purchase_data["seller_net"] == 140
assert purchase_data["status"] == "pending_escrow"
assert purchase_data["buyer_id"] == buyer_id
assert purchase_data["seller_id"] == seller_id
purchase_id = purchase_data["id"]
# 6. Check buyer wallet balance: 500 - 200 = 300
w_resp = client.get("/api/wallet", headers=buyer_headers)
assert w_resp.status_code == 200
assert w_resp.json()["balance"] == 300
# 7. Check buyer has granted item in inventory
inv_resp = client.get("/api/marketplace/items", headers=buyer_headers, params={"mine": "true"})
assert inv_resp.status_code == 200
my_items = inv_resp.json()
assert any(i["name"] == "Dragon Armor" and i["owner_id"] == buyer_id for i in my_items)
# 8. Listing is now sold and cannot be purchased again
re_buy = client.post(
f"/api/marketplace/listings/{listing_id}/purchase",
headers=buyer_headers,
)
assert re_buy.status_code == 400
assert "not active" in re_buy.text
# 9. Seller's creator balance is pending escrow
cb_resp = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp.status_code == 200, cb_resp.text
cb = cb_resp.json()
assert cb["pending"] == 140
assert cb["available"] == 0
# 10. List purchases
p_resp = client.get("/api/marketplace/purchases", headers=buyer_headers)
assert p_resp.status_code == 200
assert any(p["id"] == purchase_id for p in p_resp.json())
# 11. Escrow release
rel_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/release",
headers=seller_headers,
)
assert rel_resp.status_code == 200, rel_resp.text
assert rel_resp.json()["status"] == "completed"
# Check seller's creator balance now shows available = 140
cb_resp2 = client.get("/api/marketplace/creator/balance", headers=seller_headers)
assert cb_resp2.status_code == 200
cb2 = cb_resp2.json()
assert cb2["pending"] == 0
assert cb2["available"] == 140
assert cb2["lifetime_earned"] == 140
def test_marketplace_refund():
client = TestClient(app)
seller_id, seller_headers = _register(client, "refund_seller_1")
buyer_id, buyer_headers = _register(client, "refund_buyer_1")
# Item & Listing
item_resp = client.post(
"/api/marketplace/items",
headers=seller_headers,
json={"name": "Magic Ring", "item_type": "avatar_item"},
)
item_id = item_resp.json()["id"]
list_resp = client.post(
"/api/marketplace/listings",
headers=seller_headers,
json={"catalog_item_id": item_id, "price": 100},
)
listing_id = list_resp.json()["id"]
# Fund buyer
s_resp = client.post("/api/payments/purchase", headers=buyer_headers, json={"credits": 500})
assert s_resp.status_code == 201, s_resp.text
client.post("/api/payments/confirm", headers=buyer_headers, json={"session_id": s_resp.json()["session_id"]})
# Purchase
buy_resp = client.post(f"/api/marketplace/listings/{listing_id}/purchase", headers=buyer_headers)
assert buy_resp.status_code == 201
purchase_id = buy_resp.json()["id"]
# Buyer balance is 400
w1 = client.get("/api/wallet", headers=buyer_headers).json()
assert w1["balance"] == 400
# Seller pending is 70
cb1 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb1["pending"] == 70
# Refund
ref_resp = client.post(
f"/api/marketplace/purchases/{purchase_id}/refund",
headers=seller_headers,
json={"reason": "Buyer dissatisfaction"},
)
assert ref_resp.status_code == 200, ref_resp.text
assert ref_resp.json()["status"] == "refunded"
# Buyer balance refunded back to 500
w2 = client.get("/api/wallet", headers=buyer_headers).json()
assert w2["balance"] == 500
# Seller pending reset to 0
cb2 = client.get("/api/marketplace/creator/balance", headers=seller_headers).json()
assert cb2["pending"] == 0
+123
View File
@@ -0,0 +1,123 @@
"""Integration tests for Premium Purchase and PaymentProvider.
docs/planning/economy.md US-2 (task 575fd14b5360).
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_payments_unauthorized():
client = TestClient(app)
resp = client.post("/api/payments/purchase", json={"credits": 500})
assert resp.status_code == 401
resp = client.post("/api/payments/confirm", json={"session_id": "sess_123"})
assert resp.status_code == 401
def test_purchase_rejects_arbitrary_amounts():
client = TestClient(app)
_, headers = _register(client, "pay_user_1")
# 42 credits is not an allowed pack
resp = client.post("/api/payments/purchase", json={"credits": 42}, headers=headers)
assert resp.status_code == 422
# Negative / 0 credits
resp = client.post("/api/payments/purchase", json={"credits": -100}, headers=headers)
assert resp.status_code == 422
def test_purchase_and_confirm_flow():
client = TestClient(app)
uid, headers = _register(client, "pay_user_2")
# 1. Check initial balance
w_resp = client.get("/api/wallet", headers=headers)
assert w_resp.status_code == 200
assert w_resp.json()["balance"] == 0
# 2. Purchase session for 500 credits
p_resp = client.post(
"/api/payments/purchase",
json={"credits": 500},
headers={"Idempotency-Key": "sess_req_1", **headers},
)
assert p_resp.status_code == 201, p_resp.text
p_data = p_resp.json()
assert p_data["credits"] == 500
assert p_data["session_id"].startswith("sess_")
session_id = p_data["session_id"]
# Balance is still 0 before confirmation
w_resp = client.get("/api/wallet", headers=headers)
assert w_resp.json()["balance"] == 0
# 3. Confirm purchase session
c_resp = client.post(
"/api/payments/confirm",
json={"session_id": session_id},
headers=headers,
)
assert c_resp.status_code == 200, c_resp.text
c_data = c_resp.json()
assert c_data["confirmed"] is True
assert c_data["credits"] == 500
assert c_data["new_balance"] == 500
assert c_data["provider_ref"] != ""
# 4. Idempotent re-confirm must not double-credit
c_resp2 = client.post(
"/api/payments/confirm",
json={"session_id": session_id},
headers=headers,
)
assert c_resp2.status_code == 200
assert c_resp2.json()["new_balance"] == 500
# Check transactions in ledger: exactly 1 transaction
tx_resp = client.get("/api/wallet/transactions", headers=headers)
assert tx_resp.status_code == 200
tx_data = tx_resp.json()
assert tx_data["total"] == 1
assert tx_data["transactions"][0]["amount"] == 500
assert tx_data["transactions"][0]["type"] == "credit_purchase"
def test_confirm_invalid_or_wrong_user_fails():
client = TestClient(app)
uid1, headers1 = _register(client, "pay_user_3a")
uid2, headers2 = _register(client, "pay_user_3b")
# User 1 creates a session
p_resp = client.post("/api/payments/purchase", json={"credits": 100}, headers=headers1)
session_id = p_resp.json()["session_id"]
# User 2 tries to confirm User 1's session -> must fail 400
bad_resp = client.post("/api/payments/confirm", json={"session_id": session_id}, headers=headers2)
assert bad_resp.status_code == 400
# Non-existent session -> must fail 400
bad_resp2 = client.post("/api/payments/confirm", json={"session_id": "sess_nonexistent"}, headers=headers1)
assert bad_resp2.status_code == 400
+406
View File
@@ -0,0 +1,406 @@
"""Integration tests for /api/profile/me and /api/profile/me/favorites.
Hits the real app against the configured Postgres (same contract as the
other integration tests -- migrations applied first).
"""
import asyncio
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.profile import DEFAULT_PRIVACY
def _auth_headers(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
def _me_id(client: TestClient, headers: dict) -> int:
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _make_world(client: TestClient, headers: dict, name: str = "W") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": name, "visibility": "public"})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_asset(client: TestClient, headers: dict, name: str = "A") -> int:
"""Insert an asset row directly via raw SQL.
Not going through POST /api/assets/, and not through the Asset ORM
model, deliberately: Asset.asset_type's ENUM column has no
values_callable, so SQLAlchemy sends the Python enum *name* (e.g.
"IMAGE") to Postgres instead of the lowercase value the DB enum
actually stores ("image"), and both the router and a plain ORM insert
500/fail on it. That's a pre-existing bug unrelated to this task; these
tests only need a real asset row to reference by id, so insert with raw
SQL to sidestep the buggy enum type entirely.
"""
owner_id = _me_id(client, headers)
async def _create() -> int:
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
async with engine.begin() as conn:
result = await conn.execute(
text(
"INSERT INTO assets (name, asset_type, file_path, owner_id, is_public) "
"VALUES (:name, 'image', '', :owner_id, false) RETURNING id"
),
{"name": name, "owner_id": owner_id},
)
asset_id = result.scalar_one()
await engine.dispose()
return asset_id
return asyncio.run(_create())
class TestGetProfile:
def test_new_user_gets_the_default(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_default")
resp = client.get("/api/profile/me", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["bio"] is None
assert body["avatar_asset_id"] is None
assert body["privacy"] == DEFAULT_PRIVACY
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me").status_code == 401
class TestPutProfile:
def test_save_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_save")
asset_id = _make_asset(client, headers)
put = client.put(
"/api/profile/me", headers=headers,
json={"bio": "hello world", "avatar_asset_id": asset_id,
"privacy": {"profile_visibility": "friends"}},
)
assert put.status_code == 200, put.text
body = put.json()
assert body["is_default"] is False
assert body["bio"] == "hello world"
assert body["avatar_asset_id"] == asset_id
assert body["privacy"]["profile_visibility"] == "friends"
# unspecified privacy keys keep their default
assert body["privacy"]["friends_list_visibility"] == "public"
got = client.get("/api/profile/me", headers=headers)
assert got.status_code == 200
assert got.json() == body
def test_partial_update_preserves_other_fields(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_partial")
client.put("/api/profile/me", headers=headers, json={"bio": "first bio"})
put2 = client.put("/api/profile/me", headers=headers, json={"privacy": {"favorites_visibility": "private"}})
assert put2.status_code == 200, put2.text
body = put2.json()
assert body["bio"] == "first bio"
assert body["privacy"]["favorites_visibility"] == "private"
def test_other_user_cannot_edit_my_profile(self):
"""There is no user_id-scoped edit route at all -- PUT /me always
acts on the caller's own JWT identity, so another user's token can
never touch this user's profile."""
client = TestClient(app)
headers_a = _auth_headers(client, "pf_owner")
headers_b = _auth_headers(client, "pf_other")
client.put("/api/profile/me", headers=headers_a, json={"bio": "owner's bio"})
client.put("/api/profile/me", headers=headers_b, json={"bio": "other's bio"})
got_a = client.get("/api/profile/me", headers=headers_a).json()
got_b = client.get("/api/profile/me", headers=headers_b).json()
assert got_a["bio"] == "owner's bio"
assert got_b["bio"] == "other's bio"
assert got_a["user_id"] != got_b["user_id"]
def test_rejects_invalid_avatar_asset_id(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badasset")
resp = client.put("/api/profile/me", headers=headers, json={"avatar_asset_id": 999999})
assert resp.status_code == 422
def test_rejects_over_length_bio(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_longbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 501})
assert resp.status_code == 422
def test_accepts_bio_at_max_length(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_maxbio")
resp = client.put("/api/profile/me", headers=headers, json={"bio": "x" * 500})
assert resp.status_code == 200, resp.text
def test_rejects_unknown_privacy_key(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivkey")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"nonsense_key": "public"}})
assert resp.status_code == 422
def test_rejects_invalid_privacy_value(self):
client = TestClient(app)
headers = _auth_headers(client, "pf_badprivval")
resp = client.put("/api/profile/me", headers=headers, json={"privacy": {"profile_visibility": "nope"}})
assert resp.status_code == 422
def test_requires_auth(self):
client = TestClient(app)
resp = client.put("/api/profile/me", json={"bio": "x"})
assert resp.status_code == 401
class TestFavorites:
def test_new_user_has_no_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_default")
resp = client.get("/api/profile/me/favorites", headers=headers)
assert resp.status_code == 200
assert resp.json() == {"world_ids": []}
def test_replace_then_read_back(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_save")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
put = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, w2]})
assert put.status_code == 200, put.text
assert set(put.json()["world_ids"]) == {w1, w2}
got = client.get("/api/profile/me/favorites", headers=headers)
assert set(got.json()["world_ids"]) == {w1, w2}
def test_replace_is_a_full_replace_not_append(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_replace")
w1 = _make_world(client, headers, "W1")
w2 = _make_world(client, headers, "W2")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
second = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w2]})
assert second.json()["world_ids"] == [w2]
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == [w2]
def test_replace_with_empty_list_clears_favorites(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_clear")
w1 = _make_world(client, headers, "W1")
client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1]})
cleared = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": []})
assert cleared.status_code == 200
assert cleared.json() == {"world_ids": []}
def test_rejects_nonexistent_world_id(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_badworld")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [999999]})
assert resp.status_code == 422
def test_rejects_nonexistent_world_id_mixed_with_valid(self):
client = TestClient(app)
headers = _auth_headers(client, "fav_mixed")
w1 = _make_world(client, headers, "W1")
resp = client.put("/api/profile/me/favorites", headers=headers, json={"world_ids": [w1, 999999]})
assert resp.status_code == 422
# nothing partially applied
got = client.get("/api/profile/me/favorites", headers=headers)
assert got.json()["world_ids"] == []
def test_favorites_isolated_per_user(self):
client = TestClient(app)
headers_a = _auth_headers(client, "fav_owner")
headers_b = _auth_headers(client, "fav_other")
w1 = _make_world(client, headers_a, "W1")
client.put("/api/profile/me/favorites", headers=headers_a, json={"world_ids": [w1]})
got_b = client.get("/api/profile/me/favorites", headers=headers_b)
assert got_b.json()["world_ids"] == []
def test_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/me/favorites").status_code == 401
assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401
class TestPublicProfile:
def test_public_profile_full_view(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_alice")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_bob")
w1 = _make_world(client, h_alice, "AliceWorld")
asset_id = _make_asset(client, h_alice, "AliceAsset")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id},
)
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == alice_id
assert data["username"] == "pub_alice"
assert data["bio"] == "Hi, I am Alice"
assert data["avatar_asset_id"] == asset_id
assert data["is_restricted"] is False
assert len(data["favorites"]) == 1
assert data["favorites"][0]["id"] == w1
assert data["favorites"][0]["name"] == "AliceWorld"
assert "email" not in data
assert "password" not in data
def test_public_profile_not_found(self):
client = TestClient(app)
h_bob = _auth_headers(client, "pub_nf_bob")
resp = client.get("/api/profile/999999", headers=h_bob)
assert resp.status_code == 404
def test_public_profile_requires_auth(self):
client = TestClient(app)
assert client.get("/api/profile/1").status_code == 401
def test_public_profile_private_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_priv_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_priv_b")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}},
)
resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_bob.status_code == 200
data_bob = resp_bob.json()
assert data_bob["is_restricted"] is True
assert data_bob["bio"] is None
assert data_bob["favorites"] == []
assert data_bob["friends"] == []
# Alice viewing herself is not restricted
resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is False
assert resp_alice.json()["bio"] == "Top secret bio"
def test_public_profile_friends_only_visibility(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_fo_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_fo_b")
bob_id = _me_id(client, h_bob)
h_charlie = _auth_headers(client, "pub_fo_c")
client.put(
"/api/profile/me",
headers=h_alice,
json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}},
)
# Charlie is not a friend -> restricted
resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie)
assert resp_c.json()["is_restricted"] is True
assert resp_c.json()["bio"] is None
# Bob becomes friends with Alice
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
# Now Bob sees full profile and friends list contains Bob
resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp_b.json()["is_restricted"] is False
assert resp_b.json()["bio"] == "Friends only bio"
assert any(f["id"] == bob_id for f in resp_b.json()["friends"])
def test_public_profile_blocked_user_gets_restricted(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_blk_a")
alice_id = _me_id(client, h_alice)
h_dan = _auth_headers(client, "pub_blk_d")
dan_id = _me_id(client, h_dan)
client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"})
client.post(f"/api/friends/{dan_id}/block", headers=h_alice)
# Dan viewing Alice gets restricted view
resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan)
assert resp_dan.status_code == 200
assert resp_dan.json()["is_restricted"] is True
assert resp_dan.json()["bio"] is None
# Alice viewing Dan gets restricted view
resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice)
assert resp_alice.status_code == 200
assert resp_alice.json()["is_restricted"] is True
def test_public_profile_selective_favorites_and_friends_privacy(self):
client = TestClient(app)
h_alice = _auth_headers(client, "pub_sec_a")
alice_id = _me_id(client, h_alice)
h_bob = _auth_headers(client, "pub_sec_b")
bob_id = _me_id(client, h_bob)
w1 = _make_world(client, h_alice, "WorldSec")
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
req_id = req.json()["id"]
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
client.put(
"/api/profile/me",
headers=h_alice,
json={
"bio": "Bio is public",
"privacy": {
"profile_visibility": "public",
"favorites_visibility": "private",
"friends_list_visibility": "private",
},
},
)
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
assert resp.status_code == 200
data = resp.json()
assert data["bio"] == "Bio is public"
assert data["favorites"] == []
assert data["friends"] == []
+68
View File
@@ -1,6 +1,7 @@
"""The auth endpoints are rate-limited per client IP."""
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from app import rate_limit
@@ -54,3 +55,70 @@ def test_limiter_window_is_per_key():
with pytest.raises(Exception):
limiter.check("a")
limiter.check("b") # different key, unaffected
def _make_named_limit_app():
"""A tiny standalone app exercising two independent named limits, so
these tests don't depend on any sibling endpoint existing yet."""
limit_a = rate_limit.rate_limit("test_named_a", max_hits=2, window_seconds=60)
limit_b = rate_limit.rate_limit("test_named_b", max_hits=2, window_seconds=60)
app = FastAPI()
@app.post("/a", dependencies=[Depends(limit_a)])
def _a():
return {"ok": True}
@app.post("/b", dependencies=[Depends(limit_b)])
def _b():
return {"ok": True}
return TestClient(app)
def test_named_limits_do_not_share_counters():
client = _make_named_limit_app()
# Burn out limit "a" entirely.
assert client.post("/a").status_code == 200
assert client.post("/a").status_code == 200
r = client.post("/a")
assert r.status_code == 429
assert "Retry-After" in r.headers
# Limit "b" is untouched -- same process, same client IP, different name.
assert client.post("/b").status_code == 200
assert client.post("/b").status_code == 200
assert client.post("/b").status_code == 429
def test_named_limit_returns_429_with_retry_after_once_exhausted():
client = _make_named_limit_app()
client.post("/a")
client.post("/a")
r = client.post("/a")
assert r.status_code == 429
retry_after = r.headers["Retry-After"]
assert int(retry_after) >= 1
def test_named_limit_is_a_noop_when_rate_limiting_disabled():
prev = settings.RATE_LIMIT_ENABLED
settings.RATE_LIMIT_ENABLED = False
try:
client = _make_named_limit_app()
codes = [client.post("/a").status_code for _ in range(10)]
assert codes == [200] * 10
finally:
settings.RATE_LIMIT_ENABLED = prev
def test_reset_all_clears_named_limiters_too():
client = _make_named_limit_app()
client.post("/a")
client.post("/a")
assert client.post("/a").status_code == 429
rate_limit.reset_all()
assert client.post("/a").status_code == 200
+317
View File
@@ -0,0 +1,317 @@
"""Integration tests for the friends/follow/block API:
- POST /api/friends/requests
- GET /api/friends/requests
- POST /api/friends/requests/{id}/accept
- POST /api/friends/requests/{id}/decline
- DELETE /api/friends/{user_id}
- POST /api/friends/{user_id}/follow
- DELETE /api/friends/{user_id}/follow
- POST /api/friends/{user_id}/block
- DELETE /api/friends/{user_id}/block
- GET /api/friends
- GET /api/friends/following
- GET /api/friends/blocked
app/routers/social_friends.py, built on the merged models in
app/models/social.py (Friendship/Follow/Block).
"""
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "id": me.json()["id"]}
class TestFriendRequestFlow:
def test_send_list_accept_makes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_flow_a")
b = _register(client, "fr_flow_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp.status_code == 201, resp.text
request_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
incoming = client.get("/api/friends/requests", headers=b["headers"])
assert incoming.status_code == 200
ids = [r["id"] for r in incoming.json()["requests"]]
assert request_id in ids
# Requester doesn't see it in their own incoming list.
a_incoming = client.get("/api/friends/requests", headers=a["headers"])
assert a_incoming.json()["requests"] == []
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
assert accept.status_code == 200, accept.text
assert accept.json()["status"] == "accepted"
# Mutual: shows up in both users' friend lists.
a_friends = client.get("/api/friends", headers=a["headers"]).json()["friends"]
b_friends = client.get("/api/friends", headers=b["headers"]).json()["friends"]
assert any(u["id"] == b["id"] for u in a_friends)
assert any(u["id"] == a["id"] for u in b_friends)
def test_decline_removes_request_and_allows_re_request(self):
client = TestClient(app)
a = _register(client, "fr_decl_a")
b = _register(client, "fr_decl_b")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=b["headers"])
assert decline.status_code == 200, decline.text
assert client.get("/api/friends/requests", headers=b["headers"]).json()["requests"] == []
# A can send a new request now that the old one is gone.
resp2 = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert resp2.status_code == 201, resp2.text
def test_accept_or_decline_of_others_request_is_404_not_403(self):
client = TestClient(app)
a = _register(client, "fr_enum_a")
b = _register(client, "fr_enum_b")
c = _register(client, "fr_enum_c")
resp = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
request_id = resp.json()["id"]
# c is neither requester nor addressee -- must not be able to act on it,
# and must not be able to distinguish "not yours" from "doesn't exist".
accept = client.post(f"/api/friends/requests/{request_id}/accept", headers=c["headers"])
assert accept.status_code == 404
decline = client.post(f"/api/friends/requests/{request_id}/decline", headers=c["headers"])
assert decline.status_code == 404
nonexistent = client.post("/api/friends/requests/999999999/accept", headers=c["headers"])
assert nonexistent.status_code == 404
def test_unfriend_removes_mutual_friendship(self):
client = TestClient(app)
a = _register(client, "fr_unfr_a")
b = _register(client, "fr_unfr_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
unfriend = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert unfriend.status_code == 200, unfriend.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
# Idempotency / no dangling state: unfriending again is a clean 404, not 500.
again = client.delete(f"/api/friends/{a['id']}", headers=b["headers"])
assert again.status_code == 404
class TestSelfAndDuplicateRejection:
def test_self_request_rejected(self):
client = TestClient(app)
a = _register(client, "self_req_a")
resp = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=a["headers"])
assert resp.status_code == 400
def test_self_follow_rejected(self):
client = TestClient(app)
a = _register(client, "self_follow_a")
resp = client.post(f"/api/friends/{a['id']}/follow", headers=a["headers"])
assert resp.status_code == 400
def test_self_block_rejected(self):
client = TestClient(app)
a = _register(client, "self_block_a")
resp = client.post(f"/api/friends/{a['id']}/block", headers=a["headers"])
assert resp.status_code == 400
def test_duplicate_friend_request_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_req_a")
b = _register(client, "dup_req_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
dup = client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"])
assert dup.status_code == 409
def test_reverse_duplicate_friend_request_is_conflict(self):
"""b requesting a while a's request to b is still pending is treated
as a duplicate of the same pair, not a second independent request."""
client = TestClient(app)
a = _register(client, "dup_rev_a")
b = _register(client, "dup_rev_b")
assert client.post("/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]).status_code == 201
reverse = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert reverse.status_code == 409
def test_duplicate_follow_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_follow_a")
b = _register(client, "dup_follow_b")
assert client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert dup.status_code == 409
def test_duplicate_block_is_conflict_not_500(self):
client = TestClient(app)
a = _register(client, "dup_block_a")
b = _register(client, "dup_block_b")
assert client.post(f"/api/friends/{b['id']}/block", headers=a["headers"]).status_code == 201
dup = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert dup.status_code == 409
def test_friend_request_to_nonexistent_user_is_404(self):
client = TestClient(app)
a = _register(client, "req_missing_a")
resp = client.post("/api/friends/requests", json={"user_id": 999999999}, headers=a["headers"])
assert resp.status_code == 404
class TestFollow:
def test_follow_unfollow_and_list(self):
client = TestClient(app)
a = _register(client, "follow_flow_a")
b = _register(client, "follow_flow_b")
follow = client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert follow.status_code == 201, follow.text
following = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert any(u["id"] == b["id"] for u in following)
# One-way: b does not follow a.
b_following = client.get("/api/friends/following", headers=b["headers"]).json()["following"]
assert b_following == []
unfollow = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert unfollow.status_code == 200, unfollow.text
following_after = client.get("/api/friends/following", headers=a["headers"]).json()["following"]
assert following_after == []
# Unfollowing again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/follow", headers=a["headers"])
assert again.status_code == 404
def test_follow_requires_auth(self):
client = TestClient(app)
b = _register(client, "follow_noauth_b")
resp = client.post(f"/api/friends/{b['id']}/follow", json={})
assert resp.status_code == 401
class TestBlock:
def test_block_clears_existing_friendship_and_follow(self):
client = TestClient(app)
a = _register(client, "block_clear_a")
b = _register(client, "block_clear_b")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert any(
u["id"] == b["id"] for u in client.get("/api/friends", headers=a["headers"]).json()["friends"]
)
assert any(
u["id"] == a["id"]
for u in client.get("/api/friends/following", headers=b["headers"]).json()["following"]
)
block = client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert block.status_code == 201, block.text
assert client.get("/api/friends", headers=a["headers"]).json()["friends"] == []
assert client.get("/api/friends", headers=b["headers"]).json()["friends"] == []
assert client.get("/api/friends/following", headers=b["headers"]).json()["following"] == []
blocked_list = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert any(u["id"] == b["id"] for u in blocked_list)
def test_block_prevents_new_friend_request_and_follow(self):
client = TestClient(app)
a = _register(client, "block_prevent_a")
b = _register(client, "block_prevent_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
req = client.post("/api/friends/requests", json={"user_id": a["id"]}, headers=b["headers"])
assert req.status_code == 403
follow = client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"])
assert follow.status_code == 403
def test_unblock_removes_block(self):
client = TestClient(app)
a = _register(client, "unblock_a")
b = _register(client, "unblock_b")
client.post(f"/api/friends/{b['id']}/block", headers=a["headers"])
unblock = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert unblock.status_code == 200, unblock.text
assert client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"] == []
# Idempotency: unblocking again is a clean 404, not 500.
again = client.delete(f"/api/friends/{b['id']}/block", headers=a["headers"])
assert again.status_code == 404
# After unblock, a fresh follow/request works again.
assert client.post(f"/api/friends/{a['id']}/follow", headers=b["headers"]).status_code == 201
class TestDataIsolation:
def test_list_endpoints_are_scoped_to_caller(self):
"""A third party's friends/following/blocked lists must never leak
into another user's response."""
client = TestClient(app)
a = _register(client, "iso_a")
b = _register(client, "iso_b")
c = _register(client, "iso_c")
request_id = client.post(
"/api/friends/requests", json={"user_id": b["id"]}, headers=a["headers"]
).json()["id"]
client.post(f"/api/friends/requests/{request_id}/accept", headers=b["headers"])
client.post(f"/api/friends/{b['id']}/follow", headers=a["headers"])
client.post(f"/api/friends/{b['id']}/block", headers=c["headers"])
c_friends = client.get("/api/friends", headers=c["headers"]).json()["friends"]
c_following = client.get("/api/friends/following", headers=c["headers"]).json()["following"]
assert c_friends == []
assert c_following == []
# c's block of b must not appear in a's or b's blocked list.
a_blocked = client.get("/api/friends/blocked", headers=a["headers"]).json()["blocked"]
assert a_blocked == []
def test_requires_auth_on_all_endpoints(self):
client = TestClient(app)
assert client.get("/api/friends").status_code == 401
assert client.get("/api/friends/requests").status_code == 401
assert client.get("/api/friends/following").status_code == 401
assert client.get("/api/friends/blocked").status_code == 401
assert client.post("/api/friends/requests", json={"user_id": 1}).status_code == 401
+135
View File
@@ -0,0 +1,135 @@
"""Integration tests for Wallet service and API endpoints.
docs/planning/economy.md US-1 (task 909e3e9a59af).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.wallet import TransactionType
from app.services.wallet import InsufficientFundsError, InvalidAmountError, wallet_service
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
def test_wallet_unauthorized():
client = TestClient(app)
resp = client.get("/api/wallet")
assert resp.status_code == 401
resp = client.get("/api/wallet/transactions")
assert resp.status_code == 401
def test_get_wallet_lazy_creation():
client = TestClient(app)
uid, headers = _register(client, "wallet_api_user_1")
resp = client.get("/api/wallet", headers=headers)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["user_id"] == uid
assert data["balance"] == 0
assert data["id"] is not None
@pytest.mark.asyncio
async def test_wallet_service_credit_and_debit_flow():
client = TestClient(app)
uid, headers = _register(client, "wallet_api_user_2")
engine, sf = _session_factory()
try:
async with sf() as session:
# 1. Credit
wallet, tx = await wallet_service.credit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.credit_purchase,
reference_type="test_ref",
reference_id="ref_1",
idempotency_key="credit_idemp_1",
)
assert wallet.balance == 1000
assert tx.amount == 1000
assert tx.balance_after == 1000
# 2. Idempotent re-credit does not increase balance
wallet2, tx2 = await wallet_service.credit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.credit_purchase,
idempotency_key="credit_idemp_1",
)
assert wallet2.balance == 1000
assert tx2.id == tx.id
# 3. Debit
wallet3, tx3 = await wallet_service.debit(
session,
user_id=uid,
amount=400,
tx_type=TransactionType.marketplace_purchase,
reference_type="purchase",
reference_id="item_42",
idempotency_key="debit_idemp_1",
)
assert wallet3.balance == 600
assert tx3.amount == -400
assert tx3.balance_after == 600
# 4. Over-debit raises InsufficientFundsError
with pytest.raises(InsufficientFundsError):
await wallet_service.debit(
session,
user_id=uid,
amount=1000,
tx_type=TransactionType.marketplace_purchase,
)
# Balance remains 600
w_check = await wallet_service.get_or_create_wallet(session, uid)
assert w_check.balance == 600
finally:
await engine.dispose()
# Verify via API
resp = client.get("/api/wallet", headers=headers)
assert resp.status_code == 200
assert resp.json()["balance"] == 600
resp_tx = client.get("/api/wallet/transactions", headers=headers)
assert resp_tx.status_code == 200
tx_data = resp_tx.json()
assert tx_data["total"] == 2
assert len(tx_data["transactions"]) == 2
# Newest first: debit (-400) first, then credit (+1000)
assert tx_data["transactions"][0]["amount"] == -400
assert tx_data["transactions"][1]["amount"] == 1000
+141
View File
@@ -0,0 +1,141 @@
"""Model-level integration tests for Wallets and Transaction ledger.
docs/planning/economy.md US-1 (task 8343bc0a730f).
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import settings
from app.main import app
from app.models.wallet import Wallet, Transaction, TransactionType
from app.models.user import User
def _register(client: TestClient, username: str) -> int:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"]
def _session_factory():
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
return engine, async_sessionmaker(engine, expire_on_commit=False)
@pytest.mark.asyncio
async def test_wallet_creation_and_defaults():
client = TestClient(app)
uid = _register(client, "wallet_user_1")
engine, sf = _session_factory()
try:
async with sf() as session:
wallet = Wallet(user_id=uid)
session.add(wallet)
await session.commit()
await session.refresh(wallet)
assert wallet.id is not None
assert wallet.user_id == uid
assert wallet.balance == 0
assert wallet.created_at is not None
assert wallet.updated_at is not None
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_wallet_unique_user_id():
client = TestClient(app)
uid = _register(client, "wallet_user_2")
engine, sf = _session_factory()
try:
async with sf() as session:
w1 = Wallet(user_id=uid)
session.add(w1)
await session.commit()
async with sf() as session:
w2 = Wallet(user_id=uid)
session.add(w2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_wallet_non_negative_balance_constraint():
client = TestClient(app)
uid = _register(client, "wallet_user_3")
engine, sf = _session_factory()
try:
async with sf() as session:
w = Wallet(user_id=uid, balance=-50)
session.add(w)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_transaction_ledger_and_idempotency():
client = TestClient(app)
uid = _register(client, "wallet_user_4")
engine, sf = _session_factory()
try:
async with sf() as session:
wallet = Wallet(user_id=uid, balance=500)
session.add(wallet)
await session.commit()
await session.refresh(wallet)
tx1 = Transaction(
wallet_id=wallet.id,
type=TransactionType.credit_purchase,
amount=500,
balance_after=500,
reference_type="purchase_session",
reference_id="sess_123",
idempotency_key="idemp_key_001",
metadata_json={"payment_method": "simulated"},
)
session.add(tx1)
await session.commit()
await session.refresh(tx1)
assert tx1.id is not None
assert tx1.type == TransactionType.credit_purchase
assert tx1.amount == 500
assert tx1.balance_after == 500
# Duplicate idempotency_key must raise IntegrityError
async with sf() as session:
tx2 = Transaction(
wallet_id=wallet.id,
type=TransactionType.credit_purchase,
amount=500,
balance_after=1000,
idempotency_key="idemp_key_001",
)
session.add(tx2)
with pytest.raises(IntegrityError):
await session.commit()
finally:
await engine.dispose()
+222
View File
@@ -0,0 +1,222 @@
"""Integration tests for real-time WebSocket gateway:
- Authentication (query param + first auth frame)
- Rejection of unauthenticated clients
- Presence on connect / disconnect
- Subscriptions to world chat channels and DM conversations
- Real-time event delivery from REST endpoints
- Reconnect and ping/pong
"""
import asyncio
import time
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.main import app
from app.services.presence import presence_store
def _register(client: TestClient, username: str) -> dict:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return {"headers": headers, "token": token, "id": me.json()["id"], "username": username}
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
r = client.post("/api/worlds/", headers=headers, json={"name": "WSWorld", "visibility": visibility})
assert r.status_code == 201, r.text
return r.json()["id"]
def _make_friends(client: TestClient, user_a: dict, user_b: dict) -> None:
req = client.post("/api/friends/requests", json={"user_id": user_b["id"]}, headers=user_a["headers"])
assert req.status_code == 201, req.text
req_id = req.json()["id"]
acc = client.post(f"/api/friends/requests/{req_id}/accept", headers=user_b["headers"])
assert acc.status_code == 200, acc.text
class TestWebSocketAuth:
def test_unauthenticated_connection_rejected(self):
client = TestClient(app)
# Invalid query param token rejected
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/api/ws?token=invalid_token"):
pass
assert exc.value.code == 1008
# First frame with invalid token rejected
with pytest.raises(WebSocketDisconnect) as exc2:
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "auth", "token": "invalid_token"})
ws.receive_json()
ws.receive_json()
assert exc2.value.code == 1008
def test_auth_via_query_param(self):
client = TestClient(app)
alice = _register(client, "ws_auth_alice")
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
msg = ws.receive_json()
assert msg["type"] == "auth_ok"
assert msg["user_id"] == alice["id"]
assert msg["username"] == "ws_auth_alice"
def test_auth_via_first_message(self):
client = TestClient(app)
bob = _register(client, "ws_auth_bob")
with client.websocket_connect("/api/ws") as ws:
ws.send_json({"type": "auth", "token": bob["token"]})
msg = ws.receive_json()
assert msg["type"] == "auth_ok"
assert msg["user_id"] == bob["id"]
assert msg["username"] == "ws_auth_bob"
class TestWebSocketPresence:
def test_presence_on_connect_and_disconnect(self):
import threading
client = TestClient(app)
alice = _register(client, "ws_pres_alice")
bob = _register(client, "ws_pres_bob")
_make_friends(client, alice, bob)
# Initially both offline
assert not asyncio.run(presence_store.is_online(alice["id"]))
# Bob connects and listens for presence events
bob_events = []
def run_alice():
time.sleep(0.2)
c_alice = TestClient(app)
with c_alice.websocket_connect(f"/api/ws?token={alice['token']}") as alice_ws:
assert alice_ws.receive_json()["type"] == "auth_ok"
time.sleep(0.3)
alice_ws.close()
time.sleep(0.3)
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
alice_thread = threading.Thread(target=run_alice)
alice_thread.start()
# Bob receives Alice's online event
online_event = bob_ws.receive_json()
assert online_event["type"] == "presence"
assert online_event["user_id"] == alice["id"]
assert online_event["is_online"] is True
alice_thread.join()
# Bob receives Alice's offline event
offline_event = bob_ws.receive_json()
assert offline_event["type"] == "presence"
assert offline_event["user_id"] == alice["id"]
assert offline_event["is_online"] is False
assert not asyncio.run(presence_store.is_online(alice["id"]))
class TestWebSocketChatDelivery:
def test_world_chat_subscription_and_delivery(self):
client = TestClient(app)
alice = _register(client, "ws_chat_alice")
bob = _register(client, "ws_chat_bob")
world_id = _make_world(client, alice["headers"])
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
# Bob subscribes to world
bob_ws.send_json({"type": "subscribe_world", "world_id": world_id})
sub_ack = bob_ws.receive_json()
assert sub_ack["type"] == "subscribed"
assert sub_ack["channel"] == "world"
assert sub_ack["id"] == world_id
# Alice posts message to world via REST
rest_resp = client.post(
f"/api/chat/world/{world_id}/messages",
headers=alice["headers"],
json={"body": "Hello real-time world!"},
)
assert rest_resp.status_code == 201, rest_resp.text
msg_id = rest_resp.json()["id"]
# Bob receives message in real time over WebSocket
ws_msg = bob_ws.receive_json()
assert ws_msg["type"] == "world_chat"
assert ws_msg["world_id"] == world_id
assert ws_msg["message"]["id"] == msg_id
assert ws_msg["message"]["body"] == "Hello real-time world!"
assert ws_msg["message"]["author"]["username"] == "ws_chat_alice"
def test_dm_chat_subscription_and_delivery(self):
client = TestClient(app)
alice = _register(client, "ws_dm_alice")
bob = _register(client, "ws_dm_bob")
# Initial DM to create conversation
init_dm = client.post(
f"/api/chat/dm/{bob['id']}/messages",
headers=alice["headers"],
json={"body": "First DM to initiate"},
)
assert init_dm.status_code == 201, init_dm.text
conv_id = init_dm.json()["conversation_id"]
with client.websocket_connect(f"/api/ws?token={bob['token']}") as bob_ws:
assert bob_ws.receive_json()["type"] == "auth_ok"
# Bob subscribes to this conversation
bob_ws.send_json({"type": "subscribe_dm", "conversation_id": conv_id})
sub_ack = bob_ws.receive_json()
assert sub_ack["type"] == "subscribed"
assert sub_ack["channel"] == "dm"
assert sub_ack["id"] == conv_id
# Alice sends another DM via REST
rest_dm = client.post(
f"/api/chat/dm/{bob['id']}/messages",
headers=alice["headers"],
json={"body": "Real-time DM ping!"},
)
assert rest_dm.status_code == 201, rest_dm.text
# Bob receives real-time DM over WebSocket
ws_dm = bob_ws.receive_json()
assert ws_dm["type"] == "dm_chat"
assert ws_dm["conversation_id"] == conv_id
assert ws_dm["message"]["body"] == "Real-time DM ping!"
class TestWebSocketPingAndHeartbeat:
def test_ping_pong_and_heartbeat(self):
client = TestClient(app)
alice = _register(client, "ws_ping_alice")
with client.websocket_connect(f"/api/ws?token={alice['token']}") as ws:
assert ws.receive_json()["type"] == "auth_ok"
ws.send_json({"type": "ping"})
assert ws.receive_json()["type"] == "pong"
ws.send_json({"type": "heartbeat"})
assert ws.receive_json()["type"] == "heartbeat_ack"
+202
View File
@@ -0,0 +1,202 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/chat_ui.gd" id="1_cu"]
[node name="ChatUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_cu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Chat & Messaging"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="WorldChat" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="ControlsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="WorldPrefix" type="Label" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
layout_mode = 2
text = "World ID:"
[node name="WorldIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
text = "1"
[node name="JoinChatButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"]
custom_minimum_size = Vector2(110, 32)
layout_mode = 2
text = "Switch Channel"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
size_flags_vertical = 3
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/WorldChat/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Type a message in world chat..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Send"
[node name="DirectMessages" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="StartHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TargetUserEdit" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to start conversation..."
[node name="StartDmButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
text = "Start Direct Chat"
[node name="HSplit" type="HSplitContainer" parent="VBoxContainer/TabContainer/DirectMessages"]
layout_mode = 2
size_flags_vertical = 3
split_offset = 260
[node name="ConversationsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_stretch_ratio = 0.35
theme_override_constants/separation = 6
[node name="ConvHeader" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Conversations"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="ConvList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="ChatVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="DmHeaderLabel" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Select a conversation"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
size_flags_vertical = 3
[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Type a direct message..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Send"
[node name="ReportDialog" type="ConfirmationDialog" parent="."]
title = "Report Inappropriate Message"
size = Vector2i(420, 200)
[node name="VBox" type="VBoxContainer" parent="ReportDialog"]
offset_left = 8.0
offset_top = 8.0
offset_right = 412.0
offset_bottom = 151.0
theme_override_constants/separation = 8
[node name="DetailsLabel" type="Label" parent="ReportDialog/VBox"]
layout_mode = 2
autowrap_mode = 2
[node name="ReasonPrompt" type="Label" parent="ReportDialog/VBox"]
layout_mode = 2
text = "Reason for report:"
[node name="ReasonEdit" type="LineEdit" parent="ReportDialog/VBox"]
layout_mode = 2
text = "Inappropriate content"
+174
View File
@@ -0,0 +1,174 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/friends_presence_ui.gd" id="1_fp"]
[node name="FriendsPresenceUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_fp")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Friends & Social"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="MyStatusHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="StatusPrefix" type="Label" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
text = "My Status:"
[node name="StatusLineEdit" type="LineEdit" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "What are you doing? (e.g. Exploring worlds...)"
[node name="SetStatusButton" type="Button" parent="VBoxContainer/MyStatusHBox"]
custom_minimum_size = Vector2(110, 32)
layout_mode = 2
text = "Set Status"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Friends" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Friends"]
layout_mode = 2
size_flags_vertical = 3
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Friends/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Requests" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to add..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
text = "Send Request"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
size_flags_vertical = 3
[node name="RequestsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Requests/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Following" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="FollowHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to follow..."
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Follow"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
size_flags_vertical = 3
[node name="FollowingList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Following/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Blocked" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 3
[node name="BlockHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to block..."
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Block"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
size_flags_vertical = 3
[node name="BlockedList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Blocked/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
+179
View File
@@ -0,0 +1,179 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/groups_ui.gd" id="1_gu"]
[node name="GroupsUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_gu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Groups & Communities"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Browse" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="FilterHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MyGroupsCheck" type="CheckBox" parent="VBoxContainer/TabContainer/Browse/FilterHBox"]
layout_mode = 2
text = "Show only my groups"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
size_flags_vertical = 3
[node name="GroupsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Browse/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Create" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="FormVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/Create"]
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
size_flags_horizontal = 4
theme_override_constants/separation = 12
[node name="NamePrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Name:"
[node name="NameEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
placeholder_text = "Enter unique group name..."
[node name="DescPrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Description:"
[node name="DescEdit" type="TextEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
placeholder_text = "Describe the goals and community of your group..."
[node name="CreateButton" type="Button" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
size_flags_horizontal = 4
text = "Create Group"
[node name="GroupDetails" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/GroupDetails"]
layout_mode = 2
size_flags_vertical = 3
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="GroupNameLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Select a Group"
[node name="MemberCountLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "0 Members"
[node name="GroupDescLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
autowrap_mode = 2
text = "No description."
[node name="ActionsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="JoinButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Join Group"
[node name="LeaveButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Leave Group"
[node name="DeleteButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(110, 36)
layout_mode = 2
text = "Delete Group"
[node name="MembersVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MembersHeader" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Members & Roles"
[node name="MembersList" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_constants/separation = 6
+215
View File
@@ -0,0 +1,215 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/profile_ui.gd" id="1_pu"]
[node name="ProfileUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_pu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Profile"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="LookupHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="LookupPrefix" type="Label" parent="VBoxContainer/LookupHBox"]
layout_mode = 2
text = "Lookup User ID:"
[node name="UserIdEdit" type="LineEdit" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
placeholder_text = "e.g. 2"
[node name="ViewUserButton" type="Button" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "View"
[node name="Spacer" type="Control" parent="VBoxContainer/LookupHBox"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ViewMyProfileButton" type="Button" parent="VBoxContainer/LookupHBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
text = "My Own Profile"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="ContentVBox" type="VBoxContainer" parent="VBoxContainer/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="CardPanel" type="PanelContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
[node name="Margin" type="MarginContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel"]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 16
[node name="HBox" type="HBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="AvatarBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
custom_minimum_size = Vector2(120, 120)
layout_mode = 2
alignment = 1
[node name="AvatarLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox"]
layout_mode = 2
text = "Avatar"
horizontal_alignment = 1
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 6
[node name="UsernameLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Username"
[node name="UserIdLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
text = "User ID: --"
[node name="BioPrompt" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
layout_mode = 2
text = "Bio:"
[node name="BioEdit" type="TextEdit" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
custom_minimum_size = Vector2(0, 80)
layout_mode = 2
placeholder_text = "Tell the world about yourself..."
[node name="SaveBioButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
size_flags_horizontal = 0
text = "Save Bio"
[node name="PrivacyVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="PrivacyHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Privacy Settings"
[node name="Grid" type="GridContainer" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
layout_mode = 2
columns = 2
theme_override_constants/h_separation = 16
theme_override_constants/v_separation = 8
[node name="ProfileVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Profile Visibility:"
[node name="ProfileVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="FriendsVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Friends List Visibility:"
[node name="FriendsVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="FavoritesVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
text = "Favorites Visibility:"
[node name="FavoritesVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"]
layout_mode = 2
[node name="SavePrivacyButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"]
custom_minimum_size = Vector2(140, 32)
layout_mode = 2
size_flags_horizontal = 0
text = "Save Privacy"
[node name="FavoritesVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FavHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Favorite Experiences"
[node name="FavoritesStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
text = "No favorite experiences yet."
[node name="FavoritesList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="FriendsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FriendsHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Friends"
[node name="FriendsStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
text = "No friends listed."
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"]
layout_mode = 2
theme_override_constants/separation = 4
+199
View File
@@ -82,6 +82,9 @@ var base_url: String:
## Request timeout in seconds. 0 disables the timeout.
var timeout_seconds: float = 10.0
## Real-time WebSocket gateway client instance
var ws: WsGatewayClient = null
func _ready() -> void:
# Environment variable override (e.g. for headless smoke tests against a
@@ -96,6 +99,24 @@ func _ready() -> void:
"http://127.0.0.1:8000"
)).rstrip("/")
ws = WsGatewayClient.new()
ws.name = "WsGatewayClient"
add_child(ws)
## Connect to the WebSocket gateway using the current base_url and stored token.
func connect_ws_gateway(token: String = "") -> Error:
if ws == null:
return ERR_UNCONFIGURED
var tok := token if token != "" else _access_token
return ws.connect_gateway(_base_url, tok)
## Disconnect from the WebSocket gateway.
func disconnect_ws_gateway() -> void:
if ws != null:
ws.disconnect_gateway()
# ---------------------------------------------------------------------------
# Core request primitive
@@ -255,6 +276,184 @@ func save_avatar_loadout(loadout: Dictionary) -> Dictionary:
return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true)
# ---------------------------------------------------------------------------
# Social Features: Friends, Follow & Block
# ---------------------------------------------------------------------------
## Send a friend request to target user_id.
func send_friend_request(user_id: int) -> Dictionary:
return await SocialApiClient.send_friend_request(self, user_id)
## Get pending incoming friend requests.
func get_friend_requests() -> Dictionary:
return await SocialApiClient.get_friend_requests(self)
## Accept an incoming friend request.
func accept_friend_request(request_id: int) -> Dictionary:
return await SocialApiClient.accept_friend_request(self, request_id)
## Decline an incoming friend request.
func decline_friend_request(request_id: int) -> Dictionary:
return await SocialApiClient.decline_friend_request(self, request_id)
## Remove an existing friend.
func unfriend(user_id: int) -> Dictionary:
return await SocialApiClient.unfriend(self, user_id)
## Get list of mutual friends.
func get_friends() -> Dictionary:
return await SocialApiClient.get_friends(self)
## Follow a user.
func follow_user(user_id: int) -> Dictionary:
return await SocialApiClient.follow_user(self, user_id)
## Unfollow a user.
func unfollow_user(user_id: int) -> Dictionary:
return await SocialApiClient.unfollow_user(self, user_id)
## Get list of users followed by current user.
func get_following() -> Dictionary:
return await SocialApiClient.get_following(self)
## Block a user.
func block_user(user_id: int) -> Dictionary:
return await SocialApiClient.block_user(self, user_id)
## Unblock a user.
func unblock_user(user_id: int) -> Dictionary:
return await SocialApiClient.unblock_user(self, user_id)
## Get list of blocked users.
func get_blocked() -> Dictionary:
return await SocialApiClient.get_blocked(self)
# ---------------------------------------------------------------------------
# Social Features: Presence
# ---------------------------------------------------------------------------
## Send presence heartbeat with optional custom status.
func send_presence_heartbeat(custom_status: String = "") -> Dictionary:
return await SocialApiClient.send_presence_heartbeat(self, custom_status)
## Get presence status of mutual friends.
func get_friends_presence() -> Dictionary:
return await SocialApiClient.get_friends_presence(self)
## Get presence status of a specific user.
func get_user_presence(user_id: int) -> Dictionary:
return await SocialApiClient.get_user_presence(self, user_id)
# ---------------------------------------------------------------------------
# Social Features: Profiles
# ---------------------------------------------------------------------------
## Get current user's own profile.
func get_my_profile() -> Dictionary:
return await SocialApiClient.get_my_profile(self)
## Update current user's bio, avatar asset, or privacy settings.
func update_my_profile(bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary:
return await SocialApiClient.update_my_profile(self, bio, avatar_asset_id, privacy)
## Get current user's favorited world IDs.
func get_my_favorites() -> Dictionary:
return await SocialApiClient.get_my_favorites(self)
## Replace current user's favorited world IDs.
func replace_my_favorites(world_ids: Array) -> Dictionary:
return await SocialApiClient.replace_my_favorites(self, world_ids)
## Get public profile of another user.
func get_public_profile(user_id: int) -> Dictionary:
return await SocialApiClient.get_public_profile(self, user_id)
# ---------------------------------------------------------------------------
# Social Features: Groups
# ---------------------------------------------------------------------------
## Create a new group.
func create_group(name: String, description: String = "") -> Dictionary:
return await SocialApiClient.create_group(self, name, description)
## List groups.
func list_groups(page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary:
return await SocialApiClient.list_groups(self, page, page_size, mine)
## Get group details and member list.
func get_group(group_id: int) -> Dictionary:
return await SocialApiClient.get_group(self, group_id)
## Update group name or description.
func update_group(group_id: int, name: Variant = null, description: Variant = null) -> Dictionary:
return await SocialApiClient.update_group(self, group_id, name, description)
## Delete a group (owner only).
func delete_group(group_id: int) -> Dictionary:
return await SocialApiClient.delete_group(self, group_id)
## Join a group.
func join_group(group_id: int) -> Dictionary:
return await SocialApiClient.join_group(self, group_id)
## Leave a group.
func leave_group(group_id: int) -> Dictionary:
return await SocialApiClient.leave_group(self, group_id)
## Set a member's role in a group.
func set_group_member_role(group_id: int, user_id: int, role: String) -> Dictionary:
return await SocialApiClient.set_group_member_role(self, group_id, user_id, role)
## Transfer ownership of a group.
func transfer_group_ownership(group_id: int, new_owner_id: int) -> Dictionary:
return await SocialApiClient.transfer_group_ownership(self, group_id, new_owner_id)
# ---------------------------------------------------------------------------
# Social Features: Chat & Moderation
# ---------------------------------------------------------------------------
## List DM conversations for the authenticated user.
func get_dm_conversations() -> Dictionary:
return await SocialApiClient.get_dm_conversations(self)
## Start or get existing DM conversation with target user.
func start_dm_conversation(target_user_id: int) -> Dictionary:
return await SocialApiClient.start_dm_conversation(self, target_user_id)
## Get DM messages for a conversation.
func get_dm_messages(conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
return await SocialApiClient.get_dm_messages(self, conversation_id, before_id, limit)
## Send a DM message to a conversation.
func send_dm_message(conversation_id: int, content: String) -> Dictionary:
return await SocialApiClient.send_dm_message(self, conversation_id, content)
## Get messages in world chat.
func get_world_chat_messages(world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
return await SocialApiClient.get_world_chat_messages(self, world_id, before_id, limit)
## Send a message to world chat.
func send_world_chat_message(world_id: int, content: String) -> Dictionary:
return await SocialApiClient.send_world_chat_message(self, world_id, content)
## Get messages in room chat.
func get_room_chat_messages(room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary:
return await SocialApiClient.get_room_chat_messages(self, room_id, before_id, limit)
## Send a message to room chat.
func send_room_chat_message(room_id: String, content: String) -> Dictionary:
return await SocialApiClient.send_room_chat_message(self, room_id, content)
## Report a message to the moderation queue.
func report_message(message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary:
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
+330
View File
@@ -0,0 +1,330 @@
extends Control
class_name ChatUI
## Chat UI for Direct Messages, World/Room Chat, and Message Reporting.
##
## Features:
## - DM Conversations list + message thread
## - In-experience World/Room chat thread
## - Real-time WebSocket event dispatch for new messages
## - Inline "Report" button on each message with reporting popup
## - Rate limiting and error feedback display
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
# World chat controls
@onready var world_id_edit: LineEdit = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/WorldIdEdit
@onready var join_world_chat_btn: Button = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/JoinChatButton
@onready var world_messages_container: VBoxContainer = $VBoxContainer/TabContainer/WorldChat/Scroll/MessagesList
@onready var world_msg_input: LineEdit = $VBoxContainer/TabContainer/WorldChat/SendHBox/MessageInput
@onready var world_send_btn: Button = $VBoxContainer/TabContainer/WorldChat/SendHBox/SendButton
# DM controls
@onready var target_user_edit: LineEdit = $VBoxContainer/TabContainer/DirectMessages/StartHBox/TargetUserEdit
@onready var start_dm_btn: Button = $VBoxContainer/TabContainer/DirectMessages/StartHBox/StartDmButton
@onready var conv_list_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll/ConvList
@onready var dm_header_label: Label = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/DmHeaderLabel
@onready var dm_messages_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll/MessagesList
@onready var dm_msg_input: LineEdit = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/MessageInput
@onready var dm_send_btn: Button = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/SendButton
# Report Popup
@onready var report_dialog: ConfirmationDialog = $ReportDialog
@onready var report_reason_edit: LineEdit = $ReportDialog/VBox/ReasonEdit
@onready var report_details_label: Label = $ReportDialog/VBox/DetailsLabel
var _api_client: Node = null
var _current_world_id: int = 1
var _active_conversation_id: int = 0
var _reporting_message_id: int = 0
var _reporting_conv_type: String = ""
var _reporting_context: String = ""
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
refresh_world_chat()
refresh_dm_conversations()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_active_tab)
# World chat
if join_world_chat_btn:
join_world_chat_btn.pressed.connect(_on_join_world_chat)
if world_send_btn:
world_send_btn.pressed.connect(_on_send_world_message)
if world_msg_input:
world_msg_input.text_submitted.connect(func(_t): _on_send_world_message())
# DM chat
if start_dm_btn:
start_dm_btn.pressed.connect(_on_start_dm_pressed)
if dm_send_btn:
dm_send_btn.pressed.connect(_on_send_dm_message)
if dm_msg_input:
dm_msg_input.text_submitted.connect(func(_t): _on_send_dm_message())
# Report dialog
if report_dialog:
report_dialog.confirmed.connect(_on_report_confirmed)
# WebSocket real-time hook
if _api_client != null and _api_client.ws != null:
if not _api_client.ws.chat_message_received.is_connected(_on_ws_chat_message):
_api_client.ws.chat_message_received.connect(_on_ws_chat_message)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
func refresh_active_tab() -> void:
if tab_container == null:
return
if tab_container.current_tab == 0:
refresh_world_chat()
else:
refresh_dm_conversations()
if _active_conversation_id > 0:
refresh_dm_messages(_active_conversation_id)
# ── World Chat ──
func _on_join_world_chat() -> void:
var wid_str := world_id_edit.text.strip_edges()
if not wid_str.is_valid_int():
show_message("Enter a valid integer world ID.", true)
return
_current_world_id = int(wid_str)
if _api_client != null and _api_client.ws != null:
_api_client.ws.subscribe_world(_current_world_id)
show_message("Joined world %d chat." % _current_world_id)
refresh_world_chat()
func refresh_world_chat() -> void:
if _api_client == null or not _api_client.has_token():
return
var res: Dictionary = await _api_client.get_world_chat_messages(_current_world_id)
_clear_children(world_messages_container)
if not res.ok:
show_message("Failed to load world chat: " + res.detail, true)
return
var msgs: Array = []
if res.data is Dictionary and res.data.has("messages"):
msgs = res.data["messages"]
elif res.data is Array:
msgs = res.data
for m in msgs:
_add_message_row(world_messages_container, m, "world")
func _on_send_world_message() -> void:
var content := world_msg_input.text.strip_edges()
if content == "":
return
var res: Dictionary = await _api_client.send_world_chat_message(_current_world_id, content)
if res.ok:
world_msg_input.text = ""
refresh_world_chat()
else:
show_message("Send failed: " + res.detail, true)
# ── Direct Messages (DMs) ──
func refresh_dm_conversations() -> void:
if _api_client == null or not _api_client.has_token():
return
var res: Dictionary = await _api_client.get_dm_conversations()
_clear_children(conv_list_container)
if not res.ok:
show_message("Failed to load DMs: " + res.detail, true)
return
var convs: Array = []
if res.data is Dictionary and res.data.has("conversations"):
convs = res.data["conversations"]
elif res.data is Array:
convs = res.data
if convs.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No conversations yet."
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
conv_list_container.add_child(empty_lbl)
return
for c in convs:
var cid: int = int(c.get("id", 0))
var other_name: String = "Conversation #%d" % cid
if c.has("other_user") and c["other_user"] is Dictionary:
other_name = str(c["other_user"].get("username", other_name))
var btn := Button.new()
btn.text = other_name
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.pressed.connect(func(): _select_dm_conversation(cid, other_name))
conv_list_container.add_child(btn)
func _select_dm_conversation(cid: int, name_hint: String) -> void:
_active_conversation_id = cid
if dm_header_label:
dm_header_label.text = "Chat with " + name_hint
if _api_client != null and _api_client.ws != null:
_api_client.ws.subscribe_dm(cid)
refresh_dm_messages(cid)
func refresh_dm_messages(cid: int) -> void:
if _api_client == null or cid <= 0:
return
var res: Dictionary = await _api_client.get_dm_messages(cid)
_clear_children(dm_messages_container)
if not res.ok:
show_message("Failed to load messages: " + res.detail, true)
return
var msgs: Array = []
if res.data is Dictionary and res.data.has("messages"):
msgs = res.data["messages"]
elif res.data is Array:
msgs = res.data
for m in msgs:
_add_message_row(dm_messages_container, m, "dm")
func _on_start_dm_pressed() -> void:
var target_text := target_user_edit.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.start_dm_conversation(target_id)
if res.ok and res.data is Dictionary:
var cid: int = int(res.data.get("id", 0))
target_user_edit.text = ""
show_message("Conversation started!")
await refresh_dm_conversations()
_select_dm_conversation(cid, "User #%d" % target_id)
else:
show_message("Failed to start conversation: " + res.detail, true)
func _on_send_dm_message() -> void:
if _active_conversation_id <= 0:
show_message("Select or start a conversation first.", true)
return
var content := dm_msg_input.text.strip_edges()
if content == "":
return
var res: Dictionary = await _api_client.send_dm_message(_active_conversation_id, content)
if res.ok:
dm_msg_input.text = ""
refresh_dm_messages(_active_conversation_id)
else:
show_message("Send failed: " + res.detail, true)
# ── Message Row & Reporting ──
func _add_message_row(container: VBoxContainer, msg_data: Dictionary, conv_type: String) -> void:
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
var mid: int = int(msg_data.get("id", 0))
var author: String = "User #%s" % str(msg_data.get("author_id", msg_data.get("sender_id", "?")))
if msg_data.has("author") and msg_data["author"] is Dictionary:
author = str(msg_data["author"].get("username", author))
elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
author = str(msg_data["sender"].get("username", author))
var content: String = str(msg_data.get("content", ""))
var label := Label.new()
label.text = "[%s]: %s" % [author, content]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hbox.add_child(label)
var report_btn := Button.new()
report_btn.text = "Report"
report_btn.pressed.connect(func(): _open_report_dialog(mid, conv_type, content))
hbox.add_child(report_btn)
container.add_child(hbox)
func _open_report_dialog(msg_id: int, conv_type: String, context_text: String) -> void:
_reporting_message_id = msg_id
_reporting_conv_type = conv_type
_reporting_context = context_text
if report_details_label:
report_details_label.text = "Reporting message: \"%s\"" % context_text.left(60)
if report_reason_edit:
report_reason_edit.text = "Inappropriate content"
if report_dialog:
report_dialog.popup_centered()
func _on_report_confirmed() -> void:
if _reporting_message_id <= 0 or _api_client == null:
return
var reason := report_reason_edit.text.strip_edges() if report_reason_edit else "Inappropriate content"
var res: Dictionary = await _api_client.report_message(_reporting_message_id, _reporting_conv_type, reason, _reporting_context)
if res.ok:
show_message("Message reported to moderation queue. Thank you!")
else:
show_message("Failed to report message: " + res.detail, true)
# ── Real-Time WS Hook ──
func _on_ws_chat_message(channel: String, id: int, data: Dictionary) -> void:
if channel == "world" and id == _current_world_id:
_add_message_row(world_messages_container, data, "world")
elif channel == "dm" and id == _active_conversation_id:
_add_message_row(dm_messages_container, data, "dm")
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+444
View File
@@ -0,0 +1,444 @@
extends Control
class_name FriendsPresenceUI
## Friends & Presence UI panel.
##
## Displays:
## - Friends list with real-time online/offline presence & custom status
## - Friend requests inbox (with Accept / Decline / Send actions)
## - Following list (with Unfollow / Follow actions)
## - Blocked users list (with Unblock / Block actions)
## - Custom status updater
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
@onready var friends_container: VBoxContainer = $VBoxContainer/TabContainer/Friends/Scroll/FriendsList
@onready var requests_container: VBoxContainer = $VBoxContainer/TabContainer/Requests/Scroll/RequestsList
@onready var following_container: VBoxContainer = $VBoxContainer/TabContainer/Following/Scroll/FollowingList
@onready var blocked_container: VBoxContainer = $VBoxContainer/TabContainer/Blocked/Scroll/BlockedList
@onready var custom_status_input: LineEdit = $VBoxContainer/MyStatusHBox/StatusLineEdit
@onready var set_status_button: Button = $VBoxContainer/MyStatusHBox/SetStatusButton
# Friend request input
@onready var send_req_input: LineEdit = $VBoxContainer/TabContainer/Requests/SendHBox/TargetUserIdEdit
@onready var send_req_button: Button = $VBoxContainer/TabContainer/Requests/SendHBox/SendButton
# Follow input
@onready var follow_input: LineEdit = $VBoxContainer/TabContainer/Following/FollowHBox/FollowUserIdEdit
@onready var follow_button: Button = $VBoxContainer/TabContainer/Following/FollowHBox/FollowButton
# Block input
@onready var block_input: LineEdit = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockUserIdEdit
@onready var block_button: Button = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockButton
var _api_client: Node = null
var _friend_presence_map: Dictionary = {} # user_id (int) -> {"status": String, "custom_status": String, "last_seen": String}
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
refresh_all()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_all)
if set_status_button:
set_status_button.pressed.connect(_on_set_status_pressed)
if send_req_button:
send_req_button.pressed.connect(_on_send_req_pressed)
if follow_button:
follow_button.pressed.connect(_on_follow_pressed)
if block_button:
block_button.pressed.connect(_on_block_pressed)
if _api_client != null and _api_client.ws != null:
if not _api_client.ws.presence_changed.is_connected(_on_presence_changed):
_api_client.ws.presence_changed.connect(_on_presence_changed)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
func refresh_all() -> void:
if _api_client == null or not _api_client.has_token():
show_message("Please log in to view social features.", true)
return
await refresh_friends()
await refresh_requests()
await refresh_following()
await refresh_blocked()
# ── Friends & Presence ──
func refresh_friends() -> void:
if _api_client == null:
return
# Fetch mutual presence first
var pres_res: Dictionary = await _api_client.get_friends_presence()
_friend_presence_map.clear()
if pres_res.ok and pres_res.data is Dictionary and pres_res.data.has("friends"):
for p in pres_res.data["friends"]:
var uid: int = int(p.get("user_id", 0))
_friend_presence_map[uid] = p
# Fetch friends list
var res: Dictionary = await _api_client.get_friends()
_clear_children(friends_container)
if not res.ok:
show_message("Failed to load friends: " + res.detail, true)
return
var friends_list: Array = []
if res.data is Dictionary and res.data.has("friends"):
friends_list = res.data["friends"]
if friends_list.is_empty():
var empty_label := Label.new()
empty_label.text = "No friends yet. Send a friend request to get started!"
empty_label.modulate = Color(0.7, 0.7, 0.7)
friends_container.add_child(empty_label)
return
for friend in friends_list:
var uid: int = int(friend.get("id", 0))
var uname: String = str(friend.get("username", "Unknown"))
_build_friend_row(uid, uname)
func _build_friend_row(uid: int, uname: String) -> void:
var hbox := HBoxContainer.new()
hbox.name = "FriendRow_%d" % uid
hbox.add_theme_constant_override("separation", 12)
var pres: Dictionary = _friend_presence_map.get(uid, {})
var is_online: bool = pres.get("status", "offline") == "online"
var custom_status: String = str(pres.get("custom_status", ""))
var last_seen: String = str(pres.get("last_seen", ""))
# Status indicator dot
var status_dot := Label.new()
status_dot.text = "●"
status_dot.modulate = Color(0.2, 0.9, 0.2) if is_online else Color(0.5, 0.5, 0.5)
hbox.add_child(status_dot)
# Username label
var name_label := Label.new()
name_label.text = uname
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(name_label)
# Presence info
var pres_label := Label.new()
if is_online:
pres_label.text = "Online" + ((" - " + custom_status) if custom_status != "" else "")
pres_label.modulate = Color(0.3, 0.9, 0.3)
else:
pres_label.text = "Offline" + ((" (" + last_seen.left(10) + ")") if last_seen != "" else "")
pres_label.modulate = Color(0.6, 0.6, 0.6)
pres_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(pres_label)
# Action: Unfriend
var unfriend_btn := Button.new()
unfriend_btn.text = "Unfriend"
unfriend_btn.pressed.connect(func(): _on_unfriend(uid))
hbox.add_child(unfriend_btn)
# Action: Block
var block_btn := Button.new()
block_btn.text = "Block"
block_btn.pressed.connect(func(): _on_block_friend(uid))
hbox.add_child(block_btn)
friends_container.add_child(hbox)
func _on_unfriend(uid: int) -> void:
var res: Dictionary = await _api_client.unfriend(uid)
if res.ok:
show_message("Unfriended user.")
refresh_friends()
else:
show_message("Failed to unfriend: " + res.detail, true)
func _on_block_friend(uid: int) -> void:
var res: Dictionary = await _api_client.block_user(uid)
if res.ok:
show_message("Blocked user.")
refresh_friends()
refresh_blocked()
else:
show_message("Failed to block user: " + res.detail, true)
func _on_presence_changed(uid: int, p_status: String) -> void:
if not _friend_presence_map.has(uid):
_friend_presence_map[uid] = {}
_friend_presence_map[uid]["status"] = p_status
# Refresh UI if row exists
var row_name := "FriendRow_%d" % uid
if friends_container.has_node(row_name):
refresh_friends()
func _on_set_status_pressed() -> void:
if _api_client == null:
return
var new_status := custom_status_input.text.strip_edges()
var res: Dictionary = await _api_client.send_presence_heartbeat(new_status)
if res.ok:
show_message("Status updated.")
else:
show_message("Failed to update status: " + res.detail, true)
# ── Requests ──
func refresh_requests() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_friend_requests()
_clear_children(requests_container)
if not res.ok:
show_message("Failed to load requests: " + res.detail, true)
return
var req_list: Array = []
if res.data is Dictionary and res.data.has("requests"):
req_list = res.data["requests"]
if req_list.is_empty():
var empty_label := Label.new()
empty_label.text = "No pending friend requests."
empty_label.modulate = Color(0.7, 0.7, 0.7)
requests_container.add_child(empty_label)
return
for req in req_list:
var req_id: int = int(req.get("id", 0))
var from_id: int = int(req.get("requester_id", 0))
var created_at: String = str(req.get("created_at", "")).left(10)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "Request from User #%d (%s)" % [from_id, created_at]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var accept_btn := Button.new()
accept_btn.text = "Accept"
accept_btn.pressed.connect(func(): _on_accept_request(req_id))
hbox.add_child(accept_btn)
var decline_btn := Button.new()
decline_btn.text = "Decline"
decline_btn.pressed.connect(func(): _on_decline_request(req_id))
hbox.add_child(decline_btn)
requests_container.add_child(hbox)
func _on_accept_request(req_id: int) -> void:
var res: Dictionary = await _api_client.accept_friend_request(req_id)
if res.ok:
show_message("Friend request accepted!")
refresh_requests()
refresh_friends()
else:
show_message("Failed to accept: " + res.detail, true)
func _on_decline_request(req_id: int) -> void:
var res: Dictionary = await _api_client.decline_friend_request(req_id)
if res.ok:
show_message("Friend request declined.")
refresh_requests()
else:
show_message("Failed to decline: " + res.detail, true)
func _on_send_req_pressed() -> void:
var target_text := send_req_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.send_friend_request(target_id)
if res.ok:
show_message("Friend request sent to User #%d!" % target_id)
send_req_input.text = ""
else:
show_message("Failed to send request: " + res.detail, true)
# ── Following ──
func refresh_following() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_following()
_clear_children(following_container)
if not res.ok:
return
var list: Array = []
if res.data is Dictionary and res.data.has("following"):
list = res.data["following"]
if list.is_empty():
var empty_label := Label.new()
empty_label.text = "You are not following anyone."
empty_label.modulate = Color(0.7, 0.7, 0.7)
following_container.add_child(empty_label)
return
for u in list:
var uid: int = int(u.get("id", 0))
var uname: String = str(u.get("username", "Unknown"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "%s (ID: %d)" % [uname, uid]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var unfollow_btn := Button.new()
unfollow_btn.text = "Unfollow"
unfollow_btn.pressed.connect(func(): _on_unfollow(uid))
hbox.add_child(unfollow_btn)
following_container.add_child(hbox)
func _on_unfollow(uid: int) -> void:
var res: Dictionary = await _api_client.unfollow_user(uid)
if res.ok:
show_message("Unfollowed User #%d." % uid)
refresh_following()
else:
show_message("Failed to unfollow: " + res.detail, true)
func _on_follow_pressed() -> void:
var target_text := follow_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.follow_user(target_id)
if res.ok:
show_message("Following User #%d." % target_id)
follow_input.text = ""
refresh_following()
else:
show_message("Failed to follow: " + res.detail, true)
# ── Blocked ──
func refresh_blocked() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_blocked()
_clear_children(blocked_container)
if not res.ok:
return
var list: Array = []
if res.data is Dictionary and res.data.has("blocked"):
list = res.data["blocked"]
if list.is_empty():
var empty_label := Label.new()
empty_label.text = "No blocked users."
empty_label.modulate = Color(0.7, 0.7, 0.7)
blocked_container.add_child(empty_label)
return
for u in list:
var uid: int = int(u.get("id", 0))
var uname: String = str(u.get("username", "Unknown"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "%s (ID: %d)" % [uname, uid]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var unblock_btn := Button.new()
unblock_btn.text = "Unblock"
unblock_btn.pressed.connect(func(): _on_unblock(uid))
hbox.add_child(unblock_btn)
blocked_container.add_child(hbox)
func _on_unblock(uid: int) -> void:
var res: Dictionary = await _api_client.unblock_user(uid)
if res.ok:
show_message("Unblocked User #%d." % uid)
refresh_blocked()
else:
show_message("Failed to unblock: " + res.detail, true)
func _on_block_pressed() -> void:
var target_text := block_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.block_user(target_id)
if res.ok:
show_message("Blocked User #%d." % target_id)
block_input.text = ""
refresh_blocked()
refresh_friends()
else:
show_message("Failed to block: " + res.detail, true)
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+357
View File
@@ -0,0 +1,357 @@
extends Control
class_name GroupsUI
## Groups & Communities UI.
##
## Features:
## - Browse groups with pagination and "My Groups" filter
## - Create new group (creator becomes owner)
## - Detailed group page: description, member count, member list with roles
## - Join and Leave group actions
## - Role management for owner (promote to admin, demote to member, transfer ownership)
## - Delete group (owner only)
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
# Browse controls
@onready var my_groups_check: CheckBox = $VBoxContainer/TabContainer/Browse/FilterHBox/MyGroupsCheck
@onready var groups_list_container: VBoxContainer = $VBoxContainer/TabContainer/Browse/Scroll/GroupsList
# Create controls
@onready var new_group_name_edit: LineEdit = $VBoxContainer/TabContainer/Create/FormVBox/NameEdit
@onready var new_group_desc_edit: TextEdit = $VBoxContainer/TabContainer/Create/FormVBox/DescEdit
@onready var create_group_btn: Button = $VBoxContainer/TabContainer/Create/FormVBox/CreateButton
# Group Page controls
@onready var group_page_vbox: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox
@onready var group_name_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/GroupNameLabel
@onready var member_count_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/MemberCountLabel
@onready var group_desc_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/GroupDescLabel
@onready var join_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/JoinButton
@onready var leave_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/LeaveButton
@onready var delete_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/DeleteButton
@onready var members_list_container: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox/MembersList
var _api_client: Node = null
var _current_user_id: int = 0
var _active_group_id: int = 0
var _active_group_data: Dictionary = {}
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
_init_user()
refresh_groups()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _init_user() -> void:
if _api_client == null:
return
var me_res: Dictionary = await _api_client.get_me()
if me_res.ok and me_res.data is Dictionary:
_current_user_id = int(me_res.data.get("id", 0))
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_groups)
if my_groups_check:
my_groups_check.toggled.connect(func(_t): refresh_groups())
if create_group_btn:
create_group_btn.pressed.connect(_on_create_group_pressed)
if join_btn:
join_btn.pressed.connect(_on_join_pressed)
if leave_btn:
leave_btn.pressed.connect(_on_leave_pressed)
if delete_btn:
delete_btn.pressed.connect(_on_delete_pressed)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
# ── Browse Groups ──
func refresh_groups() -> void:
if _api_client == null or not _api_client.has_token():
return
var mine: bool = my_groups_check.button_pressed if my_groups_check else false
var res: Dictionary = await _api_client.list_groups(1, 50, mine)
_clear_children(groups_list_container)
if not res.ok:
show_message("Failed to load groups: " + res.detail, true)
return
var groups: Array = []
if res.data is Dictionary and res.data.has("groups"):
groups = res.data["groups"]
elif res.data is Array:
groups = res.data
if groups.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No groups found. Create one in the Create Group tab!"
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
groups_list_container.add_child(empty_lbl)
return
for g in groups:
var gid: int = int(g.get("id", 0))
var gname: String = str(g.get("name", "Group"))
var gdesc: String = str(g.get("description", ""))
var count: int = int(g.get("member_count", 0))
_build_group_card(gid, gname, gdesc, count)
func _build_group_card(gid: int, gname: String, gdesc: String, count: int) -> void:
var panel := PanelContainer.new()
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 12)
margin.add_theme_constant_override("margin_bottom", 12)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 16)
var info_vbox := VBoxContainer.new()
info_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_lbl := Label.new()
name_lbl.text = gname
name_lbl.add_theme_font_size_override("font_size", 18)
info_vbox.add_child(name_lbl)
if gdesc != "":
var desc_lbl := Label.new()
desc_lbl.text = gdesc.left(100)
desc_lbl.modulate = Color(0.8, 0.8, 0.8)
info_vbox.add_child(desc_lbl)
var count_lbl := Label.new()
count_lbl.text = "%d member%s" % [count, "" if count == 1 else "s"]
count_lbl.modulate = Color(0.6, 0.8, 1.0)
info_vbox.add_child(count_lbl)
hbox.add_child(info_vbox)
var view_btn := Button.new()
view_btn.text = "View Group"
view_btn.custom_minimum_size = Vector2(110, 36)
view_btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
view_btn.pressed.connect(func(): open_group_page(gid))
hbox.add_child(view_btn)
margin.add_child(hbox)
panel.add_child(margin)
groups_list_container.add_child(panel)
# ── Create Group ──
func _on_create_group_pressed() -> void:
if _api_client == null:
return
var gname := new_group_name_edit.text.strip_edges()
var gdesc := new_group_desc_edit.text.strip_edges()
if gname == "":
show_message("Group name cannot be empty.", true)
return
var res: Dictionary = await _api_client.create_group(gname, gdesc)
if res.ok and res.data is Dictionary:
var gid: int = int(res.data.get("id", 0))
new_group_name_edit.text = ""
new_group_desc_edit.text = ""
show_message("Group '%s' created!" % gname)
await refresh_groups()
open_group_page(gid)
else:
show_message("Failed to create group: " + res.detail, true)
# ── Group Page Details ──
func open_group_page(group_id: int) -> void:
_active_group_id = group_id
if tab_container:
tab_container.current_tab = 2 # Details tab
await refresh_group_page(group_id)
func refresh_group_page(group_id: int) -> void:
if _api_client == null or group_id <= 0:
return
var res: Dictionary = await _api_client.get_group(group_id)
if not res.ok:
show_message("Failed to load group: " + res.detail, true)
return
_active_group_data = res.data if res.data is Dictionary else {}
var gname: String = str(_active_group_data.get("name", "Group"))
var gdesc: String = str(_active_group_data.get("description", "No description provided."))
var members: Array = _active_group_data.get("members", [])
var count: int = int(_active_group_data.get("member_count", members.size()))
var owner_id: int = int(_active_group_data.get("owner_id", 0))
if group_name_label:
group_name_label.text = gname
if group_desc_label:
group_desc_label.text = gdesc
if member_count_label:
member_count_label.text = "%d Members" % count
# Check membership of current user
var is_member := false
var my_role := ""
for m in members:
var uid: int = int(m.get("user_id", 0))
if uid == _current_user_id:
is_member = true
my_role = str(m.get("role", "member"))
break
var is_owner := (_current_user_id == owner_id) or my_role == "owner"
if join_btn:
join_btn.visible = not is_member
if leave_btn:
leave_btn.visible = is_member and not is_owner
if delete_btn:
delete_btn.visible = is_owner
_populate_members_list(members, is_owner)
func _populate_members_list(members: Array, can_manage_roles: bool) -> void:
_clear_children(members_list_container)
for m in members:
var uid: int = int(m.get("user_id", 0))
var uname: String = "User #%d" % uid
if m.has("user") and m["user"] is Dictionary:
uname = str(m["user"].get("username", uname))
var role: String = str(m.get("role", "member"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var name_lbl := Label.new()
name_lbl.text = "%s (ID: %d)" % [uname, uid]
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(name_lbl)
var role_badge := Label.new()
role_badge.text = "[%s]" % role.to_upper()
if role == "owner":
role_badge.modulate = Color(1.0, 0.8, 0.2)
elif role == "admin":
role_badge.modulate = Color(0.4, 0.8, 1.0)
else:
role_badge.modulate = Color(0.7, 0.7, 0.7)
hbox.add_child(role_badge)
# Role selector for owner (excluding self/owner)
if can_manage_roles and role != "owner":
var opt := OptionButton.new()
opt.add_item("Member")
opt.add_item("Admin")
opt.selected = 1 if role == "admin" else 0
opt.item_selected.connect(func(idx: int): _on_role_changed(uid, "admin" if idx == 1 else "member"))
hbox.add_child(opt)
var xfer_btn := Button.new()
xfer_btn.text = "Make Owner"
xfer_btn.pressed.connect(func(): _on_transfer_ownership(uid))
hbox.add_child(xfer_btn)
members_list_container.add_child(hbox)
func _on_join_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.join_group(_active_group_id)
if res.ok:
show_message("Joined group!")
refresh_group_page(_active_group_id)
else:
show_message("Failed to join group: " + res.detail, true)
func _on_leave_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.leave_group(_active_group_id)
if res.ok:
show_message("Left group.")
refresh_group_page(_active_group_id)
else:
show_message("Failed to leave group: " + res.detail, true)
func _on_delete_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.delete_group(_active_group_id)
if res.ok:
show_message("Group deleted.")
tab_container.current_tab = 0
refresh_groups()
else:
show_message("Failed to delete group: " + res.detail, true)
func _on_role_changed(uid: int, new_role: String) -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.set_group_member_role(_active_group_id, uid, new_role)
if res.ok:
show_message("Role updated to %s." % new_role)
refresh_group_page(_active_group_id)
else:
show_message("Failed to change role: " + res.detail, true)
func _on_transfer_ownership(new_owner_id: int) -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.transfer_group_ownership(_active_group_id, new_owner_id)
if res.ok:
show_message("Ownership transferred to User #%d!" % new_owner_id)
refresh_group_page(_active_group_id)
else:
show_message("Failed to transfer ownership: " + res.detail, true)
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+312
View File
@@ -0,0 +1,312 @@
extends Control
class_name ProfileUI
## Profile Page UI.
##
## Displays:
## - User avatar representation, username, and bio
## - Favorite experiences (worlds) list
## - Public/mutual friends list
## - Respects server-enforced privacy controls and blocked-user restricted view
## - Allows authenticated user to edit their own bio and privacy settings
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
# Lookup controls
@onready var user_id_edit: LineEdit = $VBoxContainer/LookupHBox/UserIdEdit
@onready var view_user_btn: Button = $VBoxContainer/LookupHBox/ViewUserButton
@onready var view_my_profile_btn: Button = $VBoxContainer/LookupHBox/ViewMyProfileButton
# Profile Card details
@onready var username_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UsernameLabel
@onready var user_id_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UserIdLabel
@onready var avatar_preview_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox/AvatarLabel
@onready var bio_edit: TextEdit = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/BioEdit
@onready var save_bio_btn: Button = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/SaveBioButton
# Privacy Controls (visible only for own profile)
@onready var privacy_vbox: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox
@onready var profile_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/ProfileVisOption
@onready var friends_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FriendsVisOption
@onready var favorites_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FavoritesVisOption
@onready var save_privacy_btn: Button = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/SavePrivacyButton
# Favorites list
@onready var favorites_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesList
@onready var favorites_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesStatusLabel
# Friends list
@onready var friends_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsList
@onready var friends_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsStatusLabel
var _api_client: Node = null
var _viewing_user_id: int = 0
var _my_user_id: int = 0
var _is_own_profile: bool = false
func _ready() -> void:
_api_client = _get_api()
_setup_privacy_options()
_wire_signals()
if _api_client != null and _api_client.has_token():
_init_my_profile()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _setup_privacy_options() -> void:
var visibilities := ["public", "friends_only", "private"]
for opt in [profile_vis_opt, friends_vis_opt, favorites_vis_opt]:
if opt:
opt.clear()
for v in visibilities:
opt.add_item(v.capitalize())
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(_refresh_current)
if view_user_btn:
view_user_btn.pressed.connect(_on_view_user_pressed)
if view_my_profile_btn:
view_my_profile_btn.pressed.connect(_init_my_profile)
if save_bio_btn:
save_bio_btn.pressed.connect(_on_save_bio_pressed)
if save_privacy_btn:
save_privacy_btn.pressed.connect(_on_save_privacy_pressed)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
func _init_my_profile() -> void:
if _api_client == null or not _api_client.has_token():
show_message("Log in to view profile.", true)
return
var me_res: Dictionary = await _api_client.get_me()
if me_res.ok and me_res.data is Dictionary:
_my_user_id = int(me_res.data.get("id", 0))
load_profile(_my_user_id, true)
func _on_view_user_pressed() -> void:
var uid_str := user_id_edit.text.strip_edges()
if not uid_str.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var uid := int(uid_str)
load_profile(uid, uid == _my_user_id)
func _refresh_current() -> void:
if _viewing_user_id > 0:
load_profile(_viewing_user_id, _is_own_profile)
## Load profile for user_id.
func load_profile(user_id: int, is_own: bool = false) -> void:
_viewing_user_id = user_id
_is_own_profile = is_own
if _is_own_profile:
await _load_own_profile()
else:
await _load_public_profile(user_id)
func _load_own_profile() -> void:
title_label.text = "My Profile"
if privacy_vbox:
privacy_vbox.visible = true
if save_bio_btn:
save_bio_btn.visible = true
if bio_edit:
bio_edit.editable = true
var res: Dictionary = await _api_client.get_my_profile()
if not res.ok:
show_message("Failed to load own profile: " + res.detail, true)
return
var data: Dictionary = res.data if res.data is Dictionary else {}
_populate_card(_my_user_id, "You", str(data.get("bio", "")), data.get("avatar_asset_id"))
# Privacy
if data.has("privacy_json") and data["privacy_json"] is Dictionary:
var priv: Dictionary = data["privacy_json"]
_set_option_val(profile_vis_opt, priv.get("profile_visibility", "public"))
_set_option_val(friends_vis_opt, priv.get("friends_list_visibility", "public"))
_set_option_val(favorites_vis_opt, priv.get("favorites_visibility", "public"))
# Load own favorites
var fav_res: Dictionary = await _api_client.get_my_favorites()
_populate_favorites(fav_res.data.get("world_ids", []) if fav_res.ok and fav_res.data is Dictionary else [])
# Load own friends
var fr_res: Dictionary = await _api_client.get_friends()
_populate_friends(fr_res.data.get("friends", []) if fr_res.ok and fr_res.data is Dictionary else [])
func _load_public_profile(user_id: int) -> void:
title_label.text = "User Profile"
if privacy_vbox:
privacy_vbox.visible = false
if save_bio_btn:
save_bio_btn.visible = false
if bio_edit:
bio_edit.editable = false
var res: Dictionary = await _api_client.get_public_profile(user_id)
if not res.ok:
show_message("Failed to load profile: " + res.detail, true)
return
var data: Dictionary = res.data if res.data is Dictionary else {}
var uname: String = str(data.get("username", "User #%d" % user_id))
var bio: String = str(data.get("bio", "")) if data.get("bio") != null else "[Bio hidden or empty]"
_populate_card(user_id, uname, bio, data.get("avatar_asset_id"))
# Favorites
if data.has("favorites") and data["favorites"] != null:
_populate_favorites(data["favorites"])
else:
_clear_children(favorites_container)
if favorites_status_label:
favorites_status_label.text = "Favorites are hidden by privacy settings."
favorites_status_label.visible = true
# Friends
if data.has("friends") and data["friends"] != null:
_populate_friends(data["friends"])
else:
_clear_children(friends_container)
if friends_status_label:
friends_status_label.text = "Friends list is hidden by privacy settings."
friends_status_label.visible = true
func _populate_card(uid: int, uname: String, bio: String, avatar_asset_id: Variant) -> void:
if username_label:
username_label.text = uname
if user_id_label:
user_id_label.text = "User ID: %d" % uid
if bio_edit:
bio_edit.text = bio
if avatar_preview_label:
avatar_preview_label.text = "Avatar #%s" % str(avatar_asset_id) if avatar_asset_id != null else "Default Avatar"
func _populate_favorites(favorites: Array) -> void:
_clear_children(favorites_container)
if favorites_status_label:
favorites_status_label.visible = favorites.is_empty()
if favorites.is_empty():
favorites_status_label.text = "No favorite experiences yet."
for item in favorites:
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
var lbl := Label.new()
if item is Dictionary:
lbl.text = "★ %s (World #%s)" % [str(item.get("name", "World")), str(item.get("id", ""))]
else:
lbl.text = "★ World #%s" % str(item)
hbox.add_child(lbl)
favorites_container.add_child(hbox)
func _populate_friends(friends: Array) -> void:
_clear_children(friends_container)
if friends_status_label:
friends_status_label.visible = friends.is_empty()
if friends.is_empty():
friends_status_label.text = "No friends listed."
for friend in friends:
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
var lbl := Label.new()
if friend is Dictionary:
var fid: int = int(friend.get("id", 0))
var fname: String = str(friend.get("username", "User"))
lbl.text = "👤 %s (ID: %d)" % [fname, fid]
hbox.add_child(lbl)
var view_btn := Button.new()
view_btn.text = "View"
view_btn.pressed.connect(func(): load_profile(fid, fid == _my_user_id))
hbox.add_child(view_btn)
else:
lbl.text = "👤 User #%s" % str(friend)
hbox.add_child(lbl)
friends_container.add_child(hbox)
func _on_save_bio_pressed() -> void:
if _api_client == null or not _is_own_profile:
return
var new_bio := bio_edit.text.strip_edges() if bio_edit else ""
var res: Dictionary = await _api_client.update_my_profile(new_bio)
if res.ok:
show_message("Bio updated successfully!")
else:
show_message("Failed to update bio: " + res.detail, true)
func _on_save_privacy_pressed() -> void:
if _api_client == null or not _is_own_profile:
return
var privacy := {
"profile_visibility": _get_option_val(profile_vis_opt),
"friends_list_visibility": _get_option_val(friends_vis_opt),
"favorites_visibility": _get_option_val(favorites_vis_opt),
}
var res: Dictionary = await _api_client.update_my_profile(null, null, privacy)
if res.ok:
show_message("Privacy settings updated!")
else:
show_message("Failed to update privacy: " + res.detail, true)
func _set_option_val(opt: OptionButton, val: String) -> void:
if opt == null:
return
for i in range(opt.item_count):
if opt.get_item_text(i).to_lower() == val.to_lower():
opt.selected = i
return
func _get_option_val(opt: OptionButton) -> String:
if opt == null or opt.selected < 0:
return "public"
return opt.get_item_text(opt.selected).to_lower().replace(" ", "_")
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## Headless smoke test for ChatUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Chat UI verification...")
var scene_res := load("res://scenes/chat_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/chat_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate ChatUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.world_messages_container == null:
printerr("FAIL: world_messages_container is null")
quit(1)
return
if ui.dm_messages_container == null:
printerr("FAIL: dm_messages_container is null")
quit(1)
return
if ui.conv_list_container == null:
printerr("FAIL: conv_list_container is null")
quit(1)
return
if ui.report_dialog == null:
printerr("FAIL: report_dialog is null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test message row creation with Report button
var mock_msg := {
"id": 101,
"author": {"username": "ChattyUser"},
"content": "Hello world! This is a test message."
}
ui._add_message_row(ui.world_messages_container, mock_msg, "world")
if ui.world_messages_container.get_child_count() == 0:
printerr("FAIL: Message row was not added to world_messages_container")
quit(1)
return
var row: HBoxContainer = ui.world_messages_container.get_child(0)
var found_report := false
for child in row.get_children():
if child is Button and child.text == "Report":
found_report = true
break
if not found_report:
printerr("FAIL: Report button not found on message row")
quit(1)
return
print("PASS: Message row with Report button verified.")
# Test report dialog preparation
ui._open_report_dialog(101, "world", "Hello world! This is a test message.")
if ui._reporting_message_id != 101 or ui._reporting_conv_type != "world":
printerr("FAIL: Report dialog state not set correctly")
quit(1)
return
print("PASS: Report dialog setup verified.")
# Test real-time WS chat message hook
var mock_ws_event := {
"id": 102,
"author": {"username": "LiveUser"},
"content": "Real-time incoming message!"
}
ui._on_ws_chat_message("world", ui._current_world_id, mock_ws_event)
if ui.world_messages_container.get_child_count() < 2:
printerr("FAIL: Live WS message was not appended to world chat")
quit(1)
return
print("PASS: Real-time WS chat message append verified.")
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)
+77
View File
@@ -0,0 +1,77 @@
extends SceneTree
## Headless smoke test for FriendsPresenceUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Friends & Presence UI verification...")
var scene_res := load("res://scenes/friends_presence_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/friends_presence_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate FriendsPresenceUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.friends_container == null:
printerr("FAIL: friends_container is null")
quit(1)
return
if ui.requests_container == null:
printerr("FAIL: requests_container is null")
quit(1)
return
if ui.following_container == null:
printerr("FAIL: following_container is null")
quit(1)
return
if ui.blocked_container == null:
printerr("FAIL: blocked_container is null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test mock row building
ui._friend_presence_map[42] = {
"status": "online",
"custom_status": "Building cool stuff",
"last_seen": "2026-09-15T12:00:00Z"
}
ui._build_friend_row(42, "TesterFriend")
if not ui.friends_container.has_node("FriendRow_42"):
printerr("FAIL: FriendRow_42 was not added to friends_container")
quit(1)
return
print("PASS: Friend row built successfully with presence indicator.")
# Test message display
ui.show_message("Test message", false)
if ui.status_label.text != "Test message":
printerr("FAIL: status_label text not updated")
quit(1)
return
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)
+68
View File
@@ -0,0 +1,68 @@
extends SceneTree
## Headless smoke test for GroupsUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Groups UI verification...")
var scene_res := load("res://scenes/groups_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/groups_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate GroupsUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.groups_list_container == null or ui.members_list_container == null:
printerr("FAIL: container nodes are null")
quit(1)
return
if ui.group_name_label == null or ui.member_count_label == null:
printerr("FAIL: label nodes are null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test group card building
ui._build_group_card(1, "Alpha Guild", "Top tier world creators", 5)
if ui.groups_list_container.get_child_count() != 1:
printerr("FAIL: Group card not added to groups_list_container")
quit(1)
return
print("PASS: Group card building verified.")
# Test members list population with owner management
var mock_members := [
{"user_id": 10, "role": "owner", "user": {"username": "OwnerUser"}},
{"user_id": 20, "role": "admin", "user": {"username": "AdminUser"}},
{"user_id": 30, "role": "member", "user": {"username": "NormalUser"}}
]
ui._populate_members_list(mock_members, true)
if ui.members_list_container.get_child_count() != 3:
printerr("FAIL: Member list count mismatch")
quit(1)
return
print("PASS: Group members list population with role management verified.")
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)
+80
View File
@@ -0,0 +1,80 @@
extends SceneTree
## Headless smoke test for ProfileUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Profile UI verification...")
var scene_res := load("res://scenes/profile_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/profile_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate ProfileUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.username_label == null or ui.bio_edit == null:
printerr("FAIL: username_label or bio_edit is null")
quit(1)
return
if ui.privacy_vbox == null or ui.favorites_container == null or ui.friends_container == null:
printerr("FAIL: container nodes are null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test card population
ui._populate_card(99, "TestExplorer", "Just another world builder", 12)
if ui.username_label.text != "TestExplorer" or ui.bio_edit.text != "Just another world builder":
printerr("FAIL: Card population failed")
quit(1)
return
print("PASS: Profile card population verified.")
# Test favorites population
var mock_favs := [
{"id": 1, "name": "Sunset Plains"},
{"id": 2, "name": "Neon City Rooftops"}
]
ui._populate_favorites(mock_favs)
if ui.favorites_container.get_child_count() != 2:
printerr("FAIL: Favorites count mismatch")
quit(1)
return
print("PASS: Favorites list population verified.")
# Test friends population
var mock_friends := [
{"id": 10, "username": "Alice"},
{"id": 20, "username": "Bob"}
]
ui._populate_friends(mock_friends)
if ui.friends_container.get_child_count() != 2:
printerr("FAIL: Friends list count mismatch")
quit(1)
return
print("PASS: Friends list population verified.")
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)
+75
View File
@@ -0,0 +1,75 @@
extends SceneTree
## Headless smoke test for Godot API client (auth + HTTP + WebSocket gateway).
##
## Exercises:
## 1. ApiClient autoload has WsGatewayClient child node.
## 2. ApiClient has all social methods adhering to {ok, status, data, detail}.
## 3. SocialApiClient helper methods work properly.
## 4. WsGatewayClient URL parsing, signal declarations, state transitions.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Social API client verification...")
var api_client := root.get_node_or_null("ApiClient")
if api_client == null:
printerr("FAIL: ApiClient node not found in root")
quit(1)
return
# Verify ws node exists on ApiClient
if api_client.ws == null:
printerr("FAIL: api_client.ws is null")
quit(1)
return
if not (api_client.ws is WsGatewayClient):
printerr("FAIL: api_client.ws is not a WsGatewayClient")
quit(1)
return
print("PASS: ApiClient has WsGatewayClient attached.")
# Verify WsGatewayClient states and signals
var test_ws := WsGatewayClient.new()
if test_ws.is_connected_to_gateway:
printerr("FAIL: new test_ws should not be connected")
quit(1)
return
print("PASS: WsGatewayClient initial state is disconnected.")
# Verify ApiClient social methods exist
var social_methods := [
"send_friend_request", "get_friend_requests", "accept_friend_request",
"decline_friend_request", "unfriend", "get_friends", "follow_user",
"unfollow_user", "get_following", "block_user", "unblock_user", "get_blocked",
"send_presence_heartbeat", "get_friends_presence", "get_user_presence",
"get_my_profile", "update_my_profile", "get_my_favorites", "replace_my_favorites",
"get_public_profile", "create_group", "list_groups", "get_group",
"update_group", "delete_group", "join_group", "leave_group",
"set_group_member_role", "transfer_group_ownership", "get_dm_conversations",
"start_dm_conversation", "get_dm_messages", "send_dm_message",
"get_world_chat_messages", "send_world_chat_message", "get_room_chat_messages",
"send_room_chat_message", "report_message"
]
for m in social_methods:
if not api_client.has_method(m):
printerr("FAIL: ApiClient missing method: ", m)
quit(1)
return
print("PASS: All %d social REST methods verified on ApiClient." % social_methods.size())
# Verify unauthenticated call correctly yields ok == false without crashing
var result: Dictionary = await api_client.get_friends()
if not result.has("ok") or not result.has("status") or not result.has("detail"):
printerr("FAIL: result does not adhere to Result Dictionary Contract: ", result)
quit(1)
return
print("PASS: Social REST call returns valid result dictionary (ok=%s, status=%d)." % [result.ok, result.status])
print("SMOKE RESULT: PASS")
quit(0)
+171
View File
@@ -0,0 +1,171 @@
extends RefCounted
class_name SocialApiClient
## Helper class for AGP Social REST endpoints and WebSocket gateway interactions.
##
## Can be used as a standalone helper or via ApiClient.
static func get_api_client(node: Node) -> Node:
if node.has_node("/root/ApiClient"):
return node.get_node("/root/ApiClient")
return null
# ── Friends & Follow & Block ──
static func send_friend_request(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify({"user_id": user_id}), "application/json", true)
static func get_friend_requests(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/friends/requests", "", "application/json", true)
static func accept_friend_request(api: Node, request_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests/%d/accept" % request_id, "", "application/json", true)
static func decline_friend_request(api: Node, request_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/requests/%d/decline" % request_id, "", "application/json", true)
static func unfriend(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d" % user_id, "", "application/json", true)
static func get_friends(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/friends", "", "application/json", true)
static func follow_user(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % user_id, "", "application/json", true)
static func unfollow_user(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d/follow" % user_id, "", "application/json", true)
static func get_following(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/friends/following", "", "application/json", true)
static func block_user(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % user_id, "", "application/json", true)
static func unblock_user(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_DELETE, "/api/friends/%d/block" % user_id, "", "application/json", true)
static func get_blocked(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/friends/blocked", "", "application/json", true)
# ── Presence ──
static func send_presence_heartbeat(api: Node, custom_status: String = "") -> Dictionary:
var payload := {}
if custom_status != "":
payload["custom_status"] = custom_status
return await api.request(HTTPClient.METHOD_POST, "/api/presence/heartbeat", JSON.stringify(payload), "application/json", true)
static func get_friends_presence(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/presence/friends", "", "application/json", true)
static func get_user_presence(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/presence/%d" % user_id, "", "application/json", true)
# ── Profiles ──
static func get_my_profile(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/profile/me", "", "application/json", true)
static func update_my_profile(api: Node, bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary:
var payload := {}
if bio != null:
payload["bio"] = bio
if avatar_asset_id != null:
payload["avatar_asset_id"] = avatar_asset_id
if privacy != null:
payload["privacy"] = privacy
return await api.request(HTTPClient.METHOD_PUT, "/api/profile/me", JSON.stringify(payload), "application/json", true)
static func get_my_favorites(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/profile/me/favorites", "", "application/json", true)
static func replace_my_favorites(api: Node, world_ids: Array) -> Dictionary:
return await api.request(HTTPClient.METHOD_PUT, "/api/profile/me/favorites", JSON.stringify({"world_ids": world_ids}), "application/json", true)
static func get_public_profile(api: Node, user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/profile/%d" % user_id, "", "application/json", true)
# ── Groups ──
static func create_group(api: Node, name: String, description: String = "") -> Dictionary:
var payload := {"name": name}
if description != "":
payload["description"] = description
return await api.request(HTTPClient.METHOD_POST, "/api/groups", JSON.stringify(payload), "application/json", true)
static func list_groups(api: Node, page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary:
var path := "/api/groups?page=%d&page_size=%d" % [page, page_size]
if mine:
path += "&mine=true"
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
static func get_group(api: Node, group_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/groups/%d" % group_id, "", "application/json", true)
static func update_group(api: Node, group_id: int, name: Variant = null, description: Variant = null) -> Dictionary:
var payload := {}
if name != null:
payload["name"] = name
if description != null:
payload["description"] = description
return await api.request(HTTPClient.METHOD_PATCH, "/api/groups/%d" % group_id, JSON.stringify(payload), "application/json", true)
static func delete_group(api: Node, group_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_DELETE, "/api/groups/%d" % group_id, "", "application/json", true)
static func join_group(api: Node, group_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/join" % group_id, "", "application/json", true)
static func leave_group(api: Node, group_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/leave" % group_id, "", "application/json", true)
static func set_group_member_role(api: Node, group_id: int, user_id: int, role: String) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/members/%d/role" % [group_id, user_id], JSON.stringify({"role": role}), "application/json", true)
static func transfer_group_ownership(api: Node, group_id: int, new_owner_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/groups/%d/transfer" % group_id, JSON.stringify({"new_owner_id": new_owner_id}), "application/json", true)
# ── Chat & Moderation ──
static func get_dm_conversations(api: Node) -> Dictionary:
return await api.request(HTTPClient.METHOD_GET, "/api/chat/dm/conversations", "", "application/json", true)
static func start_dm_conversation(api: Node, target_user_id: int) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations", JSON.stringify({"target_user_id": target_user_id}), "application/json", true)
static func get_dm_messages(api: Node, conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
var path := "/api/chat/dm/conversations/%d/messages?limit=%d" % [conversation_id, limit]
if before_id > 0:
path += "&before_id=%d" % before_id
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
static func send_dm_message(api: Node, conversation_id: int, content: String) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/chat/dm/conversations/%d/messages" % conversation_id, JSON.stringify({"content": content}), "application/json", true)
static func get_world_chat_messages(api: Node, world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary:
var path := "/api/chat/world/%d/messages?limit=%d" % [world_id, limit]
if before_id > 0:
path += "&before_id=%d" % before_id
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
static func send_world_chat_message(api: Node, world_id: int, content: String) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/chat/world/%d/messages" % world_id, JSON.stringify({"content": content}), "application/json", true)
static func get_room_chat_messages(api: Node, room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary:
var path := "/api/chat/room/%s/messages?limit=%d" % [room_id.uri_encode(), limit]
if before_id > 0:
path += "&before_id=%d" % before_id
return await api.request(HTTPClient.METHOD_GET, path, "", "application/json", true)
static func send_room_chat_message(api: Node, room_id: String, content: String) -> Dictionary:
return await api.request(HTTPClient.METHOD_POST, "/api/chat/room/%s/messages" % room_id.uri_encode(), JSON.stringify({"content": content}), "application/json", true)
static func report_message(api: Node, message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary:
var payload := {
"message_id": message_id,
"conversation_type": conversation_type,
"reason": reason
}
if context_text != "":
payload["context_text"] = context_text
return await api.request(HTTPClient.METHOD_POST, "/api/chat/reports/messages", JSON.stringify(payload), "application/json", true)
+210
View File
@@ -0,0 +1,210 @@
extends Node
class_name WsGatewayClient
## WebSocket gateway client for real-time presence and chat.
##
## Manages connection to the AGP gateway endpoint (/api/ws or /v1/ws).
## Handles JWT authentication, presence heartbeat, DM/world channel
## subscriptions, and event dispatch.
signal connected
signal disconnected
signal authenticated(user_id: int, username: String)
signal presence_changed(user_id: int, status: String)
signal chat_message_received(channel: String, id: int, data: Dictionary)
signal subscribed(channel: String, id: int)
signal unsubscribed(channel: String, id: int)
signal gateway_error(message: String)
enum ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
AUTHENTICATED,
CLOSING
}
var _peer: WebSocketPeer = WebSocketPeer.new()
var _state: ConnectionState = ConnectionState.DISCONNECTED
var _token: String = ""
var _authenticated_user_id: int = 0
var _authenticated_username: String = ""
var _heartbeat_timer: float = 0.0
var _heartbeat_interval: float = 25.0
var _last_url: String = ""
var is_connected_to_gateway: bool:
get:
return _state == ConnectionState.CONNECTED or _state == ConnectionState.AUTHENTICATED
var is_authenticated: bool:
get:
return _state == ConnectionState.AUTHENTICATED
var user_id: int:
get:
return _authenticated_user_id
var username: String:
get:
return _authenticated_username
func _process(delta: float) -> void:
if _state == ConnectionState.DISCONNECTED:
return
_peer.poll()
var peer_state := _peer.get_ready_state()
match peer_state:
WebSocketPeer.STATE_OPEN:
if _state == ConnectionState.CONNECTING:
_state = ConnectionState.CONNECTED
connected.emit()
# If token was provided, send auth frame if not already sent via query param
if _token != "" and _state != ConnectionState.AUTHENTICATED:
send_json_message({"type": "auth", "token": _token})
# Process incoming packets
while _peer.get_available_packet_count() > 0:
var packet := _peer.get_packet()
_handle_packet(packet)
# Send periodic heartbeat
if _state == ConnectionState.AUTHENTICATED:
_heartbeat_timer += delta
if _heartbeat_timer >= _heartbeat_interval:
_heartbeat_timer = 0.0
send_heartbeat()
WebSocketPeer.STATE_CLOSING:
_state = ConnectionState.CLOSING
WebSocketPeer.STATE_CLOSED:
if _state != ConnectionState.DISCONNECTED:
var code := _peer.get_close_code()
var reason := _peer.get_close_reason()
_cleanup()
disconnected.emit()
## Connect to the WebSocket gateway.
## If ws_url is empty, derives ws:// or wss:// from base_http_url + "/api/ws".
func connect_gateway(base_http_url: String = "http://127.0.0.1:8000", token: String = "") -> Error:
_cleanup()
_token = token
var url: String
if base_http_url.begins_with("ws://") or base_http_url.begins_with("wss://"):
url = base_http_url
else:
var ws_scheme := "wss://" if base_http_url.begins_with("https://") else "ws://"
var stripped := base_http_url.replace("https://", "").replace("http://", "").rstrip("/")
url = ws_scheme + stripped + "/api/ws"
if token != "":
url += "?token=" + token.uri_encode()
_last_url = url
_peer = WebSocketPeer.new()
var err := _peer.connect_to_url(url)
if err == OK:
_state = ConnectionState.CONNECTING
else:
_state = ConnectionState.DISCONNECTED
gateway_error.emit("Failed to initiate connection: %d" % err)
return err
## Disconnect from the WebSocket gateway.
func disconnect_gateway(code: int = 1000, reason: String = "Normal closure") -> void:
if _state != ConnectionState.DISCONNECTED and _peer != null:
_peer.close(code, reason)
_cleanup()
disconnected.emit()
## Send a ping frame.
func send_ping() -> void:
send_json_message({"type": "ping"})
## Send presence heartbeat.
func send_heartbeat() -> void:
send_json_message({"type": "heartbeat"})
## Subscribe to world chat events.
func subscribe_world(world_id: int) -> void:
send_json_message({"type": "subscribe", "channel": "world", "id": world_id})
## Unsubscribe from world chat events.
func unsubscribe_world(world_id: int) -> void:
send_json_message({"type": "unsubscribe", "channel": "world", "id": world_id})
## Subscribe to DM conversation events.
func subscribe_dm(conversation_id: int) -> void:
send_json_message({"type": "subscribe", "channel": "dm", "id": conversation_id})
## Unsubscribe from DM conversation events.
func unsubscribe_dm(conversation_id: int) -> void:
send_json_message({"type": "unsubscribe", "channel": "dm", "id": conversation_id})
## Send any dictionary as a JSON text packet.
func send_json_message(data: Dictionary) -> Error:
if _peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
return ERR_UNCONFIGURED
var text := JSON.stringify(data)
return _peer.send_text(text)
func _handle_packet(packet: PackedByteArray) -> void:
var text := packet.get_string_from_utf8()
var parsed: Variant = JSON.parse_string(text)
if not (parsed is Dictionary):
return
var msg: Dictionary = parsed as Dictionary
var msg_type: String = str(msg.get("type", ""))
match msg_type:
"auth_ok":
_state = ConnectionState.AUTHENTICATED
_authenticated_user_id = int(msg.get("user_id", 0))
_authenticated_username = str(msg.get("username", ""))
authenticated.emit(_authenticated_user_id, _authenticated_username)
"presence":
var uid: int = int(msg.get("user_id", 0))
var p_status: String = str(msg.get("status", "offline"))
presence_changed.emit(uid, p_status)
"chat_message":
var channel: String = str(msg.get("channel", ""))
var target_id: int = int(msg.get("id", 0))
var data: Dictionary = msg.get("data", {}) if msg.get("data") is Dictionary else {}
chat_message_received.emit(channel, target_id, data)
"subscribed":
var channel: String = str(msg.get("channel", ""))
var id: int = int(msg.get("id", 0))
subscribed.emit(channel, id)
"unsubscribed":
var channel: String = str(msg.get("channel", ""))
var id: int = int(msg.get("id", 0))
unsubscribed.emit(channel, id)
"error":
var err_msg: String = str(msg.get("message", "Unknown error"))
gateway_error.emit(err_msg)
func _cleanup() -> void:
_state = ConnectionState.DISCONNECTED
_authenticated_user_id = 0
_authenticated_username = ""
_heartbeat_timer = 0.0