diff --git a/.idea/workspace.xml b/.idea/workspace.xml index d2358c7..1aa3f63 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,13 +2,7 @@ - - - - - - - + - { + "keyToString": { + "Gradle./home/cole/Projects/ChatSystem/ChatServer [ktlintFormat].executor": "Run", + "Gradle./home/cole/Projects/ChatSystem/ChatServer [test clean].executor": "Run", + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "feature/cleanup-codebase-for-stability", + "kotlin-language-version-configured": "true", + "last_opened_file_path": "/home/cole/Projects/ChatSystem" } -}]]> +} diff --git a/ApiServer/app/__pycache__/chat_client.cpython-314.pyc b/ApiServer/app/__pycache__/chat_client.cpython-314.pyc index bd3cf3a..a365aaf 100644 Binary files a/ApiServer/app/__pycache__/chat_client.cpython-314.pyc and b/ApiServer/app/__pycache__/chat_client.cpython-314.pyc differ diff --git a/ApiServer/app/__pycache__/dependencies.cpython-314.pyc b/ApiServer/app/__pycache__/dependencies.cpython-314.pyc index 03483a0..593214e 100644 Binary files a/ApiServer/app/__pycache__/dependencies.cpython-314.pyc and b/ApiServer/app/__pycache__/dependencies.cpython-314.pyc differ diff --git a/ApiServer/app/chat_client.py b/ApiServer/app/chat_client.py index 803a1ca..b1a3fe0 100644 --- a/ApiServer/app/chat_client.py +++ b/ApiServer/app/chat_client.py @@ -36,3 +36,14 @@ async def remove_conversation_member(conversation_id: str, user_id: str): ) if response.status_code != 204: raise HTTPException(status_code=502, detail="Failed to remove member on chat server") + + +async def get_conversation_messages(conversation_id: str) -> list: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{settings.chat_server_url}/conversations/{conversation_id}/messages", + headers=_INTERNAL_HEADERS, + ) + if not response.is_success: + raise HTTPException(status_code=502, detail="Failed to fetch messages") + return response.json() diff --git a/ApiServer/app/dependencies.py b/ApiServer/app/dependencies.py index 7321b37..2bdd950 100644 --- a/ApiServer/app/dependencies.py +++ b/ApiServer/app/dependencies.py @@ -7,7 +7,6 @@ from app.config import settings from app.database import AsyncSessionLocal from app.models.user import User -from app.schemas.token import TokenData oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") @@ -31,11 +30,10 @@ async def get_current_user( user_id: str = payload.get("sub") if user_id is None: raise credentials_exception - token_data = TokenData(user_id=user_id) except JWTError: raise credentials_exception - result = await db.execute(select(User).where(User.id == token_data.user_id)) + result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise credentials_exception diff --git a/ApiServer/app/repos/users.py b/ApiServer/app/repos/users.py deleted file mode 100644 index f8e21cb..0000000 --- a/ApiServer/app/repos/users.py +++ /dev/null @@ -1,9 +0,0 @@ -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User - - -async def get_user(user_id: str, db: AsyncSession) -> User | None: - result = await db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() diff --git a/ApiServer/app/routers/__pycache__/conversations.cpython-314.pyc b/ApiServer/app/routers/__pycache__/conversations.cpython-314.pyc index 931a930..d26e675 100644 Binary files a/ApiServer/app/routers/__pycache__/conversations.cpython-314.pyc and b/ApiServer/app/routers/__pycache__/conversations.cpython-314.pyc differ diff --git a/ApiServer/app/routers/conversations.py b/ApiServer/app/routers/conversations.py index 277225a..532be6a 100644 --- a/ApiServer/app/routers/conversations.py +++ b/ApiServer/app/routers/conversations.py @@ -1,8 +1,6 @@ -import httpx +from fastapi import APIRouter, Depends -from fastapi import APIRouter, Depends, HTTPException - -from app.config import settings +from app.chat_client import get_conversation_messages from app.dependencies import get_current_user from app.models.user import User @@ -14,11 +12,4 @@ async def get_messages( conversation_id: str, _: User = Depends(get_current_user), ): - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{settings.chat_server_url}/conversations/{conversation_id}/messages", - headers={"x-internal-key": settings.internal_api_key}, - ) - if not resp.is_success: - raise HTTPException(status_code=502, detail="Failed to fetch messages") - return resp.json() + return await get_conversation_messages(conversation_id) diff --git a/ApiServer/app/schemas/__pycache__/token.cpython-314.pyc b/ApiServer/app/schemas/__pycache__/token.cpython-314.pyc index 48b0c65..6b1c5dd 100644 Binary files a/ApiServer/app/schemas/__pycache__/token.cpython-314.pyc and b/ApiServer/app/schemas/__pycache__/token.cpython-314.pyc differ diff --git a/ApiServer/app/schemas/token.py b/ApiServer/app/schemas/token.py index 6b41ca7..3fded6a 100644 --- a/ApiServer/app/schemas/token.py +++ b/ApiServer/app/schemas/token.py @@ -4,7 +4,3 @@ class Token(BaseModel): access_token: str token_type: str - - -class TokenData(BaseModel): - user_id: str | None = None diff --git a/ApiServer/tests/__pycache__/test_conversations.cpython-314-pytest-9.0.2.pyc b/ApiServer/tests/__pycache__/test_conversations.cpython-314-pytest-9.0.2.pyc index 4418d77..c4e2638 100644 Binary files a/ApiServer/tests/__pycache__/test_conversations.cpython-314-pytest-9.0.2.pyc and b/ApiServer/tests/__pycache__/test_conversations.cpython-314-pytest-9.0.2.pyc differ diff --git a/ApiServer/tests/test_conversations.py b/ApiServer/tests/test_conversations.py index a393124..3400edc 100644 --- a/ApiServer/tests/test_conversations.py +++ b/ApiServer/tests/test_conversations.py @@ -1,6 +1,5 @@ -from unittest.mock import AsyncMock, MagicMock, patch - import pytest +from unittest.mock import AsyncMock, patch from tests.conftest import register_and_login @@ -11,17 +10,9 @@ def mock_chat_server_messages(): {"fromUserId": "alice-id", "conversationId": "conv-1", "content": "hello", "sentAt": "2024-01-01T00:00:00Z"}, {"fromUserId": "bob-id", "conversationId": "conv-1", "content": "hi", "sentAt": "2024-01-01T00:00:01Z"}, ] - mock_response = MagicMock() - mock_response.is_success = True - mock_response.json.return_value = messages - - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_response) - - with patch("app.routers.conversations.httpx.AsyncClient", return_value=mock_client): - yield mock_response.json.return_value + with patch("app.routers.conversations.get_conversation_messages", new_callable=AsyncMock) as mock: + mock.return_value = messages + yield messages async def test_get_messages(client, mock_chat_server_messages): diff --git a/ChatClient/src/components/chat/MessageBubble.tsx b/ChatClient/src/components/chat/MessageBubble.tsx index 55dfb55..c7dbcca 100644 --- a/ChatClient/src/components/chat/MessageBubble.tsx +++ b/ChatClient/src/components/chat/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import type { StoredMessage } from '../../types' import { useUserCacheStore } from '../../store/userCacheStore' import { getUser } from '../../lib/api' @@ -21,23 +21,16 @@ function formatTime(sentAt: string): string { export function MessageBubble({ message, currentUserId }: MessageBubbleProps) { const isMine = message.fromUserId === currentUserId - const getCache = useUserCacheStore((s) => s.getUser) const addUser = useUserCacheStore((s) => s.addUser) const cached = useUserCacheStore((s) => s.cache[message.fromUserId]) - const [, forceRender] = useState(0) useEffect(() => { if (!cached) { - getUser(message.fromUserId) - .then((user) => { - addUser(user) - forceRender((n) => n + 1) - }) - .catch(() => {}) + getUser(message.fromUserId).then(addUser).catch(() => {}) } }, [message.fromUserId]) - const username = getCache(message.fromUserId)?.username ?? message.fromUserId + const username = cached?.username ?? message.fromUserId return (
diff --git a/ChatClient/src/types/index.ts b/ChatClient/src/types/index.ts index 665f264..6327e37 100644 --- a/ChatClient/src/types/index.ts +++ b/ChatClient/src/types/index.ts @@ -17,12 +17,6 @@ export interface GroupResponse { created_at: string } -export interface GroupMemberResponse { - group_id: string - user_id: string - joined_at: string -} - export interface StoredMessage { fromUserId: string conversationId: string diff --git a/ChatInfra/modules/dynamodb/main.tf b/ChatInfra/modules/dynamodb/main.tf index 5867f2e..0940369 100644 --- a/ChatInfra/modules/dynamodb/main.tf +++ b/ChatInfra/modules/dynamodb/main.tf @@ -12,7 +12,7 @@ resource "aws_dynamodb_table" "server_registry" { enabled = true } - tags = { Name = "chatsystem-server-registry" } + tags = { Name = "${var.project_name}-server-registry" } } resource "aws_dynamodb_table" "user_connections" { @@ -29,7 +29,7 @@ resource "aws_dynamodb_table" "user_connections" { enabled = true } - tags = { Name = "chatsystem-user-connections" } + tags = { Name = "${var.project_name}-user-connections" } } resource "aws_dynamodb_table" "conversation_members" { @@ -58,7 +58,7 @@ resource "aws_dynamodb_table" "conversation_members" { enabled = true } - tags = { Name = "chatsystem-conversation-members" } + tags = { Name = "${var.project_name}-conversation-members" } } resource "aws_dynamodb_table" "messages" { @@ -81,7 +81,7 @@ resource "aws_dynamodb_table" "messages" { enabled = true } - tags = { Name = "chatsystem-messages" } + tags = { Name = "${var.project_name}-messages" } } resource "aws_dynamodb_table" "pending_messages" { @@ -104,5 +104,5 @@ resource "aws_dynamodb_table" "pending_messages" { enabled = true } - tags = { Name = "chatsystem-pending-messages" } + tags = { Name = "${var.project_name}-pending-messages" } } diff --git a/ChatInfra/terraform.tfvars b/ChatInfra/terraform.tfvars deleted file mode 100644 index 55731da..0000000 --- a/ChatInfra/terraform.tfvars +++ /dev/null @@ -1,3 +0,0 @@ -aws_region = "us-west-1" -project_name = "chatsystem" -db_name = "chatsystem" \ No newline at end of file diff --git a/ChatServer/docker-compose.yml b/ChatServer/docker-compose.yml index 385e8b7..c97c3d5 100644 --- a/ChatServer/docker-compose.yml +++ b/ChatServer/docker-compose.yml @@ -6,6 +6,6 @@ services: - "8080:8080" env_file: .env healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] + test: [ "CMD", "curl", "-f", "http://localhost:8080/health" ] interval: 30s retries: 3 \ No newline at end of file diff --git a/ChatServer/src/main/kotlin/data/repository/PendingMessageRepository.kt b/ChatServer/src/main/kotlin/data/repository/PendingMessageRepository.kt index b208bdb..9baf64e 100644 --- a/ChatServer/src/main/kotlin/data/repository/PendingMessageRepository.kt +++ b/ChatServer/src/main/kotlin/data/repository/PendingMessageRepository.kt @@ -5,9 +5,11 @@ import org.chatserver.models.ChatMessage import org.slf4j.LoggerFactory import software.amazon.awssdk.services.dynamodb.DynamoDbClient import software.amazon.awssdk.services.dynamodb.model.AttributeValue -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest +import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest +import software.amazon.awssdk.services.dynamodb.model.DeleteRequest import software.amazon.awssdk.services.dynamodb.model.PutItemRequest import software.amazon.awssdk.services.dynamodb.model.QueryRequest +import software.amazon.awssdk.services.dynamodb.model.WriteRequest import java.time.Instant import java.util.UUID @@ -61,11 +63,23 @@ class PendingMessageRepository(dynamoClient: DynamoDbClient) : AbstractDynamoCom ) } - response.items().forEach { item -> - dynamoClient.deleteItem( - DeleteItemRequest.builder() - .tableName(tableName) - .key(mapOf(partitionKey to item[partitionKey]!!, sortKey to item[sortKey]!!)) + response.items().chunked(25).forEach { chunk -> + dynamoClient.batchWriteItem( + BatchWriteItemRequest.builder() + .requestItems( + mapOf( + tableName to + chunk.map { item -> + WriteRequest.builder() + .deleteRequest( + DeleteRequest.builder() + .key(mapOf(partitionKey to item[partitionKey]!!, sortKey to item[sortKey]!!)) + .build(), + ) + .build() + }, + ), + ) .build(), ) } diff --git a/ChatServer/src/main/kotlin/plugins/ConversationRoutes.kt b/ChatServer/src/main/kotlin/plugins/ConversationRoutes.kt index 5ba115f..c2fc4a9 100644 --- a/ChatServer/src/main/kotlin/plugins/ConversationRoutes.kt +++ b/ChatServer/src/main/kotlin/plugins/ConversationRoutes.kt @@ -16,7 +16,6 @@ fun Application.configureConversationRoutes( internalApiKey: String = System.getenv("INTERNAL_API_KEY") ?: error("INTERNAL_API_KEY env var not set"), ) { val conversationRegistry by inject() - conversationRegistry.init() routing { post("/conversations") { diff --git a/ChatServer/src/test/kotlin/data/repository/PendingMessageRepositoryTest.kt b/ChatServer/src/test/kotlin/data/repository/PendingMessageRepositoryTest.kt index 9a4ca09..820ac26 100644 --- a/ChatServer/src/test/kotlin/data/repository/PendingMessageRepositoryTest.kt +++ b/ChatServer/src/test/kotlin/data/repository/PendingMessageRepositoryTest.kt @@ -6,8 +6,8 @@ import io.mockk.verify import org.chatserver.models.ChatMessage import software.amazon.awssdk.services.dynamodb.DynamoDbClient import software.amazon.awssdk.services.dynamodb.model.AttributeValue +import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest import software.amazon.awssdk.services.dynamodb.model.PutItemRequest import software.amazon.awssdk.services.dynamodb.model.QueryRequest @@ -98,7 +98,7 @@ class PendingMessageRepositoryTest { repository.fetchAndClear("bob") - verify(exactly = 2) { dynamoClient.deleteItem(any()) } + verify(exactly = 1) { dynamoClient.batchWriteItem(any()) } } @Test @@ -109,6 +109,6 @@ class PendingMessageRepositoryTest { val messages = repository.fetchAndClear("alice") assertTrue(messages.isEmpty()) - verify(exactly = 0) { dynamoClient.deleteItem(any()) } + verify(exactly = 0) { dynamoClient.batchWriteItem(any()) } } } diff --git a/PresenceServer/Dockerfile b/PresenceServer/Dockerfile index b592cf9..160ebd9 100644 --- a/PresenceServer/Dockerfile +++ b/PresenceServer/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim AS build +FROM python:3.14-slim WORKDIR /app COPY requirements.txt . diff --git a/PresenceServer/app/routers/__pycache__/presence.cpython-314.pyc b/PresenceServer/app/routers/__pycache__/presence.cpython-314.pyc index 3c35ccc..d2f221a 100644 Binary files a/PresenceServer/app/routers/__pycache__/presence.cpython-314.pyc and b/PresenceServer/app/routers/__pycache__/presence.cpython-314.pyc differ diff --git a/PresenceServer/app/routers/presence.py b/PresenceServer/app/routers/presence.py index e872120..1dc7a15 100644 --- a/PresenceServer/app/routers/presence.py +++ b/PresenceServer/app/routers/presence.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from redis.asyncio import Redis -from app.dependencies import get_current_user_id, verify_internal_key # get_current_user_id used by GET /{user_id} +from app.dependencies import get_current_user_id, verify_internal_key from app.redis import get_redis from app.repos.presence import set_online, is_online, are_online from app.schemas.presence import HeartbeatRequest, PresenceResponse, BatchPresenceRequest, BatchPresenceResponse diff --git a/PresenceServer/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc b/PresenceServer/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc index d507b9f..d0ac97c 100644 Binary files a/PresenceServer/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc and b/PresenceServer/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc differ diff --git a/PresenceServer/tests/__pycache__/test_presence.cpython-314-pytest-9.0.2.pyc b/PresenceServer/tests/__pycache__/test_presence.cpython-314-pytest-9.0.2.pyc index d81bc95..a480267 100644 Binary files a/PresenceServer/tests/__pycache__/test_presence.cpython-314-pytest-9.0.2.pyc and b/PresenceServer/tests/__pycache__/test_presence.cpython-314-pytest-9.0.2.pyc differ diff --git a/PresenceServer/tests/conftest.py b/PresenceServer/tests/conftest.py index 1d6de55..4a15fe8 100644 --- a/PresenceServer/tests/conftest.py +++ b/PresenceServer/tests/conftest.py @@ -7,6 +7,12 @@ from app.redis import get_redis +def _make_redis_override(redis): + async def override(): + return redis + return override + + @pytest_asyncio.fixture async def fake_redis(): redis = FakeRedis() @@ -16,16 +22,13 @@ async def fake_redis(): @pytest_asyncio.fixture async def client(fake_redis): - async def override_get_redis(): - return fake_redis - async def override_get_current_user_id(): return "test-user-id" async def override_verify_internal_key(): return None - app.dependency_overrides[get_redis] = override_get_redis + app.dependency_overrides[get_redis] = _make_redis_override(fake_redis) app.dependency_overrides[get_current_user_id] = override_get_current_user_id app.dependency_overrides[verify_internal_key] = override_verify_internal_key @@ -37,10 +40,7 @@ async def override_verify_internal_key(): @pytest_asyncio.fixture async def unauthed_client(fake_redis): - async def override_get_redis(): - return fake_redis - - app.dependency_overrides[get_redis] = override_get_redis + app.dependency_overrides[get_redis] = _make_redis_override(fake_redis) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac diff --git a/PresenceServer/tests/test_presence.py b/PresenceServer/tests/test_presence.py index 7ab2245..718935a 100644 --- a/PresenceServer/tests/test_presence.py +++ b/PresenceServer/tests/test_presence.py @@ -68,3 +68,14 @@ async def test_batch_presence_wrong_key(unauthed_client): headers={"x-internal-key": "wrongkey"}, ) assert resp.status_code == 403 + + +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +async def test_heartbeat_missing_key(unauthed_client): + resp = await unauthed_client.post("/presence/heartbeat", json={"user_ids": ["user-1"]}) + assert resp.status_code == 422