From a8755bd3de4aee3c47fa15ea63bc5f27f8d9d8e04007d404e970920810ac9bf9 Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:29:38 -0400 Subject: [PATCH 1/3] Add comprehensive documentation and unit tests Documentation: - Add detailed README files for all services (auth, character, database, launcher, packet, utils, world) - Create API documentation for the database service with detailed endpoint specifications - Document database schema and relationships - Add service architecture overviews and configuration instructions Unit Tests: - Implement comprehensive test suite for database repositories (user, character, session) - Add gRPC service tests for database interactions - Create tests for packet service components (bufferpool, connection, packets) - Add utility service tests (health check, logging, load balancer, redis cache, service discovery) - Implement auth service user tests - Add character service tests Code Structure: - Reorganize test files into a more consistent structure - Create a dedicated tests crate for integration testing - Add test helpers and mock implementations for easier testing --- Cargo.toml | 11 +- README.md | 111 ++++++- auth-service/README.md | 94 ++++++ auth-service/build.rs | 5 +- auth-service/src/database_client.rs | 88 +---- auth-service/src/grpc.rs | 161 ++------- auth-service/src/main.rs | 15 +- .../src/mocks/database_client_mock.rs | 7 +- auth-service/src/session_client.rs | 21 +- auth-service/src/users.rs | 5 +- auth-service/tests/integration.rs | 65 ---- character-service/Cargo.toml | 1 - character-service/README.md | 93 ++++++ character-service/build.rs | 5 +- character-service/src/character_db_client.rs | 7 +- character-service/src/character_service.rs | 14 +- character-service/src/database.rs | 5 + character-service/src/lib.rs | 3 + character-service/src/main.rs | 19 +- database-service/API.md | 229 +++++++++++++ database-service/README.md | 111 +++++++ database-service/SCHEMA.md | 129 ++++++++ database-service/src/characters.rs | 16 +- database-service/src/db.rs | 4 +- .../src/grpc/character_service.rs | 10 +- database-service/src/grpc/mod.rs | 4 +- database-service/src/grpc/session_service.rs | 34 +- database-service/src/grpc/user_service.rs | 10 +- database-service/src/lib.rs | 2 +- database-service/src/main.rs | 6 +- database-service/src/sessions.rs | 49 +-- .../src/tests/character_repository_tests.rs | 306 ++++++++++++++++++ database-service/src/tests/grpc_tests.rs | 288 +++++++++++++++++ database-service/src/tests/mod.rs | 4 + .../src/tests/session_repository_tests.rs | 176 ++++++++++ .../src/tests/user_repository_tests.rs | 169 ++++++++++ database-service/src/users.rs | 46 ++- database-service/tests/get_user.rs | 29 -- database-service/tests/grpc_get_user.rs | 20 -- launcher/README.md | 78 +++++ launcher/src/launcher.rs | 12 +- packet-service/Cargo.toml | 9 + packet-service/README.md | 107 ++++++ packet-service/src/auth_client.rs | 5 +- packet-service/src/character_client.rs | 5 +- packet-service/src/connection_service.rs | 7 +- packet-service/src/handlers/auth.rs | 9 +- packet-service/src/handlers/character.rs | 41 +-- packet-service/src/handlers/world.rs | 36 +-- packet-service/src/lib.rs | 7 + packet-service/src/main.rs | 22 +- packet-service/src/metrics.rs | 3 +- packet-service/src/router.rs | 3 +- proto/auth.proto | 29 -- tests/Cargo.toml | 94 ++++++ tests/README.md | 72 +++++ tests/auth-service/users_tests.rs | 70 ++++ .../character_service_tests.rs | 251 ++++++++++++++ tests/database-service/get_user.rs | 78 +++++ tests/database-service/grpc_get_user.rs | 92 ++++++ .../database-service}/integration.rs | 0 tests/database-service/mock_tests.rs | 137 ++++++++ .../database-service}/redis_cache.rs | 0 tests/database-service/test_helpers.rs | 151 +++++++++ tests/packet-service/bufferpool_tests.rs | 96 ++++++ .../connection_service_tests.rs | 108 +++++++ tests/packet-service/packet_tests.rs | 98 ++++++ tests/src/lib.rs | 18 ++ tests/utils/health_check_tests.rs | 64 ++++ tests/utils/logging_tests.rs | 42 +++ .../multi_service_load_balancer_tests.rs | 151 +++++++++ tests/utils/redis_cache_tests.rs | 152 +++++++++ tests/utils/service_discovery_tests.rs | 84 +++++ utils/README.md | 81 +++++ utils/src/consul_registration.rs | 14 +- utils/src/health_check.rs | 9 +- utils/src/lib.rs | 6 +- utils/src/logging.rs | 18 +- utils/src/multi_service_load_balancer.rs | 189 ++++++----- utils/src/redis_cache.rs | 10 +- utils/src/service_discovery.rs | 36 ++- utils/src/signal_handler.rs | 6 +- world-service/README.md | 95 ++++++ world-service/build.rs | 5 +- world-service/src/main.rs | 10 +- 85 files changed, 4218 insertions(+), 764 deletions(-) create mode 100644 auth-service/README.md delete mode 100644 auth-service/tests/integration.rs create mode 100644 character-service/README.md create mode 100644 character-service/src/database.rs create mode 100644 character-service/src/lib.rs create mode 100644 database-service/API.md create mode 100644 database-service/README.md create mode 100644 database-service/SCHEMA.md create mode 100644 database-service/src/tests/character_repository_tests.rs create mode 100644 database-service/src/tests/grpc_tests.rs create mode 100644 database-service/src/tests/mod.rs create mode 100644 database-service/src/tests/session_repository_tests.rs create mode 100644 database-service/src/tests/user_repository_tests.rs delete mode 100644 database-service/tests/get_user.rs delete mode 100644 database-service/tests/grpc_get_user.rs create mode 100644 launcher/README.md create mode 100644 packet-service/README.md create mode 100644 packet-service/src/lib.rs create mode 100644 tests/Cargo.toml create mode 100644 tests/README.md create mode 100644 tests/auth-service/users_tests.rs create mode 100644 tests/character-service/character_service_tests.rs create mode 100644 tests/database-service/get_user.rs create mode 100644 tests/database-service/grpc_get_user.rs rename {database-service/tests => tests/database-service}/integration.rs (100%) create mode 100644 tests/database-service/mock_tests.rs rename {database-service/tests => tests/database-service}/redis_cache.rs (100%) create mode 100644 tests/database-service/test_helpers.rs create mode 100644 tests/packet-service/bufferpool_tests.rs create mode 100644 tests/packet-service/connection_service_tests.rs create mode 100644 tests/packet-service/packet_tests.rs create mode 100644 tests/src/lib.rs create mode 100644 tests/utils/health_check_tests.rs create mode 100644 tests/utils/logging_tests.rs create mode 100644 tests/utils/multi_service_load_balancer_tests.rs create mode 100644 tests/utils/redis_cache_tests.rs create mode 100644 tests/utils/service_discovery_tests.rs create mode 100644 utils/README.md create mode 100644 world-service/README.md diff --git a/Cargo.toml b/Cargo.toml index 0fc1bed..37717d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "auth-service", "character-service", @@ -6,6 +7,12 @@ members = [ "packet-service", "world-service", "utils", - "launcher" + "launcher", ] -resolver = "2" + +[workspace.package] +edition = "2021" + +# Include tests directory only when testing +[workspace.metadata.test] +members = ["tests"] diff --git a/README.md b/README.md index f625774..fe0df58 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,111 @@ -# osirose-new +# MMORPG Server Architecture +A microservice-based server architecture for an MMORPG game, built with Rust. + +## Overview + +This project implements a complete server infrastructure for an MMORPG game, using a microservice architecture for scalability and maintainability. Each service is responsible for a specific domain of the game, communicating with other services via gRPC. + +## Architecture + +The server architecture consists of the following microservices: + +### Core Services + +- **Auth Service**: Handles user authentication, session validation, and account management +- **Character Service**: Manages character creation, deletion, and retrieval +- **Database Service**: Provides centralized database access for all services +- **Packet Service**: Handles game client communication via custom binary packets +- **World Service**: Manages game world state and character interactions + +### Support Components + +- **Utils**: Shared utilities used by all services +- **Launcher**: Client-side launcher for the game + +## Communication Flow + +1. **Client → Launcher**: User launches the game via the launcher +2. **Launcher → Packet Service**: Game client connects to the packet service +3. **Packet Service → Auth Service**: Validates user session +4. **Packet Service → Character Service**: Retrieves character data +5. **Packet Service → World Service**: Manages game world interactions + +## Technologies + +- **Language**: Rust +- **Communication**: gRPC, custom binary protocol +- **Database**: PostgreSQL (managed by external system) +- **Caching**: Redis +- **Service Discovery**: Kubernetes DNS, Consul +- **Metrics**: Prometheus + +## External Dependencies + +- **Database Schema**: Managed by an external web application using better-auth +- **User Management**: Handled by the external web application +- **Session Creation**: Initial sessions created by the external web application + +## Getting Started + +### Prerequisites + +- Rust (latest stable) +- PostgreSQL +- Redis +- Protobuf compiler + +### Building + +```bash +cargo build --release +``` + +### Running + +Each service can be run individually: + +```bash +# Auth Service +cd auth-service && cargo run + +# Character Service +cd character-service && cargo run + +# Database Service +cd database-service && cargo run + +# Packet Service +cd packet-service && cargo run + +# World Service +cd world-service && cargo run +``` + +### Docker + +Each service includes a Dockerfile for containerized deployment. + +## Documentation + +Each service includes its own README.md with detailed documentation: + +- [Auth Service](auth-service/README.md) +- [Character Service](character-service/README.md) +- [Database Service](database-service/README.md) +- [Packet Service](packet-service/README.md) +- [World Service](world-service/README.md) +- [Utils](utils/README.md) +- [Launcher](launcher/README.md) + +## Testing + +Run tests for all services: + +```bash +cargo test --all +``` + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. diff --git a/auth-service/README.md b/auth-service/README.md new file mode 100644 index 0000000..ac069da --- /dev/null +++ b/auth-service/README.md @@ -0,0 +1,94 @@ +# Authentication Service + +The Authentication Service is responsible for user authentication, session validation, and account management in the MMORPG server architecture. + +## Overview + +The Authentication Service provides gRPC endpoints for: +- User login and logout +- Session validation and refresh + +It communicates with the external database service to verify user credentials and manage sessions. + +## Architecture + +The service is built using the following components: + +- **gRPC Server**: Exposes authentication endpoints +- **Database Client**: Communicates with the database service for user data +- **Session Client**: Manages user sessions +- **Password Hashing**: Securely handles password verification + +> **Note**: User registration and password reset functionality are handled by an external system. + +## Service Endpoints + +The Authentication Service exposes the following gRPC endpoints: + +### Login +Authenticates a user and creates a new session. + +```protobuf +rpc Login(LoginRequest) returns (LoginResponse); +``` + +### Logout +Terminates a user session. + +```protobuf +rpc Logout(LogoutRequest) returns (Empty); +``` + +### ValidateToken +Validates a JWT token. + +```protobuf +rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse); +``` + +### ValidateSession +Validates a session ID. + +```protobuf +rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse); +``` + +### RefreshSession +Refreshes an existing session. + +```protobuf +rpc RefreshSession(ValidateSessionRequest) returns (RefreshSessionResponse); +``` + + + +## Configuration + +The service can be configured using environment variables: + +- `LISTEN_ADDR`: The address to listen on (default: "0.0.0.0") +- `SERVICE_PORT`: The port to listen on (default: "50051") +- `LOG_LEVEL`: Logging level (default: "info") + +## Running the Service + +### Local Development + +```bash +cargo run +``` + +### Docker + +```bash +docker build -t auth-service . +docker run -p 50051:50051 auth-service +``` + +## Integration with External Systems + +The Authentication Service integrates with: + +- **Database Service**: For user data storage and retrieval +- **Session Service**: For session management +- **External Auth System**: The service is designed to work with an external authentication system (better-auth) that manages the database schema and user accounts diff --git a/auth-service/build.rs b/auth-service/build.rs index c496210..74bffd8 100644 --- a/auth-service/build.rs +++ b/auth-service/build.rs @@ -12,10 +12,7 @@ fn main() { .build_server(false) // Generate gRPC client code .compile_well_known_types(true) .compile_protos( - &[ - "../proto/user_db_api.proto", - "../proto/session_db_api.proto", - ], + &["../proto/user_db_api.proto", "../proto/session_db_api.proto"], &["../proto"], ) .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); diff --git a/auth-service/src/database_client.rs b/auth-service/src/database_client.rs index 4bfd15a..cf48f14 100644 --- a/auth-service/src/database_client.rs +++ b/auth-service/src/database_client.rs @@ -1,56 +1,23 @@ -use crate::database::{user_service_client::UserServiceClient, GetUserByEmailRequest, GetUserByUsernameRequest, GetUserRequest, GetUserResponse}; +use crate::database::{ + user_service_client::UserServiceClient, GetUserByEmailRequest, GetUserByUsernameRequest, GetUserRequest, + GetUserResponse, +}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use std::error::Error; use tonic::transport::Channel; #[async_trait] pub trait DatabaseClientTrait: Sized { async fn connect(endpoint: &str) -> Result>; - async fn get_user_by_userid( - &mut self, - user_id: i32, - ) -> Result>; - async fn get_user_by_username( - &mut self, - user_id: &str, - ) -> Result>; - async fn get_user_by_email( - &mut self, - email: &str, - ) -> Result>; - async fn store_password_reset( - &mut self, - email: &str, - reset_token: &str, - expires_at: DateTime, - ) -> Result<(), Box>; - async fn get_password_reset( - &self, - reset_token: &str, - ) -> Result, Box>; - async fn delete_password_reset( - &self, - reset_token: &str, - ) -> Result<(), Box>; - async fn update_user_password( - &self, - email: &str, - hashed_password: &str, - ) -> Result<(), Box>; + async fn get_user_by_userid(&mut self, user_id: i32) -> Result>; + async fn get_user_by_username(&mut self, user_id: &str) -> Result>; + async fn get_user_by_email(&mut self, email: &str) -> Result>; } #[derive(Clone)] pub struct DatabaseClient { client: UserServiceClient, } -#[derive(Debug)] -pub struct PasswordReset { - pub email: String, - pub reset_token: String, - pub expires_at: DateTime, -} - #[async_trait] impl DatabaseClientTrait for DatabaseClient { async fn connect(endpoint: &str) -> Result> { @@ -58,19 +25,13 @@ impl DatabaseClientTrait for DatabaseClient { Ok(Self { client }) } - async fn get_user_by_userid( - &mut self, - user_id: i32, - ) -> Result> { + async fn get_user_by_userid(&mut self, user_id: i32) -> Result> { let request = tonic::Request::new(GetUserRequest { user_id }); let response = self.client.get_user(request).await?; Ok(response.into_inner()) } - async fn get_user_by_username( - &mut self, - username: &str, - ) -> Result> { + async fn get_user_by_username(&mut self, username: &str) -> Result> { let request = tonic::Request::new(GetUserByUsernameRequest { username: username.to_string(), }); @@ -85,35 +46,4 @@ impl DatabaseClientTrait for DatabaseClient { let response = self.client.get_user_by_email(request).await?; Ok(response.into_inner()) } - - async fn store_password_reset( - &mut self, - _email: &str, - _reset_token: &str, - _expires_at: DateTime, - ) -> Result<(), Box> { - Ok(()) - } - - async fn get_password_reset( - &self, - _reset_token: &str, - ) -> Result, Box> { - todo!() - } - - async fn delete_password_reset( - &self, - _reset_token: &str, - ) -> Result<(), Box> { - Ok(()) - } - - async fn update_user_password( - &self, - _email: &str, - _hashed_password: &str, - ) -> Result<(), Box> { - Ok(()) - } } diff --git a/auth-service/src/grpc.rs b/auth-service/src/grpc.rs index 7e42bc6..90ad105 100644 --- a/auth-service/src/grpc.rs +++ b/auth-service/src/grpc.rs @@ -1,16 +1,12 @@ use crate::auth::auth_service_server::AuthService; use crate::auth::{ - LoginRequest, LoginResponse, LogoutRequest, PasswordResetRequest, PasswordResetResponse, - RefreshSessionResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, - ResetPasswordResponse, ValidateSessionRequest, ValidateSessionResponse, ValidateTokenRequest, ValidateTokenResponse, + LoginRequest, LoginResponse, LogoutRequest, RefreshSessionResponse, ValidateSessionRequest, + ValidateSessionResponse, ValidateTokenRequest, ValidateTokenResponse, }; use crate::common::Empty; -use crate::database_client::{DatabaseClient, DatabaseClientTrait}; +use crate::database_client::{DatabaseClient}; use crate::session::session_service_client::SessionServiceClient; use crate::session::{GetSessionRequest, RefreshSessionRequest}; -use crate::users::{hash_password, verify_user}; -use chrono::{Duration, Utc}; -use rand::Rng; use std::sync::Arc; use tonic::{Request, Response, Status}; use tracing::{debug, error, info, warn}; @@ -22,22 +18,19 @@ pub struct MyAuthService { #[tonic::async_trait] impl AuthService for MyAuthService { - async fn login( - &self, - request: Request, - ) -> Result, Status> { + async fn login(&self, _request: Request) -> Result, Status> { Err(Status::unimplemented("login not implemented due to changes")) } async fn logout(&self, request: Request) -> Result, Status> { - let req = request.into_inner(); + let _req = request.into_inner(); Ok(Response::new(Empty {})) } async fn validate_token( &self, - request: Request, + _request: Request, ) -> Result, Status> { Ok(Response::new(ValidateTokenResponse { valid: false, @@ -51,20 +44,32 @@ impl AuthService for MyAuthService { request: Request, ) -> Result, Status> { let req = request.into_inner(); - let response = self.session_client.as_ref().clone() + let response = self + .session_client + .as_ref() + .clone() .get_session(GetSessionRequest { session_id: req.session_id, - }).await; + }) + .await; match response { Ok(res) => { let res = res.into_inner(); debug!("Session valid: {:?}", res); - Ok(Response::new(ValidateSessionResponse { valid: true, session_id: res.session_id.to_string(), user_id: res.user_id.to_string() })) + Ok(Response::new(ValidateSessionResponse { + valid: true, + session_id: res.session_id.to_string(), + user_id: res.user_id.to_string(), + })) } Err(error) => { debug!("Session invalid or not found: {error}"); - Ok(Response::new(ValidateSessionResponse { valid: false, session_id: "".to_string(), user_id: "".to_string() })) + Ok(Response::new(ValidateSessionResponse { + valid: false, + session_id: "".to_string(), + user_id: "".to_string(), + })) } } } @@ -95,126 +100,4 @@ impl AuthService for MyAuthService { } } } - - async fn register( - &self, - request: Request, - ) -> Result, Status> { - // let req = request.into_inner(); - // - // // Hash the password - // let hashed_password = hash_password(&req.password); - // - // // Create user in the database - // let result = self - // .db_client - // .as_ref() - // .clone() - // .create_user(&req.username, &req.email, &hashed_password) - // .await; - // - // match result { - // Ok(user) => Ok(Response::new(RegisterResponse { - // user_id: user.user_id, - // message: "User registered successfully".into(), - // })), - // Err(e) => { - // error!("Failed to register user: {:?}", e); - // Err(Status::internal("Failed to register user")) - // } - // } - Err(Status::unimplemented("register not implemented")) - } - - async fn request_password_reset( - &self, - request: Request, - ) -> Result, Status> { - let email = request.into_inner().email; - - let user = self - .db_client - .as_ref() - .clone() - .get_user_by_email(&email) - .await; - - // Check if the email exists - if user.ok().is_some() { - // Generate a reset token - let reset_token: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(32) - .map(char::from) - .collect(); - - // Set token expiration (e.g., 1 hour) - let expires_at = Utc::now() + Duration::hours(1); - - // Store the reset token in the database - self.db_client - .as_ref() - .clone() - .store_password_reset(&email, &reset_token, expires_at) - .await - .map_err(|e| Status::internal(format!("Database error: {}", e)))?; - - // Send the reset email - // send_email(&email, "Password Reset Request", &format!( - // "Click the link to reset your password: https://azgstudio.com/reset?token={}", - // reset_token - // )) - // .map_err(|e| Status::internal(format!("Email error: {}", e)))?; - - Ok(Response::new(PasswordResetResponse { - message: "Password reset email sent".to_string(), - })) - } else { - // Respond with a generic message to avoid information leaks - Ok(Response::new(PasswordResetResponse { - message: "If the email exists, a reset link has been sent.".to_string(), - })) - } - } - - async fn reset_password( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - - // Validate the reset token - if let Some(password_reset) = self - .db_client - .clone() - .get_password_reset(&req.reset_token) - .await - .map_err(|e| Status::internal(format!("Database error: {}", e)))? - { - if password_reset.expires_at < Utc::now() { - return Err(Status::unauthenticated("Token expired")); - } - - // Hash the new password - let hashed_password = hash_password(&req.new_password); - - // Update the user's password - self.db_client - .update_user_password(&password_reset.email, &hashed_password) - .await - .map_err(|e| Status::internal(format!("Database error: {}", e)))?; - - // Delete the reset token - self.db_client - .delete_password_reset(&req.reset_token) - .await - .map_err(|e| Status::internal(format!("Database error: {}", e)))?; - - Ok(Response::new(ResetPasswordResponse { - message: "Password successfully reset".to_string(), - })) - } else { - Err(Status::unauthenticated("Invalid reset token")) - } - } } diff --git a/auth-service/src/main.rs b/auth-service/src/main.rs index 2b3a339..513c144 100644 --- a/auth-service/src/main.rs +++ b/auth-service/src/main.rs @@ -8,9 +8,8 @@ use std::env; use std::sync::Arc; use tonic::transport::Server; use tracing::info; -use tracing_subscriber::{fmt, EnvFilter}; use utils::logging; -use utils::service_discovery::{get_kube_service_endpoints_by_dns}; +use utils::service_discovery::get_kube_service_endpoints_by_dns; #[tokio::main] async fn main() -> Result<(), Box> { @@ -19,11 +18,17 @@ async fn main() -> Result<(), Box> { let app_name = env!("CARGO_PKG_NAME"); logging::setup_logging(app_name, &["auth_service"]); - + // Set the gRPC server address let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "50051".to_string()); - let db_url = format!("http://{}",get_kube_service_endpoints_by_dns("database-service","tcp","database-service").await?.get(0).unwrap()); + let db_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("database-service", "tcp", "database-service") + .await? + .get(0) + .unwrap() + ); let db_client = Arc::new(DatabaseClient::connect(&db_url).await?); let session_client = Arc::new(SessionServiceClient::connect(db_url).await?); @@ -32,7 +37,7 @@ async fn main() -> Result<(), Box> { let address = full_addr.parse().expect("Invalid address"); let auth_service = MyAuthService { db_client, - session_client + session_client, }; let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); diff --git a/auth-service/src/mocks/database_client_mock.rs b/auth-service/src/mocks/database_client_mock.rs index efb0d18..310338d 100644 --- a/auth-service/src/mocks/database_client_mock.rs +++ b/auth-service/src/mocks/database_client_mock.rs @@ -1,7 +1,6 @@ use crate::database::GetUserResponse; -use crate::database_client::{DatabaseClientTrait, PasswordReset}; +use crate::database_client::DatabaseClientTrait; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use mockall::{mock, predicate::*}; use std::error::Error; @@ -15,10 +14,6 @@ mock! { async fn get_user_by_userid(&mut self, user_id: i32) -> Result>; async fn get_user_by_username(&mut self, user_id: &str) -> Result>; async fn get_user_by_email(&mut self, email: &str) -> Result>; - async fn store_password_reset(&mut self, email: &str, reset_token: &str, expires_at: DateTime) -> Result<(), Box>; - async fn get_password_reset(&self, reset_token: &str) -> Result, Box>; - async fn delete_password_reset(&self, reset_token: &str) -> Result<(), Box>; - async fn update_user_password(&self, email: &str, hashed_password: &str) -> Result<(), Box>; } } diff --git a/auth-service/src/session_client.rs b/auth-service/src/session_client.rs index bb440d2..00c1558 100644 --- a/auth-service/src/session_client.rs +++ b/auth-service/src/session_client.rs @@ -1,16 +1,15 @@ -use crate::session::{session_service_client::SessionServiceClient, GetSessionRequest, GetSessionResponse, RefreshSessionRequest, RefreshSessionResponse}; +use crate::session::{ + session_service_client::SessionServiceClient, GetSessionRequest, GetSessionResponse, RefreshSessionRequest, + RefreshSessionResponse, +}; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use std::error::Error; use tonic::transport::Channel; #[async_trait] pub trait SessionClientTrait: Sized { async fn connect(endpoint: &str) -> Result>; - async fn get_session( - &mut self, - session_id: String, - ) -> Result>; + async fn get_session(&mut self, session_id: String) -> Result>; async fn refresh_session( &mut self, session_id: String, @@ -27,18 +26,14 @@ impl SessionClientTrait for SessionClient { let client = SessionServiceClient::connect(endpoint.to_string()).await?; Ok(Self { client }) } - + async fn get_session(&mut self, session_id: String) -> Result> { - let request = tonic::Request::new(GetSessionRequest { - session_id, - }); + let request = tonic::Request::new(GetSessionRequest { session_id }); let response = self.client.get_session(request).await?; Ok(response.into_inner()) } async fn refresh_session(&mut self, session_id: String) -> Result> { - let request = tonic::Request::new(RefreshSessionRequest { - session_id, - }); + let request = tonic::Request::new(RefreshSessionRequest { session_id }); let response = self.client.refresh_session(request).await?; Ok(response.into_inner()) } diff --git a/auth-service/src/users.rs b/auth-service/src/users.rs index d29f788..7f9daeb 100644 --- a/auth-service/src/users.rs +++ b/auth-service/src/users.rs @@ -9,10 +9,7 @@ use argon2::{ pub fn hash_password(password: &str) -> String { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - argon2 - .hash_password(password.as_ref(), &salt) - .unwrap() - .to_string() + argon2.hash_password(password.as_ref(), &salt).unwrap().to_string() } pub fn verify_password(password: &str, hash: &str) -> bool { diff --git a/auth-service/tests/integration.rs b/auth-service/tests/integration.rs deleted file mode 100644 index 909499a..0000000 --- a/auth-service/tests/integration.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(test)] -mod tests { - use dotenv::dotenv; - // use auth_service::mocks::database_client_mock::MockDatabaseClient; - - #[tokio::test] - async fn test_login() { - // dotenv().ok(); - // let mut db_client = MockDatabaseClient::new(); - // - // db_client - // .expect_get_user_by_username() - // .with(mockall::predicate::eq("test")) - // .returning(|user_id| { - // Ok(GetUserResponse { - // user_id: 1, - // username: "test".to_string(), - // email: "test@test.com".to_string(), - // hashed_password: "test".to_string(), - // }) - // }); - // - // - // let auth_service = MyAuthService { - // db_client, - // }; - // - // // Create a test LoginRequest - // let request = Request::new(LoginRequest { - // username: "test".into(), - // password: "test".into(), - // }); - // - // // Call the login method - // let response = auth_service.login(request).await.unwrap().into_inner(); - // - // // Verify the response - // assert!(!response.token.is_empty()); - // assert_eq!(response.user_id, "1"); // Replace with the expected user ID - } - - #[tokio::test] - async fn test_validate_token() { - dotenv().ok(); - // let addr = std::env::var("DATABASE_SERVICE_ADDR").unwrap_or_else(|_| "127.0.0.1:50052".to_string()); - // let db_client = DatabaseClient::connect(&addr).await.unwrap(); - // - // let auth_service = MyAuthService { - // db_client, - // }; - // - // // Generate a token for testing - // let token = jwt::generate_token("123", Vec::from(["".to_string()])).unwrap(); - // - // // Create a ValidateTokenRequest - // let request = Request::new(ValidateTokenRequest { token }); - // - // // Call the validate_token method - // let response = auth_service.validate_token(request).await.unwrap().into_inner(); - // - // // Verify the response - // assert!(response.valid); - // assert_eq!(response.user_id, "123"); - } -} diff --git a/character-service/Cargo.toml b/character-service/Cargo.toml index a19b863..f288d7f 100644 --- a/character-service/Cargo.toml +++ b/character-service/Cargo.toml @@ -12,7 +12,6 @@ tracing = "0.1" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "chrono"] } tonic = "0.12.3" prost = "0.13.4" -warp = "0.3.7" async-trait = "0.1.83" serde_json = "1.0.133" tonic-health = "0.12.3" diff --git a/character-service/README.md b/character-service/README.md new file mode 100644 index 0000000..98ba26c --- /dev/null +++ b/character-service/README.md @@ -0,0 +1,93 @@ +# Character Service + +The Character Service manages character creation, deletion, and retrieval in the MMORPG server architecture. + +## Overview + +The Character Service provides gRPC endpoints for: +- Retrieving character lists for users +- Creating new characters +- Deleting characters +- Retrieving detailed character information + +It communicates with the database service to store and retrieve character data. + +## Architecture + +The service is built using the following components: + +- **gRPC Server**: Exposes character management endpoints +- **Character DB Client**: Communicates with the database service for character data + +## Service Endpoints + +The Character Service exposes the following gRPC endpoints: + +### GetCharacterList +Retrieves a list of characters for a user. + +```protobuf +rpc GetCharacterList(GetCharacterListRequest) returns (GetCharacterListResponse); +``` + +### CreateCharacter +Creates a new character for a user. + +```protobuf +rpc CreateCharacter(CreateCharacterRequest) returns (CreateCharacterResponse); +``` + +### DeleteCharacter +Marks a character for deletion or permanently deletes it. + +```protobuf +rpc DeleteCharacter(DeleteCharacterRequest) returns (DeleteCharacterResponse); +``` + +### GetCharacter +Retrieves detailed information about a specific character. + +```protobuf +rpc GetCharacter(GetCharacterRequest) returns (GetCharacterResponse); +``` + +## Character Data Structure + +Characters in the system have the following key attributes: + +- **Basic Information**: ID, name, user ID, creation/deletion dates +- **Appearance**: Race, face, hair, stone +- **Stats**: Level, attributes (STR, DEX, INT, etc.), HP, MP, experience +- **Inventory**: Items, equipment +- **Position**: Map ID, coordinates + +## Configuration + +The service can be configured using environment variables: + +- `LISTEN_ADDR`: The address to listen on (default: "0.0.0.0") +- `SERVICE_PORT`: The port to listen on (default: "50053") +- `LOG_LEVEL`: Logging level (default: "info") + +## Running the Service + +### Local Development + +```bash +cargo run +``` + +### Docker + +```bash +docker build -t character-service . +docker run -p 50053:50053 character-service +``` + +## Integration with External Systems + +The Character Service integrates with: + +- **Database Service**: For character data storage and retrieval +- **Auth Service**: For user authentication and authorization +- **Packet Service**: For handling client requests related to characters diff --git a/character-service/build.rs b/character-service/build.rs index 8d30228..1c86073 100644 --- a/character-service/build.rs +++ b/character-service/build.rs @@ -5,10 +5,7 @@ fn main() { .compile_well_known_types(true) .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") .compile_protos( - &[ - "../proto/character_common.proto", - "../proto/character.proto", - ], + &["../proto/character_common.proto", "../proto/character.proto"], &["../proto"], ) .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); diff --git a/character-service/src/character_db_client.rs b/character-service/src/character_db_client.rs index e2d7d56..3876dcf 100644 --- a/character-service/src/character_db_client.rs +++ b/character-service/src/character_db_client.rs @@ -1,9 +1,8 @@ -use crate::database::character_db_service_client::CharacterDbServiceClient; use crate::database::{ - Character, CharacterListRequest, CharacterListResponse, CharacterRequest, - CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, - DeleteCharacterResponse, + character_db_service_client::CharacterDbServiceClient, Character, CharacterListRequest, CharacterListResponse, + CharacterRequest, CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse, }; + use serde::{Deserialize, Serialize}; use tonic::transport::Channel; diff --git a/character-service/src/character_service.rs b/character-service/src/character_service.rs index d54b257..5bc327a 100644 --- a/character-service/src/character_service.rs +++ b/character-service/src/character_service.rs @@ -1,9 +1,8 @@ use crate::character_db_client::CharacterDbClient; use crate::character_service::character::character_service_server::CharacterService; use crate::character_service::character::{ - CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, - DeleteCharacterResponse, GetCharacterListRequest, GetCharacterListResponse, - GetCharacterRequest, GetCharacterResponse, + CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse, + GetCharacterListRequest, GetCharacterListResponse, GetCharacterRequest, GetCharacterResponse, }; use crate::character_service::character_common::{Character, CharacterFull}; use std::sync::Arc; @@ -69,14 +68,7 @@ impl CharacterService for MyCharacterService { .character_db_client .as_ref() .clone() - .create_character( - &req.user_id, - &req.name, - req.race, - req.face, - req.hair, - req.stone, - ) + .create_character(&req.user_id, &req.name, req.race, req.face, req.hair, req.stone) .await .map_err(|_| Status::aborted("Unable to create character"))?; diff --git a/character-service/src/database.rs b/character-service/src/database.rs new file mode 100644 index 0000000..af326c2 --- /dev/null +++ b/character-service/src/database.rs @@ -0,0 +1,5 @@ +// Include the generated code +tonic::include_proto!("character_db_api"); + +// Re-export the types we need +pub use character_db_service_client::CharacterDbServiceClient; diff --git a/character-service/src/lib.rs b/character-service/src/lib.rs new file mode 100644 index 0000000..d25e99c --- /dev/null +++ b/character-service/src/lib.rs @@ -0,0 +1,3 @@ +pub mod character_db_client; +pub mod character_service; +pub mod database; diff --git a/character-service/src/main.rs b/character-service/src/main.rs index 057a61f..eb45eb5 100644 --- a/character-service/src/main.rs +++ b/character-service/src/main.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use tracing::Level; use tracing_subscriber::EnvFilter; use utils::logging; -use utils::service_discovery::{get_kube_service_endpoints_by_dns}; +use utils::service_discovery::get_kube_service_endpoints_by_dns; #[tokio::main] async fn main() -> Result<(), Box> { @@ -25,17 +25,22 @@ async fn main() -> Result<(), Box> { // Set the gRPC server address let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "50053".to_string()); - let db_url = format!("http://{}",get_kube_service_endpoints_by_dns("database-service","tcp","database-service").await?.get(0).unwrap()); - + let db_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("database-service", "tcp", "database-service") + .await? + .get(0) + .unwrap() + ); let full_addr = format!("{}:{}", &addr, port); let address = full_addr.parse().expect("Invalid address"); let character_db_client = Arc::new(CharacterDbClient::connect(&db_url).await?); - let character_service = MyCharacterService { - character_db_client, - }; + let character_service = MyCharacterService { character_db_client }; let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); - health_reporter.set_serving::>().await; + health_reporter + .set_serving::>() + .await; tonic::transport::Server::builder() .add_service(health_service) diff --git a/database-service/API.md b/database-service/API.md new file mode 100644 index 0000000..cd88ee4 --- /dev/null +++ b/database-service/API.md @@ -0,0 +1,229 @@ +# Database Service API Documentation + +This document provides detailed information about the gRPC API endpoints exposed by the Database Service. + +## UserService + +### GetUser + +Retrieves a user by their ID. + +**Request:** +```protobuf +message GetUserRequest { + int32 user_id = 1; +} +``` + +**Response:** +```protobuf +message GetUserResponse { + int32 user_id = 1; + string username = 2; + string email = 3; + string role = 4; +} +``` + +**Error Codes:** +- `NOT_FOUND`: User with the specified ID does not exist + +### GetUserByUsername + +Retrieves a user by their username. + +**Request:** +```protobuf +message GetUserByUsernameRequest { + string username = 1; +} +``` + +**Response:** +```protobuf +message GetUserResponse { + int32 user_id = 1; + string username = 2; + string email = 3; + string role = 4; +} +``` + +**Error Codes:** +- `NOT_FOUND`: User with the specified username does not exist + +### GetUserByEmail + +Retrieves a user by their email address. + +**Request:** +```protobuf +message GetUserByEmailRequest { + string email = 1; +} +``` + +**Response:** +```protobuf +message GetUserResponse { + int32 user_id = 1; + string username = 2; + string email = 3; + string role = 4; +} +``` + +**Error Codes:** +- `NOT_FOUND`: User with the specified email does not exist + +## CharacterDbService + +### GetCharacter + +Retrieves a character by ID. + +**Request:** +```protobuf +message CharacterRequest { + string user_id = 1; + int32 character_id = 2; +} +``` + +**Response:** +```protobuf +message Character { + int32 id = 1; + string user_id = 2; + string name = 3; + int64 money = 4; + string inventory = 6; + string stats = 7; + string skills = 8; + string looks = 9; + string position = 10; + string created_at = 11; + string updated_at = 12; + string deleted_at = 13; + bool is_active = 14; +} +``` + +**Error Codes:** +- `NOT_FOUND`: Character with the specified ID does not exist + +### GetCharacterList + +Retrieves all characters for a user. + +**Request:** +```protobuf +message CharacterListRequest { + string user_id = 1; +} +``` + +**Response:** +```protobuf +message CharacterListResponse { + repeated Character characters = 1; +} +``` + +### CreateCharacter + +Creates a new character. + +**Request:** +```protobuf +message CreateCharacterRequest { + string user_id = 1; + string name = 2; + string inventory = 3; // JSON serialized + string skills = 4; // JSON serialized + string stats = 5; // JSON serialized + string looks = 6; // JSON serialized + string position = 7; // JSON serialized +} +``` + +**Response:** +```protobuf +message CreateCharacterResponse { + int32 result = 1; + int32 character_id = 2; +} +``` + +**Error Codes:** +- `INTERNAL`: Failed to create character + +### DeleteCharacter + +Marks a character for deletion. + +**Request:** +```protobuf +message DeleteCharacterRequest { + string user_id = 1; + int32 character_id = 2; + int32 delete_type = 3; +} +``` + +**Response:** +```protobuf +message DeleteCharacterResponse { + int64 remaining_time = 1; + string name = 2; +} +``` + +**Error Codes:** +- `INTERNAL`: Failed to delete character + +## SessionService + +### GetSession + +Retrieves session information. + +**Request:** +```protobuf +message GetSessionRequest { + string session_id = 1; +} +``` + +**Response:** +```protobuf +message GetSessionResponse { + string session_id = 1; + string user_id = 2; +} +``` + +**Error Codes:** +- `NOT_FOUND`: Session with the specified ID does not exist + +### RefreshSession + +Updates session expiration. + +**Request:** +```protobuf +message RefreshSessionRequest { + string session_id = 1; +} +``` + +**Response:** +```protobuf +message RefreshSessionResponse { + string session_id = 1; + string user_id = 2; +} +``` + +**Error Codes:** +- `NOT_FOUND`: Session with the specified ID does not exist diff --git a/database-service/README.md b/database-service/README.md new file mode 100644 index 0000000..bb3464c --- /dev/null +++ b/database-service/README.md @@ -0,0 +1,111 @@ +# Database Service + +The Database Service is a core component of the MMORPG server architecture, providing centralized database access for user accounts, character data, and session management. + +## Overview + +The Database Service exposes gRPC endpoints that allow other services to interact with the PostgreSQL database and Redis cache. It implements the repository pattern to abstract database operations and provides efficient caching mechanisms to improve performance. + +## Architecture + +The service is built using the following components: + +- **gRPC Server**: Exposes endpoints for user, character, and session operations +- **Repository Layer**: Implements data access logic with caching +- **PostgreSQL Database**: Stores persistent data +- **Redis Cache**: Provides high-speed caching for frequently accessed data + +## Services + +The Database Service exposes the following gRPC services: + +### UserService + +Handles user account operations: + +- `GetUser`: Retrieves a user by ID +- `GetUserByUsername`: Retrieves a user by username +- `GetUserByEmail`: Retrieves a user by email + +### CharacterDbService + +Handles character operations: + +- `GetCharacter`: Retrieves a character by ID +- `GetCharacterList`: Retrieves all characters for a user +- `CreateCharacter`: Creates a new character +- `DeleteCharacter`: Marks a character for deletion + +### SessionService + +Handles session management: + +- `GetSession`: Retrieves session information +- `RefreshSession`: Updates session expiration + +## Repositories + +The service implements the repository pattern with the following repositories: + +### UserRepository + +Manages user data with methods for retrieving users by ID, username, email, or session. + +### CharacterRepository + +Manages character data with methods for creating, retrieving, and deleting characters. + +### SessionRepository + +Manages session data with methods for retrieving and refreshing sessions. + +## Caching Strategy + +The service uses Redis for caching with the following strategy: + +- Cache keys follow a consistent pattern (e.g., `user:{id}`, `character:{id}`) +- Default TTL of 300 seconds (5 minutes) +- Cache invalidation on updates +- Fallback to database when cache misses + +## Configuration + +The service is configured using environment variables: + +- `LISTEN_ADDR`: The address to listen on (default: 0.0.0.0) +- `SERVICE_PORT`: The port to listen on (default: 50052) +- `DATABASE_URL`: PostgreSQL connection string +- `REDIS_URL`: Redis connection string (default: redis://127.0.0.1:6379) + +## Running the Service + +```bash +# Set environment variables +export DATABASE_URL=postgres://username:password@localhost:5432/dbname +export REDIS_URL=redis://localhost:6379 + +# Run the service +cargo run +``` + +## Docker + +The service can be run using Docker: + +```bash +docker build -t database-service . +docker run -p 50052:50052 \ + -e DATABASE_URL=postgres://username:password@host.docker.internal:5432/dbname \ + -e REDIS_URL=redis://host.docker.internal:6379 \ + database-service +``` + +## Integration with External Systems + +The Database Service is designed to work with an external database schema management system: + +- **Schema Management**: The database schema is managed by an external web application using better-auth +- **User Management**: User creation and updates are handled by the external system +- **Session Management**: Initial session creation is handled by the external system + +The Database Service primarily provides read access to this data for other microservices, with limited write capabilities for game-specific data like characters. diff --git a/database-service/SCHEMA.md b/database-service/SCHEMA.md new file mode 100644 index 0000000..322c522 --- /dev/null +++ b/database-service/SCHEMA.md @@ -0,0 +1,129 @@ +# Database Schema + +This document describes the database schema used by the Database Service. + +## Tables + +### User + +Stores user account information. + +| Column | Type | Description | +|------------|---------------------|-----------------------------------| +| id | INTEGER | Primary key | +| name | VARCHAR | Username | +| email | VARCHAR | Email address | +| role | VARCHAR | User role (e.g., admin, user) | +| createdAt | TIMESTAMP | Account creation timestamp | +| updatedAt | TIMESTAMP | Last update timestamp | + +### Character + +Stores character information. + +| Column | Type | Description | +|------------|---------------------|-----------------------------------| +| id | INTEGER | Primary key | +| userId | INTEGER | Foreign key to User.id | +| name | VARCHAR | Character name | +| money | BIGINT | Character's currency | +| inventory | JSONB | Serialized inventory data | +| stats | JSONB | Character statistics | +| skills | JSONB | Character skills | +| looks | JSONB | Character appearance | +| position | JSONB | Character position in world | +| createdAt | TIMESTAMP | Character creation timestamp | +| updatedAt | TIMESTAMP | Last update timestamp | +| deletedAt | TIMESTAMP | Deletion timestamp (if deleted) | +| isActive | BOOLEAN | Whether character is active | + +### Session + +Stores session information. + +| Column | Type | Description | +|------------|---------------------|-----------------------------------| +| id | VARCHAR | Session ID (primary key) | +| userId | INTEGER | Foreign key to User.id | +| createdAt | TIMESTAMP | Session creation timestamp | +| expiresAt | TIMESTAMP | Session expiration timestamp | + +## JSON Structures + +### Inventory + +```json +{ + "items": [ + { + "id": 1, + "itemId": 1001, + "count": 5, + "slot": 0 + } + ], + "capacity": 100 +} +``` + +### Stats + +```json +{ + "strength": 10, + "dexterity": 10, + "intelligence": 10, + "vitality": 10, + "hp": 100, + "mp": 100, + "level": 1, + "experience": 0 +} +``` + +### Skills + +```json +{ + "skills": [ + { + "id": 1, + "level": 1, + "cooldown": 0 + } + ] +} +``` + +### Looks + +```json +{ + "race": 1, + "gender": 0, + "hair": 1, + "face": 1, + "height": 180, + "skinColor": "#F5DEB3" +} +``` + +### Position + +```json +{ + "mapId": 1, + "x": 100.0, + "y": 100.0, + "z": 0.0, + "direction": 0 +} +``` + +## Indexes + +- `user_name_idx`: Index on User.name for fast username lookups +- `user_email_idx`: Index on User.email for fast email lookups +- `character_user_id_idx`: Index on Character.userId for fast character list retrieval +- `character_name_idx`: Index on Character.name for name uniqueness checks +- `session_user_id_idx`: Index on Session.userId for user session lookups diff --git a/database-service/src/characters.rs b/database-service/src/characters.rs index 971f821..a3b7373 100644 --- a/database-service/src/characters.rs +++ b/database-service/src/characters.rs @@ -102,19 +102,12 @@ impl CharacterRepository { Ok(result.get("id")) } - pub async fn delete_character( - &self, - character_id: i32, - delete_type: i32, - ) -> Result { + pub async fn delete_character(&self, character_id: i32, delete_type: i32) -> Result { let mut query = "UPDATE character SET \"updatedAt\" = NOW(), \"deletedAt\" = NOW() + '24 hours' WHERE id = $1 RETURNING \"userId\", extract(epoch from (\"deletedAt\" - now()))::BIGINT as deleted_at"; if 0 == delete_type { query = "UPDATE character SET \"updatedAt\" = NOW(), \"deletedAt\" = null WHERE id = $1 RETURNING \"userId\", 0::BIGINT as deleted_at"; } - let result = sqlx::query(query) - .bind(character_id) - .fetch_one(&self.pool) - .await?; + let result = sqlx::query(query).bind(character_id).fetch_one(&self.pool).await?; // Invalidate cache let cache_key = format!("character:user:{}", result.get::("user_id")); @@ -134,10 +127,7 @@ impl CharacterRepository { Ok(result.get::("deleted_at")) } - pub async fn get_characters_by_user( - &self, - user_id: String, - ) -> Result, sqlx::Error> { + pub async fn get_characters_by_user(&self, user_id: String) -> Result, sqlx::Error> { let cache_key = format!("character:user:{}", user_id); // Try fetching from Redis cache diff --git a/database-service/src/db.rs b/database-service/src/db.rs index ac464ff..7479815 100644 --- a/database-service/src/db.rs +++ b/database-service/src/db.rs @@ -1,10 +1,10 @@ use crate::characters::CharacterRepository; +use crate::sessions::SessionRepository; use crate::users::UserRepository; use sqlx::PgPool; use std::sync::Arc; use tokio::sync::Mutex; use utils::redis_cache::RedisCache; -use crate::sessions::SessionRepository; pub struct Database { pub user_repo: Arc, @@ -21,7 +21,7 @@ impl Database { Self { user_repo, character_repo, - session_repo + session_repo, } } } diff --git a/database-service/src/grpc/character_service.rs b/database-service/src/grpc/character_service.rs index 2ae5312..3e3ab31 100644 --- a/database-service/src/grpc/character_service.rs +++ b/database-service/src/grpc/character_service.rs @@ -1,18 +1,14 @@ use crate::grpc::character_db_service_server::CharacterDbService; use crate::grpc::database_service::MyDatabaseService; use crate::grpc::{ - Character, CharacterListRequest, CharacterListResponse, CharacterRequest, - CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, - DeleteCharacterResponse, + Character, CharacterListRequest, CharacterListResponse, CharacterRequest, CreateCharacterRequest, + CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse, }; use tonic::{Request, Response, Status}; #[tonic::async_trait] impl CharacterDbService for MyDatabaseService { - async fn get_character( - &self, - request: Request, - ) -> Result, Status> { + async fn get_character(&self, request: Request) -> Result, Status> { let req = request.into_inner(); let repo = &self.db.character_repo; diff --git a/database-service/src/grpc/mod.rs b/database-service/src/grpc/mod.rs index b439a90..7c7e454 100644 --- a/database-service/src/grpc/mod.rs +++ b/database-service/src/grpc/mod.rs @@ -1,7 +1,7 @@ -pub mod database_service; mod character_service; -mod user_service; +pub mod database_service; mod session_service; +mod user_service; tonic::include_proto!("user_db_api"); tonic::include_proto!("character_db_api"); diff --git a/database-service/src/grpc/session_service.rs b/database-service/src/grpc/session_service.rs index 62674df..7d1aaf2 100644 --- a/database-service/src/grpc/session_service.rs +++ b/database-service/src/grpc/session_service.rs @@ -1,21 +1,22 @@ use crate::grpc::database_service::MyDatabaseService; use crate::grpc::session_service_server::SessionService; +use crate::grpc::{GetSessionRequest, GetSessionResponse, RefreshSessionRequest, RefreshSessionResponse}; use tonic::{Request, Response, Status}; use tracing::debug; -use crate::grpc::{GetSessionRequest, GetSessionResponse, RefreshSessionRequest, RefreshSessionResponse}; #[tonic::async_trait] impl SessionService for MyDatabaseService { - async fn get_session( - &self, - request: Request, - ) -> Result, Status> { + async fn get_session(&self, request: Request) -> Result, Status> { let req = request.into_inner(); debug!("get_session: {:?}", req); - let session = self.db.session_repo.get_session(&req.session_id).await + let session = self + .db + .session_repo + .get_session(&req.session_id) + .await .map_err(|_| Status::not_found("Session not found"))?; - + debug!("session: {:?}", session); Ok(Response::new(GetSessionResponse { session_id: session.id, @@ -23,18 +24,23 @@ impl SessionService for MyDatabaseService { })) } - async fn refresh_session(&self, request: Request) -> Result, Status> { + async fn refresh_session( + &self, + request: Request, + ) -> Result, Status> { let req = request.into_inner(); debug!("get_session: {:?}", req); - let session = self.db.session_repo.refresh_session(&req.session_id).await + let session = self + .db + .session_repo + .refresh_session(&req.session_id) + .await .map_err(|_| Status::not_found("Session not found"))?; - + let valid = true; - + debug!("session: {:?}", session); - Ok(Response::new(RefreshSessionResponse { - valid - })) + Ok(Response::new(RefreshSessionResponse { valid })) } } diff --git a/database-service/src/grpc/user_service.rs b/database-service/src/grpc/user_service.rs index 898cc66..ab7c5d3 100644 --- a/database-service/src/grpc/user_service.rs +++ b/database-service/src/grpc/user_service.rs @@ -1,17 +1,11 @@ use crate::grpc::database_service::MyDatabaseService; use crate::grpc::user_service_server::UserService; -use crate::grpc::{ - GetUserByEmailRequest, GetUserByUsernameRequest, - GetUserRequest, GetUserResponse, -}; +use crate::grpc::{GetUserByEmailRequest, GetUserByUsernameRequest, GetUserRequest, GetUserResponse}; use tonic::{Request, Response, Status}; #[tonic::async_trait] impl UserService for MyDatabaseService { - async fn get_user( - &self, - request: Request, - ) -> Result, Status> { + async fn get_user(&self, request: Request) -> Result, Status> { let req = request.into_inner(); let user = self diff --git a/database-service/src/lib.rs b/database-service/src/lib.rs index dcd9b63..ab6ac03 100644 --- a/database-service/src/lib.rs +++ b/database-service/src/lib.rs @@ -1,5 +1,5 @@ pub mod characters; pub mod db; pub mod grpc; -pub mod users; pub mod sessions; +pub mod users; diff --git a/database-service/src/main.rs b/database-service/src/main.rs index c408705..a4169c2 100644 --- a/database-service/src/main.rs +++ b/database-service/src/main.rs @@ -1,6 +1,7 @@ use database_service::db::Database; use database_service::grpc::character_db_service_server::CharacterDbServiceServer; use database_service::grpc::database_service::MyDatabaseService; +use database_service::grpc::session_service_server::SessionServiceServer; use database_service::grpc::user_service_server::UserServiceServer; use dotenv::dotenv; use sqlx::postgres::PgPoolOptions; @@ -12,7 +13,6 @@ use tokio::sync::Mutex; use tonic::transport::Server; use tracing::{info, Level}; use tracing_subscriber::EnvFilter; -use database_service::grpc::session_service_server::SessionServiceServer; use utils::logging; use utils::redis_cache::RedisCache; @@ -39,7 +39,9 @@ async fn main() -> Result<(), Box> { let my_service = MyDatabaseService { db }; let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); - health_reporter.set_serving::>().await; + health_reporter + .set_serving::>() + .await; let address = SocketAddr::new(addr.parse()?, port.parse()?); tokio::spawn( diff --git a/database-service/src/sessions.rs b/database-service/src/sessions.rs index 237948f..673664e 100644 --- a/database-service/src/sessions.rs +++ b/database-service/src/sessions.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Row}; use std::sync::Arc; use tokio::sync::Mutex; -use utils::redis_cache::{Cache, RedisCache}; use tracing::debug; +use utils::redis_cache::{Cache, RedisCache}; #[derive(Debug, FromRow, Serialize, Deserialize)] pub struct Session { @@ -24,25 +24,30 @@ impl SessionRepository { pub async fn get_session(&self, session_id: &str) -> Result { let cache_key = format!("session:{}", session_id); - if let Some(session) = self.cache.lock().await - .get::(&cache_key).await + if let Some(session) = self + .cache + .lock() + .await + .get::(&cache_key) + .await .map_err(|_| sqlx::Error::RowNotFound)? { return Ok(session); } // Fetch from database - let session = sqlx::query_as::<_, Session>( - "SELECT id, \"userId\" as user_id FROM session WHERE id = $1", - ) + let session = sqlx::query_as::<_, Session>("SELECT id, \"userId\" as user_id FROM session WHERE id = $1") .bind(session_id) .fetch_one(&self.pool) .await?; - + debug!("session: {:?}", session); - self.cache.lock().await - .set(&cache_key, &session, 300).await + self.cache + .lock() + .await + .set(&cache_key, &session, 300) + .await .map_err(|_| sqlx::Error::RowNotFound)?; Ok(session) } @@ -50,26 +55,34 @@ impl SessionRepository { pub async fn refresh_session(&self, session_id: &str) -> Result { let cache_key = format!("session:{}", session_id); - if let Some(session) = self.cache.lock().await - .get::(&cache_key).await + if let Some(session) = self + .cache + .lock() + .await + .get::(&cache_key) + .await .map_err(|_| sqlx::Error::RowNotFound)? { - self.cache.lock().await - .refresh(&cache_key, 300).await + self.cache + .lock() + .await + .refresh(&cache_key, 300) + .await .map_err(|_| sqlx::Error::RowNotFound)?; return Ok(session); } // Check to make sure the session is still valid - let session = sqlx::query_as::<_, Session>( - "SELECT id, \"userId\" as user_id FROM session WHERE id = $1", - ) + let session = sqlx::query_as::<_, Session>("SELECT id, \"userId\" as user_id FROM session WHERE id = $1") .bind(session_id) .fetch_one(&self.pool) .await?; - self.cache.lock().await - .set(&cache_key, &session, 300).await + self.cache + .lock() + .await + .set(&cache_key, &session, 300) + .await .map_err(|_| sqlx::Error::RowNotFound)?; Ok(session) } diff --git a/database-service/src/tests/character_repository_tests.rs b/database-service/src/tests/character_repository_tests.rs new file mode 100644 index 0000000..09719e4 --- /dev/null +++ b/database-service/src/tests/character_repository_tests.rs @@ -0,0 +1,306 @@ +#[cfg(test)] +mod tests { + use crate::characters::{Character, CharacterRepository}; + use sqlx::{Pool, Postgres}; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + use tokio::sync::Mutex; + use utils::redis_cache::RedisCache; + use serde_json::json; + + // Helper function to create a test database pool + async fn create_test_pool() -> Pool { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to create test database pool") + } + + // Helper function to create a mock Redis cache + fn create_mock_redis() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) + } + + // Helper function to create a test user in the database + async fn create_test_user(pool: &Pool) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ('test_char_user', 'test_char@example.com', 'user', NOW(), NOW()) + RETURNING id + "# + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id + } + + // Helper function to create a test character in the database + async fn create_test_character(pool: &Pool, user_id: i32, name: &str) -> i32 { + let inventory = json!({ + "items": [], + "capacity": 100 + }); + + let stats = json!({ + "strength": 10, + "dexterity": 10, + "intelligence": 10, + "vitality": 10 + }); + + let skills = json!({ + "skills": [] + }); + + let looks = json!({ + "race": 1, + "gender": 0, + "hair": 1, + "face": 1 + }); + + let position = json!({ + "mapId": 1, + "x": 100.0, + "y": 100.0, + "z": 0.0 + }); + + let result = sqlx::query!( + r#" + INSERT INTO character ( + "userId", name, money, inventory, stats, skills, looks, position, + "createdAt", "updatedAt", "isActive" + ) + VALUES ($1, $2, 0, $3, $4, $5, $6, $7, NOW(), NOW(), true) + RETURNING id + "#, + user_id, + name, + inventory, + stats, + skills, + looks, + position + ) + .fetch_one(pool) + .await + .expect("Failed to create test character"); + + result.id + } + + // Helper function to clean up test data + async fn cleanup_test_data(pool: &Pool, user_id: i32, character_id: i32) { + sqlx::query!(r#"DELETE FROM character WHERE id = $1"#, character_id) + .execute(pool) + .await + .expect("Failed to delete test character"); + + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); + } + + #[tokio::test] + async fn test_get_character_by_id() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache); + + // Create test user and character + let user_id = create_test_user(&pool).await; + let character_id = create_test_character(&pool, user_id, "test_character").await; + + // Test + let result = repo.get_character_by_id(character_id).await; + + // Assert + assert!(result.is_ok()); + let character = result.unwrap(); + assert_eq!(character.id, character_id); + assert_eq!(character.name, "test_character"); + assert_eq!(character.user_id, user_id.to_string()); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id).await; + } + + #[tokio::test] + async fn test_get_character_list() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache); + + // Create test user and characters + let user_id = create_test_user(&pool).await; + let character_id1 = create_test_character(&pool, user_id, "test_character1").await; + let character_id2 = create_test_character(&pool, user_id, "test_character2").await; + + // Test + let result = repo.get_character_list(user_id.to_string()).await; + + // Assert + assert!(result.is_ok()); + let characters = result.unwrap(); + assert_eq!(characters.len(), 2); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id1).await; + sqlx::query!(r#"DELETE FROM character WHERE id = $1"#, character_id2) + .execute(&pool) + .await + .expect("Failed to delete second test character"); + } + + #[tokio::test] + async fn test_create_character() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache); + + // Create test user + let user_id = create_test_user(&pool).await; + + // Test + let inventory = json!({ + "items": [], + "capacity": 100 + }); + + let stats = json!({ + "strength": 10, + "dexterity": 10, + "intelligence": 10, + "vitality": 10 + }); + + let skills = json!({ + "skills": [] + }); + + let looks = json!({ + "race": 1, + "gender": 0, + "hair": 1, + "face": 1 + }); + + let position = json!({ + "mapId": 1, + "x": 100.0, + "y": 100.0, + "z": 0.0 + }); + + let result = repo.create_character( + user_id.to_string(), + "created_character", + inventory, + skills, + stats, + looks, + position + ).await; + + // Assert + assert!(result.is_ok()); + let character_id = result.unwrap(); + + // Verify character was created + let character = repo.get_character_by_id(character_id).await.unwrap(); + assert_eq!(character.name, "created_character"); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id).await; + } + + #[tokio::test] + async fn test_delete_character() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache); + + // Create test user and character + let user_id = create_test_user(&pool).await; + let character_id = create_test_character(&pool, user_id, "delete_test_character").await; + + // Test + let result = repo.delete_character(character_id, 1).await; + + // Assert + assert!(result.is_ok()); + + // Verify character was marked for deletion + let character = repo.get_character_by_id(character_id).await.unwrap(); + assert!(character.deleted_at.is_some()); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id).await; + } + + #[tokio::test] + async fn test_get_nonexistent_character() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache); + + // Test + let result = repo.get_character_by_id(99999).await; + + // Assert + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_character_cache() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = CharacterRepository::new(pool.clone(), cache.clone()); + + // Create test user and character + let user_id = create_test_user(&pool).await; + let character_id = create_test_character(&pool, user_id, "cache_test_character").await; + + // First call to populate cache + let _ = repo.get_character_by_id(character_id).await.unwrap(); + + // Delete from database to ensure we're getting from cache + sqlx::query!(r#"DELETE FROM character WHERE id = $1"#, character_id) + .execute(&pool) + .await + .expect("Failed to delete test character"); + + // Test - should still work because of cache + let result = repo.get_character_by_id(character_id).await; + + // Assert + assert!(result.is_ok()); + let character = result.unwrap(); + assert_eq!(character.id, character_id); + assert_eq!(character.name, "cache_test_character"); + + // Cleanup + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(&pool) + .await + .expect("Failed to delete test user"); + } +} diff --git a/database-service/src/tests/grpc_tests.rs b/database-service/src/tests/grpc_tests.rs new file mode 100644 index 0000000..eb46b9d --- /dev/null +++ b/database-service/src/tests/grpc_tests.rs @@ -0,0 +1,288 @@ +#[cfg(test)] +mod tests { + use crate::db::Database; + use crate::grpc::character_db_service_server::{CharacterDbService, CharacterDbServiceServer}; + use crate::grpc::database_service::MyDatabaseService; + use crate::grpc::user_service_server::{UserService, UserServiceServer}; + use crate::grpc::session_service_server::{SessionService, SessionServiceServer}; + use crate::grpc::{ + Character, CharacterListRequest, CharacterRequest, CreateCharacterRequest, + DeleteCharacterRequest, GetSessionRequest, GetUserByEmailRequest, GetUserByUsernameRequest, + GetUserRequest, RefreshSessionRequest, + }; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + use tokio::sync::Mutex; + use tonic::{Request, Response, Status}; + use tonic::transport::{Channel, Server}; + use utils::redis_cache::RedisCache; + use uuid::Uuid; + + // Helper function to create a test database pool + async fn create_test_pool() -> sqlx::PgPool { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to create test database pool") + } + + // Helper function to create a mock Redis cache + fn create_mock_redis() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) + } + + // Helper function to create a test user in the database + async fn create_test_user(pool: &sqlx::PgPool) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ('grpc_test_user', 'grpc_test@example.com', 'user', NOW(), NOW()) + RETURNING id + "# + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id + } + + // Helper function to create a test character in the database + async fn create_test_character(pool: &sqlx::PgPool, user_id: i32) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO character ( + "userId", name, money, inventory, stats, skills, looks, position, + "createdAt", "updatedAt", "isActive" + ) + VALUES ( + $1, 'grpc_test_character', 0, + '{"items":[]}'::jsonb, + '{"stats":{}}'::jsonb, + '{"skills":[]}'::jsonb, + '{"looks":{}}'::jsonb, + '{"position":{}}'::jsonb, + NOW(), NOW(), true + ) + RETURNING id + "#, + user_id + ) + .fetch_one(pool) + .await + .expect("Failed to create test character"); + + result.id + } + + // Helper function to create a test session in the database + async fn create_test_session(pool: &sqlx::PgPool, user_id: i32) -> String { + let session_id = Uuid::new_v4().to_string(); + + sqlx::query!( + r#" + INSERT INTO session (id, "userId", "createdAt", "expiresAt") + VALUES ($1, $2, NOW(), NOW() + INTERVAL '1 hour') + "#, + session_id, + user_id + ) + .execute(pool) + .await + .expect("Failed to create test session"); + + session_id + } + + // Helper function to clean up test data + async fn cleanup_test_data(pool: &sqlx::PgPool, user_id: i32, character_id: i32, session_id: &str) { + sqlx::query!(r#"DELETE FROM session WHERE id = $1"#, session_id) + .execute(pool) + .await + .expect("Failed to delete test session"); + + sqlx::query!(r#"DELETE FROM character WHERE id = $1"#, character_id) + .execute(pool) + .await + .expect("Failed to delete test character"); + + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); + } + + // Helper function to start a test gRPC server + async fn start_test_server() -> ( + String, + sqlx::PgPool, + i32, + i32, + String, + ) { + // Create test database pool and Redis cache + let pool = create_test_pool().await; + let redis_cache = create_mock_redis(); + + // Create test data + let user_id = create_test_user(&pool).await; + let character_id = create_test_character(&pool, user_id).await; + let session_id = create_test_session(&pool, user_id).await; + + // Create database service + let db = Arc::new(Database::new(pool.clone(), redis_cache)); + let service = MyDatabaseService { db }; + + // Start gRPC server + let addr = "[::1]:0".parse().unwrap(); + let user_service = UserServiceServer::new(service.clone()); + let character_service = CharacterDbServiceServer::new(service.clone()); + let session_service = SessionServiceServer::new(service); + + let server = Server::builder() + .add_service(user_service) + .add_service(character_service) + .add_service(session_service) + .serve(addr); + + let server_addr = server.local_addr(); + tokio::spawn(server); + + ( + format!("http://{}", server_addr), + pool, + user_id, + character_id, + session_id, + ) + } + + #[tokio::test] + async fn test_user_service() { + // Start test server + let (server_addr, pool, user_id, character_id, session_id) = start_test_server().await; + + // Create client + let channel = Channel::from_shared(server_addr.clone()) + .unwrap() + .connect() + .await + .unwrap(); + + let mut client = crate::grpc::user_service_client::UserServiceClient::new(channel); + + // Test GetUser + let request = Request::new(GetUserRequest { user_id }); + let response = client.get_user(request).await.unwrap(); + let user = response.into_inner(); + + assert_eq!(user.user_id, user_id); + assert_eq!(user.username, "grpc_test_user"); + assert_eq!(user.email, "grpc_test@example.com"); + + // Test GetUserByUsername + let request = Request::new(GetUserByUsernameRequest { + username: "grpc_test_user".to_string(), + }); + let response = client.get_user_by_username(request).await.unwrap(); + let user = response.into_inner(); + + assert_eq!(user.user_id, user_id); + + // Test GetUserByEmail + let request = Request::new(GetUserByEmailRequest { + email: "grpc_test@example.com".to_string(), + }); + let response = client.get_user_by_email(request).await.unwrap(); + let user = response.into_inner(); + + assert_eq!(user.user_id, user_id); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id, &session_id).await; + } + + #[tokio::test] + async fn test_character_service() { + // Start test server + let (server_addr, pool, user_id, character_id, session_id) = start_test_server().await; + + // Create client + let channel = Channel::from_shared(server_addr.clone()) + .unwrap() + .connect() + .await + .unwrap(); + + let mut client = crate::grpc::character_db_service_client::CharacterDbServiceClient::new(channel); + + // Test GetCharacter + let request = Request::new(CharacterRequest { + user_id: user_id.to_string(), + character_id, + }); + let response = client.get_character(request).await.unwrap(); + let character = response.into_inner(); + + assert_eq!(character.id, character_id); + assert_eq!(character.name, "grpc_test_character"); + + // Test GetCharacterList + let request = Request::new(CharacterListRequest { + user_id: user_id.to_string(), + }); + let response = client.get_character_list(request).await.unwrap(); + let character_list = response.into_inner(); + + assert_eq!(character_list.characters.len(), 1); + assert_eq!(character_list.characters[0].id, character_id); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id, &session_id).await; + } + + #[tokio::test] + async fn test_session_service() { + // Start test server + let (server_addr, pool, user_id, character_id, session_id) = start_test_server().await; + + // Create client + let channel = Channel::from_shared(server_addr.clone()) + .unwrap() + .connect() + .await + .unwrap(); + + let mut client = crate::grpc::session_service_client::SessionServiceClient::new(channel); + + // Test GetSession + let request = Request::new(GetSessionRequest { + session_id: session_id.clone(), + }); + let response = client.get_session(request).await.unwrap(); + let session = response.into_inner(); + + assert_eq!(session.session_id, session_id); + assert_eq!(session.user_id, user_id.to_string()); + + // Test RefreshSession + let request = Request::new(RefreshSessionRequest { + session_id: session_id.clone(), + }); + let response = client.refresh_session(request).await.unwrap(); + let session = response.into_inner(); + + assert_eq!(session.session_id, session_id); + assert_eq!(session.user_id, user_id.to_string()); + + // Cleanup + cleanup_test_data(&pool, user_id, character_id, &session_id).await; + } +} diff --git a/database-service/src/tests/mod.rs b/database-service/src/tests/mod.rs new file mode 100644 index 0000000..7d12d32 --- /dev/null +++ b/database-service/src/tests/mod.rs @@ -0,0 +1,4 @@ +pub mod user_repository_tests; +pub mod character_repository_tests; +pub mod session_repository_tests; +pub mod grpc_tests; diff --git a/database-service/src/tests/session_repository_tests.rs b/database-service/src/tests/session_repository_tests.rs new file mode 100644 index 0000000..2dac0a3 --- /dev/null +++ b/database-service/src/tests/session_repository_tests.rs @@ -0,0 +1,176 @@ +#[cfg(test)] +mod tests { + use crate::sessions::{Session, SessionRepository}; + use sqlx::{Pool, Postgres}; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + use tokio::sync::Mutex; + use utils::redis_cache::RedisCache; + use uuid::Uuid; + + // Helper function to create a test database pool + async fn create_test_pool() -> Pool { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to create test database pool") + } + + // Helper function to create a mock Redis cache + fn create_mock_redis() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) + } + + // Helper function to create a test user in the database + async fn create_test_user(pool: &Pool) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ('test_session_user', 'test_session@example.com', 'user', NOW(), NOW()) + RETURNING id + "# + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id + } + + // Helper function to create a test session in the database + async fn create_test_session(pool: &Pool, user_id: i32) -> String { + let session_id = Uuid::new_v4().to_string(); + + sqlx::query!( + r#" + INSERT INTO session (id, "userId", "createdAt", "expiresAt") + VALUES ($1, $2, NOW(), NOW() + INTERVAL '1 hour') + "#, + session_id, + user_id + ) + .execute(pool) + .await + .expect("Failed to create test session"); + + session_id + } + + // Helper function to clean up test data + async fn cleanup_test_data(pool: &Pool, user_id: i32, session_id: &str) { + sqlx::query!(r#"DELETE FROM session WHERE id = $1"#, session_id) + .execute(pool) + .await + .expect("Failed to delete test session"); + + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); + } + + #[tokio::test] + async fn test_get_session() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = SessionRepository::new(pool.clone(), cache); + + // Create test user and session + let user_id = create_test_user(&pool).await; + let session_id = create_test_session(&pool, user_id).await; + + // Test + let result = repo.get_session(&session_id).await; + + // Assert + assert!(result.is_ok()); + let session = result.unwrap(); + assert_eq!(session.id, session_id); + assert_eq!(session.user_id, user_id.to_string()); + + // Cleanup + cleanup_test_data(&pool, user_id, &session_id).await; + } + + #[tokio::test] + async fn test_refresh_session() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = SessionRepository::new(pool.clone(), cache); + + // Create test user and session + let user_id = create_test_user(&pool).await; + let session_id = create_test_session(&pool, user_id).await; + + // Test + let result = repo.refresh_session(&session_id).await; + + // Assert + assert!(result.is_ok()); + let session = result.unwrap(); + assert_eq!(session.id, session_id); + assert_eq!(session.user_id, user_id.to_string()); + + // Cleanup + cleanup_test_data(&pool, user_id, &session_id).await; + } + + #[tokio::test] + async fn test_get_nonexistent_session() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = SessionRepository::new(pool.clone(), cache); + + // Test + let result = repo.get_session("nonexistent-session-id").await; + + // Assert + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_session_cache() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = SessionRepository::new(pool.clone(), cache.clone()); + + // Create test user and session + let user_id = create_test_user(&pool).await; + let session_id = create_test_session(&pool, user_id).await; + + // First call to populate cache + let _ = repo.get_session(&session_id).await.unwrap(); + + // Delete from database to ensure we're getting from cache + sqlx::query!(r#"DELETE FROM session WHERE id = $1"#, session_id) + .execute(&pool) + .await + .expect("Failed to delete test session"); + + // Test - should still work because of cache + let result = repo.get_session(&session_id).await; + + // Assert + assert!(result.is_ok()); + let session = result.unwrap(); + assert_eq!(session.id, session_id); + assert_eq!(session.user_id, user_id.to_string()); + + // Cleanup + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(&pool) + .await + .expect("Failed to delete test user"); + } +} diff --git a/database-service/src/tests/user_repository_tests.rs b/database-service/src/tests/user_repository_tests.rs new file mode 100644 index 0000000..1d96b9b --- /dev/null +++ b/database-service/src/tests/user_repository_tests.rs @@ -0,0 +1,169 @@ +#[cfg(test)] +mod tests { + use crate::users::{User, UserRepository}; + use chrono::NaiveDateTime; + use sqlx::{Pool, Postgres}; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + use tokio::sync::Mutex; + use utils::redis_cache::RedisCache; + + // Helper function to create a test database pool + async fn create_test_pool() -> Pool { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to create test database pool") + } + + // Helper function to create a mock Redis cache + fn create_mock_redis() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) + } + + // Helper function to create a test user in the database + async fn create_test_user(pool: &Pool, name: &str, email: &str) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ($1, $2, 'user', NOW(), NOW()) + RETURNING id + "#, + name, + email + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id + } + + // Helper function to clean up test data + async fn cleanup_test_user(pool: &Pool, user_id: i32) { + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); + } + + #[tokio::test] + async fn test_get_user_by_id() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = UserRepository::new(pool.clone(), cache); + + // Create test user + let user_id = create_test_user(&pool, "test_user", "test@example.com").await; + + // Test + let result = repo.get_user_by_id(user_id).await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); + assert_eq!(user.name, "test_user"); + assert_eq!(user.email, "test@example.com"); + assert_eq!(user.role, "user"); + + // Cleanup + cleanup_test_user(&pool, user_id).await; + } + + #[tokio::test] + async fn test_get_user_by_username() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = UserRepository::new(pool.clone(), cache); + + // Create test user + let user_id = create_test_user(&pool, "test_user_by_name", "test_name@example.com").await; + + // Test + let result = repo.get_user_by_username("test_user_by_name").await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); + assert_eq!(user.name, "test_user_by_name"); + assert_eq!(user.email, "test_name@example.com"); + + // Cleanup + cleanup_test_user(&pool, user_id).await; + } + + #[tokio::test] + async fn test_get_user_by_email() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = UserRepository::new(pool.clone(), cache); + + // Create test user + let user_id = create_test_user(&pool, "test_user_by_email", "test_email@example.com").await; + + // Test + let result = repo.get_user_by_email("test_email@example.com").await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); + assert_eq!(user.name, "test_user_by_email"); + assert_eq!(user.email, "test_email@example.com"); + + // Cleanup + cleanup_test_user(&pool, user_id).await; + } + + #[tokio::test] + async fn test_get_nonexistent_user() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = UserRepository::new(pool.clone(), cache); + + // Test + let result = repo.get_user_by_id(99999).await; + + // Assert + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_cache_hit() { + // Setup + let pool = create_test_pool().await; + let cache = create_mock_redis(); + let repo = UserRepository::new(pool.clone(), cache.clone()); + + // Create test user + let user_id = create_test_user(&pool, "cache_test_user", "cache_test@example.com").await; + + // First call to populate cache + let _ = repo.get_user_by_id(user_id).await.unwrap(); + + // Delete from database to ensure we're getting from cache + cleanup_test_user(&pool, user_id).await; + + // Test - should still work because of cache + let result = repo.get_user_by_id(user_id).await; + + // Assert + assert!(result.is_ok()); + let user = result.unwrap(); + assert_eq!(user.id, user_id); + assert_eq!(user.name, "cache_test_user"); + } +} diff --git a/database-service/src/users.rs b/database-service/src/users.rs index 36bffc9..40e0123 100644 --- a/database-service/src/users.rs +++ b/database-service/src/users.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use sqlx::{FromRow}; +use sqlx::FromRow; use std::sync::Arc; use tokio::sync::Mutex; use utils::redis_cache::{Cache, RedisCache}; @@ -38,12 +38,11 @@ impl UserRepository { return Ok(user); } - let user = sqlx::query_as::<_, User>( - "SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE id = $1", - ) - .bind(user_id) - .fetch_one(&self.pool) - .await?; + let user = + sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE id = $1") + .bind(user_id) + .fetch_one(&self.pool) + .await?; self.cache .lock() @@ -68,12 +67,11 @@ impl UserRepository { return Ok(user); } - let user = sqlx::query_as::<_, User>( - "SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE name = $1", - ) - .bind(username) - .fetch_one(&self.pool) - .await?; + let user = + sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE name = $1") + .bind(username) + .fetch_one(&self.pool) + .await?; self.cache .lock() @@ -98,12 +96,11 @@ impl UserRepository { return Ok(user); } - let user = sqlx::query_as::<_, User>( - "SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1", - ) - .bind(email) - .fetch_one(&self.pool) - .await?; + let user = + sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1") + .bind(email) + .fetch_one(&self.pool) + .await?; self.cache .lock() @@ -128,12 +125,11 @@ impl UserRepository { return Ok(user); } - let user = sqlx::query_as::<_, User>( - "SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1", - ) - .bind(session) - .fetch_one(&self.pool) - .await?; + let user = + sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1") + .bind(session) + .fetch_one(&self.pool) + .await?; self.cache .lock() diff --git a/database-service/tests/get_user.rs b/database-service/tests/get_user.rs deleted file mode 100644 index fa41325..0000000 --- a/database-service/tests/get_user.rs +++ /dev/null @@ -1,29 +0,0 @@ -use tokio; - -#[tokio::test] -async fn test_get_user() { - // // Set up a temporary in-memory PostgreSQL database - // let pool = PgPool::connect("postgres://user:password@localhost/test_database").await.unwrap(); - // - // // Create the test table - // pool.execute( - // r#" - // CREATE TABLE users ( - // user_id TEXT PRIMARY KEY, - // username TEXT NOT NULL, - // email TEXT NOT NULL, - // hashed_password TEXT NOT NULL - // ); - // INSERT INTO users (user_id, username, email, hashed_password) - // VALUES ('123', 'test_user', 'test@example.com', 'hashed_password_example'); - // "#, - // ) - // .await - // .unwrap(); - // - // // Test the `get_user` function - // let user = get_user(&pool, "123").await.unwrap(); - // assert_eq!(user.user_id, "123"); - // assert_eq!(user.username, "test_user"); - // assert_eq!(user.email, "test@example.com"); -} diff --git a/database-service/tests/grpc_get_user.rs b/database-service/tests/grpc_get_user.rs deleted file mode 100644 index ab04a33..0000000 --- a/database-service/tests/grpc_get_user.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[tokio::test] -async fn test_grpc_get_user() { - // let pool = setup_test_pool().await; // Set up your test pool - // let cache = setup_test_cache().await; // Set up mock Redis cache - // - // let service = MyDatabaseService { pool, cache }; - // - // // Create a mock gRPC request - // let request = Request::new(GetUserRequest { - // user_id: 123, - // }); - // - // // Call the service - // let response = service.get_user(request).await.unwrap().into_inner(); - // - // // Validate the response - // assert_eq!(response.user_id, 123); - // assert_eq!(response.username, "test_user"); - // assert_eq!(response.email, "test@example.com"); -} diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..a0d282f --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,78 @@ +# Game Launcher + +The Game Launcher is a client-side application that launches the MMORPG game client with the appropriate connection parameters. + +## Overview + +The Launcher is responsible for: +- Parsing launch URLs from the web authentication system +- Extracting connection parameters (server IP, port, session tokens) +- Launching the game client with the correct command-line arguments +- Handling updates (future functionality) + +## URL Format + +The launcher accepts URLs in the following format: + +``` +launcher://launch?ip=127.0.0.1&port=29000&session=SESSION_TOKEN&username=USERNAME +``` + +Parameters: +- `ip`: Game server IP address +- `port`: Game server port +- `otp`: One-time password (for direct login) +- `session`: Session token (for direct login) +- `username`: User's username +- `password`: User's password (only used for non-direct login) + +## Command-Line Arguments + +The launcher passes the following arguments to the game client: + +- `@TRIGGER_SOFT@`: Required trigger argument +- `_server`: Server IP address +- `_port`: Server port +- `_direct`: Direct login flag +- `_otp`: One-time password +- `_session`: Session token +- `_userid`: User's username +- `_pass`: User's password + +## Platform Support + +The launcher supports: +- Windows: Launches TRose.exe directly +- Linux: Uses Bottles to run the Windows client + +## Configuration + +No additional configuration is required. The launcher extracts all necessary information from the launch URL. + +## Building the Launcher + +```bash +cargo build --release +``` + +## Usage + +The launcher is typically invoked by clicking a link on the game's website: + +```html + + Launch Game + +``` + +It can also be run directly from the command line: + +```bash +launcher "launcher://launch?ip=127.0.0.1&port=29000&session=SESSION_TOKEN&username=USERNAME" +``` + +## Integration with External Systems + +The Launcher integrates with: +- **Web Authentication System**: Receives launch parameters via URL +- **Game Client**: Launches the client with the appropriate parameters diff --git a/launcher/src/launcher.rs b/launcher/src/launcher.rs index 338a063..ced1351 100644 --- a/launcher/src/launcher.rs +++ b/launcher/src/launcher.rs @@ -1,14 +1,15 @@ use crate::format_shell_command; -use crate::wait_for_keypress; use std::borrow::Cow; -use std::env; -use std::process::exit; use std::process::{Command, Stdio}; use tracing::{debug, error, info, warn}; use url::Url; #[cfg(target_os = "windows")] fn create_command() -> Command { + use crate::wait_for_keypress; + use std::env; + use std::process::exit; + let exe_dir_path = env::current_exe().unwrap().parent().unwrap().to_path_buf(); // Change the working directory @@ -76,10 +77,7 @@ pub(crate) fn launch_game(url: String) { } } - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); + command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); info!("Executing: {:?}", format_shell_command(&command)); diff --git a/packet-service/Cargo.toml b/packet-service/Cargo.toml index 6c9c111..33edbd3 100644 --- a/packet-service/Cargo.toml +++ b/packet-service/Cargo.toml @@ -3,6 +3,15 @@ name = "packet-service" version = "0.1.0" edition = "2021" +# Define both a binary and a library target +[[bin]] +name = "packet-service" +path = "src/main.rs" + +[lib] +name = "packet_service" +path = "src/lib.rs" + [features] mocks = [] consul = [] diff --git a/packet-service/README.md b/packet-service/README.md new file mode 100644 index 0000000..575c024 --- /dev/null +++ b/packet-service/README.md @@ -0,0 +1,107 @@ +# Packet Service + +The Packet Service handles communication between game clients and the MMORPG server using a custom binary packet protocol. + +## Overview + +The Packet Service is responsible for: +- Accepting TCP connections from game clients +- Parsing binary packet data +- Routing packets to appropriate handlers +- Sending response packets back to clients +- Managing client connection state + +## Architecture + +The service is built using the following components: + +- **TCP Server**: Accepts client connections +- **Buffer Pool**: Efficiently manages memory for packet processing +- **Packet Router**: Routes packets to appropriate handlers +- **Connection Service**: Manages client connection state +- **Packet Handlers**: Process specific packet types +- **Auth/Character Clients**: Communicate with other services + +## Packet Structure + +Each packet follows a standard binary format: + +``` ++----------------+----------------+----------------+----------------+ +| Packet Size | Packet Type | Packet CRC | Payload | +| (2 bytes) | (2 bytes) | (2 bytes) | (variable) | ++----------------+----------------+----------------+----------------+ +``` + +- **Packet Size**: Total size of the packet in bytes (including header) +- **Packet Type**: Identifies the packet type (see `packet_type.rs`) +- **Packet CRC**: Checksum for packet validation +- **Payload**: Packet-specific data + +## Packet Types + +The service supports numerous packet types for different game operations: + +- **Authentication**: Login, logout, server selection +- **Character**: Character creation, deletion, selection +- **Chat**: Normal chat, whispers, shouts, party chat +- **Movement**: Position updates, teleportation +- **Combat**: Attacks, skills, damage +- **Items**: Inventory management, equipment +- **Party**: Party formation, invitations +- **Trade**: Item trading between players +- **Shop**: NPC shop interactions + +See `packet_type.rs` for a complete list of supported packet types. + +## Connection State + +The service maintains state for each client connection, including: + +- User ID +- Session ID +- Character ID +- Character list +- Additional session data + +## Metrics + +The service exposes Prometheus metrics for monitoring: + +- Active connections +- Packets received/sent +- Packet processing time + +## Configuration + +The service can be configured using environment variables: + +- `LISTEN_ADDR`: The address to listen on (default: "0.0.0.0") +- `SERVICE_PORT`: The port to listen on (default: "29000") +- `PACKET_METRICS_PORT`: Port for Prometheus metrics (default: "9102") +- `MAX_CONCURRENT_CONNECTIONS`: Maximum allowed concurrent connections +- `BUFFER_POOL_SIZE`: Size of the packet buffer pool +- `LOG_LEVEL`: Logging level (default: "info") + +## Running the Service + +### Local Development + +```bash +cargo run +``` + +### Docker + +```bash +docker build -t packet-service . +docker run -p 29000:29000 -p 9102:9102 packet-service +``` + +## Integration with External Systems + +The Packet Service integrates with: + +- **Auth Service**: For user authentication and session validation +- **Character Service**: For character management +- **World Service**: For game world interactions diff --git a/packet-service/src/auth_client.rs b/packet-service/src/auth_client.rs index abe70c2..2c8dfdf 100644 --- a/packet-service/src/auth_client.rs +++ b/packet-service/src/auth_client.rs @@ -69,10 +69,7 @@ impl AuthClient { Ok(response.into_inner()) } - pub async fn logout( - &mut self, - session_id: &str, - ) -> Result> { + pub async fn logout(&mut self, session_id: &str) -> Result> { let request = LogoutRequest { session_id: session_id.to_string(), }; diff --git a/packet-service/src/character_client.rs b/packet-service/src/character_client.rs index e76727c..3c1dad5 100644 --- a/packet-service/src/character_client.rs +++ b/packet-service/src/character_client.rs @@ -1,8 +1,7 @@ use crate::character::character_service_client::CharacterServiceClient; use crate::character::{ - CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, - DeleteCharacterResponse, GetCharacterListRequest, GetCharacterListResponse, - GetCharacterRequest, GetCharacterResponse, + CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse, + GetCharacterListRequest, GetCharacterListResponse, GetCharacterRequest, GetCharacterResponse, }; use tonic::transport::Channel; use utils::null_string::NullTerminatedString; diff --git a/packet-service/src/connection_service.rs b/packet-service/src/connection_service.rs index 2b29f19..0956c6d 100644 --- a/packet-service/src/connection_service.rs +++ b/packet-service/src/connection_service.rs @@ -17,15 +17,12 @@ impl ConnectionService { pub fn add_connection(&self) -> String { let connection_id = Uuid::new_v4().to_string(); - self.connections - .insert(connection_id.clone(), ConnectionState::new()); + self.connections.insert(connection_id.clone(), ConnectionState::new()); connection_id } pub fn get_connection(&self, connection_id: &str) -> Option { - self.connections - .get(connection_id) - .map(|entry| entry.clone()) + self.connections.get(connection_id).map(|entry| entry.clone()) } pub fn get_connection_mut( diff --git a/packet-service/src/handlers/auth.rs b/packet-service/src/handlers/auth.rs index 1c43726..fa9d6c2 100644 --- a/packet-service/src/handlers/auth.rs +++ b/packet-service/src/handlers/auth.rs @@ -133,10 +133,7 @@ pub(crate) async fn handle_login_req( connection_id: String, addr: SocketAddr, ) -> Result<(), Box> { - debug!( - "decoding packet payload of size {}", - packet.payload.as_slice().len() - ); + debug!("decoding packet payload of size {}", packet.payload.as_slice().len()); let data = CliLoginTokenReq::decode(packet.payload.as_slice())?; debug!("{:?}", data); @@ -175,9 +172,7 @@ pub(crate) async fn handle_login_req( "name" => { server_name = value; } - "tags" => { - - } + "tags" => {} _ => {} } } diff --git a/packet-service/src/handlers/character.rs b/packet-service/src/handlers/character.rs index e7c4a97..4734c43 100644 --- a/packet-service/src/handlers/character.rs +++ b/packet-service/src/handlers/character.rs @@ -7,16 +7,15 @@ use crate::enums::ItemType; use crate::packet::{send_packet, Packet, PacketPayload}; use crate::packet_type::PacketType; use crate::packets::*; +use std::collections::hash_map::DefaultHasher; use std::error::Error; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio::net::TcpStream; use tokio::sync::Mutex; use tonic::{Code, Status}; use tracing::{debug, error, info, warn}; use utils::null_string::NullTerminatedString; -use std::hash::{Hash, Hasher}; -use std::collections::hash_map::DefaultHasher; - fn string_to_u32(s: &str) -> u32 { let mut hasher = DefaultHasher::new(); @@ -80,20 +79,16 @@ pub(crate) async fn handle_char_list_req( let request = CliCharListReq::decode(packet.payload.as_slice()); debug!("{:?}", request); - let mut user_id= "".to_string(); + let mut user_id = "".to_string(); let session_id; if let Some(mut state) = connection_service.get_connection(&connection_id) { user_id = state.user_id.expect("Missing user id in connection state"); - session_id = state - .session_id - .expect("Missing session id in connection state"); + session_id = state.session_id.expect("Missing session id in connection state"); } // query the character service for the character list for this user let mut character_client = character_client.lock().await; - let character_list = character_client - .get_character_list(&user_id) - .await?; + let character_list = character_client.get_character_list(&user_id).await?; let mut characters = vec![]; let mut character_id_list: Vec = Vec::new(); for character in character_list.characters { @@ -154,9 +149,7 @@ pub(crate) async fn handle_create_char_req( let session_id; if let Some(mut state) = connection_service.get_connection(&connection_id) { user_id = state.user_id.expect("Missing user id in connection state"); - session_id = state - .session_id - .expect("Missing session id in connection state"); + session_id = state.session_id.expect("Missing session id in connection state"); } // send the data to the character service to create the character @@ -181,10 +174,7 @@ pub(crate) async fn handle_create_char_req( _ => srv_create_char_reply::Result::Failed, }; - let data = SrvCreateCharReply { - result, - platininum: 0, - }; + let data = SrvCreateCharReply { result, platininum: 0 }; let response_packet = Packet::new(PacketType::PakccCreateCharReply, &data)?; send_packet(stream, &response_packet).await?; @@ -209,9 +199,7 @@ pub(crate) async fn handle_delete_char_req( if let Some(mut state) = connection_service.get_connection(&connection_id) { user_id = state.user_id.expect("Missing user id in connection state"); - session_id = state - .session_id - .expect("Missing session id in connection state"); + session_id = state.session_id.expect("Missing session id in connection state"); character_id_list = state.character_list.expect("Missing character id list"); } @@ -256,10 +244,7 @@ pub(crate) async fn handle_select_char_req( let mut character_id_list: Vec = Vec::new(); if let Some(mut state) = connection_service.get_connection_mut(&connection_id) { user_id = state.user_id.clone().expect("Missing user id in connection state"); - character_id_list = state - .character_list - .clone() - .expect("Missing character id list"); + character_id_list = state.character_list.clone().expect("Missing character id list"); state.character_id = Some(request.char_id as i8); } @@ -274,10 +259,7 @@ pub(crate) async fn handle_select_char_req( let mut character_client = character_client.lock().await; let character_data = character_client - .get_character( - &user_id.to_string(), - character_id_list[request.char_id as usize] as u8, - ) + .get_character(&user_id.to_string(), character_id_list[request.char_id as usize] as u8) .await?; let character = character_data.character.unwrap_or_default(); @@ -332,8 +314,7 @@ pub(crate) async fn handle_select_char_req( let mut effect_list: [StatusEffect; (MAX_STATUS_EFFECTS as usize)] = core::array::from_fn(|i| StatusEffect::default()); - let mut hotbar_list: [HotbarItem; (MAX_HOTBAR_ITEMS as usize)] = - core::array::from_fn(|i| HotbarItem::default()); + let mut hotbar_list: [HotbarItem; (MAX_HOTBAR_ITEMS as usize)] = core::array::from_fn(|i| HotbarItem::default()); let data = SrvSelectCharReply { race: looks.race as u8, map: position.map_id as u16, diff --git a/packet-service/src/handlers/world.rs b/packet-service/src/handlers/world.rs index 66dd1d1..cdcdb8d 100644 --- a/packet-service/src/handlers/world.rs +++ b/packet-service/src/handlers/world.rs @@ -1,4 +1,4 @@ -use crate::character_client::CharacterClient; +use crate::character_client::CharacterClient; use crate::connection_service::ConnectionService; use crate::packet::{send_packet, Packet, PacketPayload}; use crate::packet_type::PacketType; @@ -32,24 +32,14 @@ pub(crate) async fn handle_change_map_req( let session_id; if let Some(mut state) = connection_service.get_connection(&connection_id) { user_id = state.user_id.expect("Missing user id in connection state"); - session_id = state - .session_id - .expect("Missing session id in connection state"); - char_id = state - .character_id - .expect("Missing character id in connection state"); - character_id_list = state - .character_list - .clone() - .expect("Missing character id list"); + session_id = state.session_id.expect("Missing session id in connection state"); + char_id = state.character_id.expect("Missing character id in connection state"); + character_id_list = state.character_list.clone().expect("Missing character id list"); } let mut character_client = character_client.lock().await; let character_data = character_client - .get_character( - &user_id.to_string(), - character_id_list[char_id as usize] as u8, - ) + .get_character(&user_id.to_string(), character_id_list[char_id as usize] as u8) .await?; let character = character_data.character.unwrap_or_default(); @@ -93,24 +83,14 @@ pub(crate) async fn handle_mouse_cmd_req( let mut char_id = 0; let mut character_id_list: Vec = Vec::new(); if let Some(mut state) = connection_service.get_connection(&connection_id) { - char_id = state - .character_id - .expect("Missing character id in connection state"); - character_id_list = state - .character_list - .clone() - .expect("Missing character id list"); + char_id = state.character_id.expect("Missing character id in connection state"); + character_id_list = state.character_list.clone().expect("Missing character id list"); } let data = SrvMouseCmd { id: character_id_list[char_id as usize] as u16, target_id: request.target_id, - distance: distance( - 520000 as f64, - 520000 as f64, - request.x as f64, - request.y as f64, - ), + distance: distance(520000 as f64, 520000 as f64, request.x as f64, request.y as f64), x: request.x, y: request.y, z: request.z, diff --git a/packet-service/src/lib.rs b/packet-service/src/lib.rs new file mode 100644 index 0000000..1f8322d --- /dev/null +++ b/packet-service/src/lib.rs @@ -0,0 +1,7 @@ +// Re-export only the modules needed for tests +pub mod bufferpool; +pub mod connection_service; +pub mod connection_state; +pub mod metrics; +pub mod packet; +pub mod packet_type; diff --git a/packet-service/src/main.rs b/packet-service/src/main.rs index 90ea970..e426fe7 100644 --- a/packet-service/src/main.rs +++ b/packet-service/src/main.rs @@ -21,8 +21,8 @@ use tokio::{select, signal}; use tracing::Level; use tracing::{debug, error, info, warn}; use tracing_subscriber::EnvFilter; +use utils::service_discovery::get_kube_service_endpoints_by_dns; use utils::{health_check, logging}; -use utils::service_discovery::{get_kube_service_endpoints_by_dns}; use warp::Filter; mod auth_client; @@ -66,8 +66,20 @@ async fn main() -> Result<(), Box> { let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "29000".to_string()); let metrics_port = env::var("PACKET_METRICS_PORT").unwrap_or_else(|_| "4001".to_string()); - let auth_url = format!("http://{}",get_kube_service_endpoints_by_dns("auth-service","tcp","auth-service").await?.get(0).unwrap()); - let character_url = format!("http://{}",get_kube_service_endpoints_by_dns("character-service","tcp","character-service").await?.get(0).unwrap()); + let auth_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("auth-service", "tcp", "auth-service") + .await? + .get(0) + .unwrap() + ); + let character_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("character-service", "tcp", "character-service") + .await? + .get(0) + .unwrap() + ); // Start health-check endpoint health_check::start_health_check(addr.as_str()).await?; @@ -109,9 +121,7 @@ async fn main() -> Result<(), Box> { { error!("Error handling connection: {}", e); } - packet_router - .connection_service - .remove_connection(&connection_id); + packet_router.connection_service.remove_connection(&connection_id); }); } }); diff --git a/packet-service/src/metrics.rs b/packet-service/src/metrics.rs index d65a6a1..3e932c2 100644 --- a/packet-service/src/metrics.rs +++ b/packet-service/src/metrics.rs @@ -1,7 +1,6 @@ use lazy_static::lazy_static; use prometheus::{ - register_counter, register_gauge, register_histogram, Counter, Encoder, Gauge, Histogram, - TextEncoder, + register_counter, register_gauge, register_histogram, Counter, Encoder, Gauge, Histogram, TextEncoder, }; lazy_static! { diff --git a/packet-service/src/router.rs b/packet-service/src/router.rs index a17752a..6a3414e 100644 --- a/packet-service/src/router.rs +++ b/packet-service/src/router.rs @@ -52,8 +52,7 @@ impl PacketRouter { Ok(packet) => { debug!("Parsed Packet: {:?}", packet); // Handle the parsed packet (route it, process it, etc.) - self.route_packet(stream, packet, connection_id.clone()) - .await?; + self.route_packet(stream, packet, connection_id.clone()).await?; } Err(e) => warn!("Failed to parse packet: {}", e), } diff --git a/proto/auth.proto b/proto/auth.proto index 2b14938..8f32a08 100644 --- a/proto/auth.proto +++ b/proto/auth.proto @@ -10,9 +10,6 @@ service AuthService { rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse); rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse); rpc RefreshSession(ValidateSessionRequest) returns (RefreshSessionResponse); - rpc Register (RegisterRequest) returns (RegisterResponse); - rpc RequestPasswordReset (PasswordResetRequest) returns (PasswordResetResponse); - rpc ResetPassword (ResetPasswordRequest) returns (ResetPasswordResponse); } message LoginRequest { @@ -55,30 +52,4 @@ message RefreshSessionResponse { bool valid = 1; } -message RegisterRequest { - string username = 1; - string email = 2; - string password = 3; -} -message RegisterResponse { - int32 user_id = 1; - string message = 2; -} - -message PasswordResetRequest { - string email = 1; -} - -message PasswordResetResponse { - string message = 1; -} - -message ResetPasswordRequest { - string reset_token = 1; - string new_password = 2; -} - -message ResetPasswordResponse { - string message = 1; -} diff --git a/tests/Cargo.toml b/tests/Cargo.toml new file mode 100644 index 0000000..b5ac2cc --- /dev/null +++ b/tests/Cargo.toml @@ -0,0 +1,94 @@ +[package] +name = "mmorpg-server-tests" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +proc-macro = false +test = true +doctest = false +bench = false +doc = false +plugin = false +harness = true + +[dependencies] +auth-service = { path = "../auth-service" } +character-service = { path = "../character-service" } +database-service = { path = "../database-service" } +packet-service = { path = "../packet-service" } +utils = { path = "../utils" } + +# Common dependencies +tokio = { version = "1.36.0", features = ["full"] } +tonic = "0.11.0" +bincode = { version = "2.0.0", features = ["derive", "serde"] } +serde = { version = "1.0.197", features = ["derive"] } +mockall = "0.12.1" +reqwest = { version = "0.11.24", features = ["json"] } +tracing = "0.1.40" +chrono = "0.4.35" +sqlx = "0.8.3" +serde_json = "1.0.140" +dotenv = "0.15" + +[[test]] +name = "auth_users_tests" +path = "auth-service/users_tests.rs" + +[[test]] +name = "character_service_tests" +path = "character-service/character_service_tests.rs" + +[[test]] +name = "packet_tests" +path = "packet-service/packet_tests.rs" + +[[test]] +name = "bufferpool_tests" +path = "packet-service/bufferpool_tests.rs" + +[[test]] +name = "connection_service_tests" +path = "packet-service/connection_service_tests.rs" + +[[test]] +name = "redis_cache_tests" +path = "utils/redis_cache_tests.rs" + +[[test]] +name = "service_discovery_tests" +path = "utils/service_discovery_tests.rs" + +[[test]] +name = "multi_service_load_balancer_tests" +path = "utils/multi_service_load_balancer_tests.rs" + +[[test]] +name = "health_check_tests" +path = "utils/health_check_tests.rs" + +[[test]] +name = "logging_tests" +path = "utils/logging_tests.rs" + +[[test]] +name = "get_user_tests" +path = "database-service/get_user.rs" + +[[test]] +name = "grpc_get_user_tests" +path = "database-service/grpc_get_user.rs" + +[[test]] +name = "integration_tests" +path = "database-service/integration.rs" + +[[test]] +name = "mock_tests" +path = "database-service/mock_tests.rs" + +[[test]] +name = "redis_cache_db_tests" +path = "database-service/redis_cache.rs" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ced5309 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,72 @@ +# MMORPG Server Tests + +This directory contains tests for all components of the MMORPG server architecture. + +## Running Tests + +### Running All Tests + +To run all tests: + +```bash +cd tests +cargo test +``` + +### Running Tests for a Specific Service + +To run tests for a specific service: + +```bash +cd tests +cargo test --test auth_users_tests +cargo test --test character_service_tests +cargo test --test packet_tests +cargo test --test redis_cache_tests +cargo test --test get_user_tests +cargo test --test grpc_get_user_tests +cargo test --test mock_tests +# etc. +``` + +### Running Tests with Environment Variables + +Some tests require environment variables to be set: + +```bash +# Redis tests +export REDIS_TEST_ENABLED=true +export TEST_REDIS_URL=redis://127.0.0.1:6379 + +# Kubernetes tests +export KUBE_TEST_ENABLED=true +export TEST_K8S_SERVICE_NAME=database-service +export TEST_K8S_PORT_NAME=database-service + +# Consul tests +export CONSUL_TEST_ENABLED=true +export TEST_CONSUL_URL=127.0.0.1:8600 +export TEST_CONSUL_SERVICE_NAME=database-service + +# Run tests +cd tests +cargo test +``` + +## Test Organization + +Tests are organized by service: + +- **auth-service/**: Tests for the authentication service +- **character-service/**: Tests for the character service +- **database-service/**: Tests for the database service +- **packet-service/**: Tests for the packet service +- **utils/**: Tests for shared utilities + +## Adding New Tests + +To add a new test: + +1. Create a new test file in the appropriate service directory +2. Add the test to the `[[test]]` section in `Cargo.toml` +3. Run the test to ensure it works correctly diff --git a/tests/auth-service/users_tests.rs b/tests/auth-service/users_tests.rs new file mode 100644 index 0000000..bd119d8 --- /dev/null +++ b/tests/auth-service/users_tests.rs @@ -0,0 +1,70 @@ +use auth_service::users::{hash_password, verify_password}; + +#[test] +fn test_password_hashing_and_verification() { + // Test with a simple password + let password = "test_password"; + let hashed = hash_password(password); + + // Verify the hash is not the same as the original password + assert_ne!(password, hashed); + + // Verify the password against the hash + assert!(verify_password(password, &hashed)); + + // Verify an incorrect password fails + assert!(!verify_password("wrong_password", &hashed)); +} + +#[test] +fn test_password_hashing_with_special_characters() { + // Test with special characters + let password = "P@$$w0rd!#%^&*()"; + let hashed = hash_password(password); + + // Verify the hash is not the same as the original password + assert_ne!(password, hashed); + + // Verify the password against the hash + assert!(verify_password(password, &hashed)); +} + +#[test] +fn test_password_hashing_with_unicode() { + // Test with Unicode characters + let password = "пароль123你好世界"; + let hashed = hash_password(password); + + // Verify the hash is not the same as the original password + assert_ne!(password, hashed); + + // Verify the password against the hash + assert!(verify_password(password, &hashed)); +} + +#[test] +fn test_different_passwords_produce_different_hashes() { + let password1 = "password1"; + let password2 = "password2"; + + let hash1 = hash_password(password1); + let hash2 = hash_password(password2); + + // Different passwords should produce different hashes + assert_ne!(hash1, hash2); +} + +#[test] +fn test_same_password_produces_different_hashes() { + let password = "same_password"; + + let hash1 = hash_password(password); + let hash2 = hash_password(password); + + // Same password should produce different hashes due to salt + assert_ne!(hash1, hash2); + + // But both hashes should verify against the original password + assert!(verify_password(password, &hash1)); + assert!(verify_password(password, &hash2)); +} diff --git a/tests/character-service/character_service_tests.rs b/tests/character-service/character_service_tests.rs new file mode 100644 index 0000000..72fdf0f --- /dev/null +++ b/tests/character-service/character_service_tests.rs @@ -0,0 +1,251 @@ +use character_service::character_service::character::character_service_server::CharacterService; +use character_service::character_service::character::{ + CreateCharacterRequest, DeleteCharacterRequest, GetCharacterListRequest, GetCharacterRequest, +}; +use character_service::character_service::MyCharacterService; +use mockall::predicate::*; +use mockall::predicate::eq; +use mockall::mock; + +// Wrapper for the mock to implement the trait +struct MockWrapper(MockCharacterDbClientTrait); + +impl character_service::character_db_client::CharacterDbClient for MockWrapper { + async fn get_character_list(&mut self, user_id: &str) -> Result> { + self.0.get_character_list(user_id).await + } + + async fn create_character(&mut self, user_id: &str, name: &str, race: i32, face: i32, hair: i32, stone: i32) -> Result> { + self.0.create_character(user_id, name, race, face, hair, stone).await + } + + async fn delete_character(&mut self, user_id: &str, char_id: &str, delete_type: i32) -> Result> { + self.0.delete_character(user_id, char_id, delete_type).await + } + + async fn get_character(&mut self, user_id: &str, char_id: &str) -> Result> { + self.0.get_character(user_id, char_id).await + } +} +use std::sync::Arc; +use tonic::{Request, Status}; + +// Define a trait for the CharacterDbClient to make it mockable +#[mockall::automock] +trait CharacterDbClientTrait { + async fn get_character_list(&mut self, user_id: &str) -> Result>; + async fn create_character(&mut self, user_id: &str, name: &str, race: i32, face: i32, hair: i32, stone: i32) -> Result>; + async fn delete_character(&mut self, user_id: &str, char_id: &str, delete_type: i32) -> Result>; + async fn get_character(&mut self, user_id: &str, char_id: &str) -> Result>; +} + +#[tokio::test] +async fn test_get_character_list() { + // Create a mock CharacterDbClient + let mut mock_client = MockCharacterDbClientTrait::new(); + + // Set up expectations + mock_client + .expect_get_character_list() + .with(eq("test_user")) + .times(1) + .returning(|_| { + Ok(character_service::database::CharacterListResponse { + characters: vec![ + character_service::database::Character { + id: 1, + user_id: "test_user".to_string(), + name: "Character1".to_string(), + money: 1000, + inventory: "{\"items\":[]}".to_string(), + stats: "{\"level\":1}".to_string(), + skills: "{\"skills\":[]}".to_string(), + looks: "{\"race\":0}".to_string(), + position: "{\"x\":0,\"y\":0,\"z\":0}".to_string(), + deleted_at: "".to_string(), + }, + character_service::database::Character { + id: 2, + user_id: "test_user".to_string(), + name: "Character2".to_string(), + money: 2000, + inventory: "{\"items\":[]}".to_string(), + stats: "{\"level\":5}".to_string(), + skills: "{\"skills\":[]}".to_string(), + looks: "{\"race\":1}".to_string(), + position: "{\"x\":10,\"y\":10,\"z\":0}".to_string(), + deleted_at: "".to_string(), + }, + ], + }) + }); + + // Create a wrapper struct that implements CharacterDbClient + struct MockWrapper(MockCharacterDbClientTrait); + impl std::ops::Deref for MockWrapper { + type Target = MockCharacterDbClientTrait; + fn deref(&self) -> &Self::Target { + &self.0 + } + } + impl std::ops::DerefMut for MockWrapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + impl Clone for MockWrapper { + fn clone(&self) -> Self { + panic!("Mock should not be cloned") + } + } + + // Create the service with the mock client + let service = MyCharacterService { + character_db_client: Arc::new(MockWrapper(mock_client)), + }; + + // Create a request + let request = Request::new(GetCharacterListRequest { + user_id: "test_user".to_string(), + }); + + // Call the service method + let response = service.get_character_list(request).await.unwrap(); + let response = response.into_inner(); + + // Verify the response + assert_eq!(response.characters.len(), 2); + assert_eq!(response.characters[0].name, "Character1"); + assert_eq!(response.characters[1].name, "Character2"); +} + +#[tokio::test] +async fn test_create_character() { + // Create a mock CharacterDbClient + let mut mock_client = MockCharacterDbClientTrait::new(); + + // Set up expectations + mock_client + .expect_create_character() + .with(eq("test_user"), eq("NewCharacter"), eq(0), eq(1), eq(2), eq(3)) + .times(1) + .returning(|_, _, _, _, _, _| { + Ok(character_service::database::CreateCharacterResponse { + result: 0, + character_id: 3, + }) + }); + + // Create the service with the mock client + let service = MyCharacterService { + character_db_client: Arc::new(MockWrapper(mock_client)), + }; + + // Create a request + let request = Request::new(CreateCharacterRequest { + user_id: "test_user".to_string(), + name: "NewCharacter".to_string(), + race: 0, + face: 1, + hair: 2, + stone: 3, + }); + + // Call the service method + let response = service.create_character(request).await.unwrap(); + let response = response.into_inner(); + + // Verify the response + assert_eq!(response.result, 0); +} + +#[tokio::test] +async fn test_delete_character() { + // Create a mock CharacterDbClient + let mut mock_client = MockCharacterDbClientTrait::new(); + + // Set up expectations + mock_client + .expect_delete_character() + .with(eq("test_user"), eq("3"), eq(1)) + .times(1) + .returning(|_, _, _| { + Ok(character_service::database::DeleteCharacterResponse { + remaining_time: 86400, // 24 hours in seconds + name: "DeletedCharacter".to_string(), + }) + }); + + // Create the service with the mock client + let service = MyCharacterService { + character_db_client: Arc::new(MockWrapper(mock_client)), + }; + + // Create a request + let request = Request::new(DeleteCharacterRequest { + user_id: "test_user".to_string(), + char_id: "3".to_string(), + delete_type: 1, + }); + + // Call the service method + let response = service.delete_character(request).await.unwrap(); + let response = response.into_inner(); + + // Verify the response + assert_eq!(response.remaining_time, 86400); + assert_eq!(response.name, "DeletedCharacter"); +} + +#[tokio::test] +async fn test_get_character() { + // Create a mock CharacterDbClient + let mut mock_client = MockCharacterDbClientTrait::new(); + + // Set up expectations + mock_client + .expect_get_character() + .with(eq("test_user"), eq("1")) + .times(1) + .returning(|_, _| { + Ok(character_service::database::Character { + id: 1, + user_id: "test_user".to_string(), + name: "Character1".to_string(), + money: 1000, + inventory: "{\"items\":[{\"id\":1,\"count\":10}]}".to_string(), + stats: "{\"level\":10,\"hp\":100,\"mp\":50}".to_string(), + skills: "{\"skills\":[{\"id\":1,\"level\":5}]}".to_string(), + looks: "{\"race\":0,\"face\":1,\"hair\":2}".to_string(), + position: "{\"x\":100,\"y\":200,\"z\":0,\"map\":1}".to_string(), + deleted_at: "".to_string(), + }) + }); + + // Create the service with the mock client + let service = MyCharacterService { + character_db_client: Arc::new(MockWrapper(mock_client)), + }; + + // Create a request + let request = Request::new(GetCharacterRequest { + user_id: "test_user".to_string(), + char_id: "1".to_string(), + }); + + // Call the service method + let response = service.get_character(request).await.unwrap(); + let response = response.into_inner(); + + // Verify the response + let character = response.character.unwrap(); + assert_eq!(character.name, "Character1"); + assert_eq!(character.money, 1000); + + // Verify JSON fields were parsed correctly + assert!(character.inventory.contains("items")); + assert!(character.stats.contains("level")); + assert!(character.skills.contains("skills")); + assert!(character.looks.contains("race")); + assert!(character.position.contains("map")); +} diff --git a/tests/database-service/get_user.rs b/tests/database-service/get_user.rs new file mode 100644 index 0000000..2fe3f6f --- /dev/null +++ b/tests/database-service/get_user.rs @@ -0,0 +1,78 @@ +use database_service::users::{User, UserRepository}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::sync::Mutex; +use utils::redis_cache::RedisCache; + +// Helper function to create a test database pool +async fn setup_test_pool() -> Result { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await +} + +// Helper function to create a mock Redis cache +fn setup_test_cache() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) +} + +// Helper function to create a test user in the database +async fn create_test_user(pool: &sqlx::PgPool) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ('test_user', 'test@example.com', 'user', NOW(), NOW()) + RETURNING id + "# + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id +} + +// Helper function to clean up test data +async fn cleanup_test_user(pool: &sqlx::PgPool, user_id: i32) { + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); +} + +#[tokio::test] +async fn test_get_user() { + // Skip test if database connection is not available + let pool = match setup_test_pool().await { + Ok(pool) => pool, + Err(_) => { + println!("Skipping test_get_user: Test database not available"); + return; + } + }; + + let cache = setup_test_cache(); + let repo = UserRepository::new(pool.clone(), cache); + + // Create test user + let user_id = create_test_user(&pool).await; + + // Test the get_user_by_id function + let user = repo.get_user_by_id(user_id).await.unwrap(); + + // Validate the user + assert_eq!(user.id, user_id); + assert_eq!(user.name, "test_user"); + assert_eq!(user.email, "test@example.com"); + assert_eq!(user.role, "user"); + + // Cleanup + cleanup_test_user(&pool, user_id).await; +} diff --git a/tests/database-service/grpc_get_user.rs b/tests/database-service/grpc_get_user.rs new file mode 100644 index 0000000..1a1b09c --- /dev/null +++ b/tests/database-service/grpc_get_user.rs @@ -0,0 +1,92 @@ +use database_service::db::Database; +use database_service::grpc::database_service::MyDatabaseService; +use database_service::grpc::user_service_server::UserService; +use database_service::grpc::{GetUserRequest, GetUserResponse}; +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::sync::Mutex; +use tonic::{Request, Response, Status}; +use utils::redis_cache::RedisCache; + +// Helper function to create a test database pool +async fn setup_test_pool() -> sqlx::PgPool { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + .expect("Failed to create test database pool") +} + +// Helper function to create a mock Redis cache +fn setup_test_cache() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) +} + +// Helper function to create a test user in the database +async fn create_test_user(pool: &sqlx::PgPool) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ('test_user', 'test@example.com', 'user', NOW(), NOW()) + RETURNING id + "# + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id +} + +// Helper function to clean up test data +async fn cleanup_test_user(pool: &sqlx::PgPool, user_id: i32) { + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); +} + +#[tokio::test] +async fn test_grpc_get_user() { + // Skip test if database connection is not available + let pool_result = setup_test_pool().await; + let pool = match pool_result { + Ok(pool) => pool, + Err(_) => { + println!("Skipping test_grpc_get_user: Test database not available"); + return; + } + }; + + let cache = setup_test_cache(); + + // Create test user + let user_id = create_test_user(&pool).await; + + // Create the database service + let db = Arc::new(Database::new(pool.clone(), cache)); + let service = MyDatabaseService { db }; + + // Create a gRPC request + let request = Request::new(GetUserRequest { + user_id, + }); + + // Call the service + let response = service.get_user(request).await.unwrap().into_inner(); + + // Validate the response + assert_eq!(response.user_id, user_id); + assert_eq!(response.username, "test_user"); + assert_eq!(response.email, "test@example.com"); + assert_eq!(response.role, "user"); + + // Cleanup + cleanup_test_user(&pool, user_id).await; +} diff --git a/database-service/tests/integration.rs b/tests/database-service/integration.rs similarity index 100% rename from database-service/tests/integration.rs rename to tests/database-service/integration.rs diff --git a/tests/database-service/mock_tests.rs b/tests/database-service/mock_tests.rs new file mode 100644 index 0000000..b4ca596 --- /dev/null +++ b/tests/database-service/mock_tests.rs @@ -0,0 +1,137 @@ +use database_service::users::User; +use mockall::predicate::*; +use mockall::predicate::eq; +use mockall::mock; +use std::sync::Arc; +use chrono::NaiveDateTime; + +// Mock the UserRepository +mock! { + pub UserRepository { + fn get_user_by_id(&self, user_id: i32) -> Result; + fn get_user_by_username(&self, username: &str) -> Result; + fn get_user_by_email(&self, email: &str) -> Result; + } +} + +// Mock the CharacterRepository +mock! { + pub CharacterRepository { + fn get_character_by_id(&self, character_id: i32) -> Result; + fn get_character_list(&self, user_id: String) -> Result, sqlx::Error>; + fn create_character( + &self, + user_id: String, + name: &str, + inventory: serde_json::Value, + skills: serde_json::Value, + stats: serde_json::Value, + looks: serde_json::Value, + position: serde_json::Value, + ) -> Result; + fn delete_character(&self, character_id: i32, delete_type: i32) -> Result; + } +} + +// Mock the SessionRepository +mock! { + pub SessionRepository { + fn get_session(&self, session_id: &str) -> Result; + fn refresh_session(&self, session_id: &str) -> Result; + } +} + +// Mock the Database struct +struct MockDatabase { + user_repo: Arc, + character_repo: Arc, + session_repo: Arc, +} + +type MockDatabaseWithMocks = MockDatabase; + +#[tokio::test] +async fn test_user_repository_mock() { + // Create a mock UserRepository + let mut mock_user_repo = MockUserRepository::new(); + + // Set up expectations + mock_user_repo + .expect_get_user_by_id() + .with(eq(123)) + .times(1) + .returning(|_| { + Ok(User { + id: 123, + name: "mock_user".to_string(), + email: "mock@example.com".to_string(), + role: "user".to_string(), + created_at: NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), + updated_at: NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), + }) + }); + + // Test the mock + let user = mock_user_repo.get_user_by_id(123).unwrap(); + assert_eq!(user.id, 123); + assert_eq!(user.name, "mock_user"); + assert_eq!(user.email, "mock@example.com"); +} + +#[tokio::test] +async fn test_character_repository_mock() { + // Create a mock CharacterRepository + let mut mock_character_repo = MockCharacterRepository::new(); + + // Set up expectations + mock_character_repo + .expect_get_character_by_id() + .with(eq(456)) + .times(1) + .returning(|_| { + Ok(database_service::characters::Character { + id: 456, + user_id: "123".to_string(), + name: "mock_character".to_string(), + money: 1000, + inventory: serde_json::json!({}), + stats: serde_json::json!({}), + skills: serde_json::json!({}), + looks: serde_json::json!({}), + position: serde_json::json!({}), + created_at: NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), + updated_at: NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), + deleted_at: None, + is_active: true, + }) + }); + + // Test the mock + let character = mock_character_repo.get_character_by_id(456).unwrap(); + assert_eq!(character.id, 456); + assert_eq!(character.name, "mock_character"); + assert_eq!(character.user_id, "123"); +} + +#[tokio::test] +async fn test_session_repository_mock() { + // Create a mock SessionRepository + let mut mock_session_repo = MockSessionRepository::new(); + + // Set up expectations + mock_session_repo + .expect_get_session() + .with(eq("session123")) + .times(1) + .returning(|_| { + Ok(database_service::sessions::Session { + id: "session123".to_string(), + user_id: "123".to_string(), + }) + }); + + // Test the mock + let session = mock_session_repo.get_session("session123").unwrap(); + assert_eq!(session.id, "session123"); + assert_eq!(session.user_id, "123"); +} diff --git a/database-service/tests/redis_cache.rs b/tests/database-service/redis_cache.rs similarity index 100% rename from database-service/tests/redis_cache.rs rename to tests/database-service/redis_cache.rs diff --git a/tests/database-service/test_helpers.rs b/tests/database-service/test_helpers.rs new file mode 100644 index 0000000..0be40f8 --- /dev/null +++ b/tests/database-service/test_helpers.rs @@ -0,0 +1,151 @@ +use sqlx::postgres::PgPoolOptions; +use std::sync::Arc; +use tokio::sync::Mutex; +use utils::redis_cache::RedisCache; + +// Helper function to create a test database pool +pub async fn setup_test_pool() -> Result { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/test_db".to_string()); + + PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await +} + +// Helper function to create a mock Redis cache +pub fn setup_test_cache() -> Arc> { + let redis_url = std::env::var("TEST_REDIS_URL") + .unwrap_or_else(|_| "redis://localhost:6379".to_string()); + + Arc::new(Mutex::new(RedisCache::new(&redis_url))) +} + +// Helper function to create a test user in the database +pub async fn create_test_user(pool: &sqlx::PgPool, name: &str, email: &str) -> i32 { + let result = sqlx::query!( + r#" + INSERT INTO "user" (name, email, role, "createdAt", "updatedAt") + VALUES ($1, $2, 'user', NOW(), NOW()) + RETURNING id + "#, + name, + email + ) + .fetch_one(pool) + .await + .expect("Failed to create test user"); + + result.id +} + +// Helper function to create a test character in the database +pub async fn create_test_character( + pool: &sqlx::PgPool, + user_id: i32, + name: &str +) -> i32 { + let inventory = serde_json::json!({ + "items": [], + "capacity": 100 + }); + + let stats = serde_json::json!({ + "strength": 10, + "dexterity": 10, + "intelligence": 10, + "vitality": 10 + }); + + let skills = serde_json::json!({ + "skills": [] + }); + + let looks = serde_json::json!({ + "race": 1, + "gender": 0, + "hair": 1, + "face": 1 + }); + + let position = serde_json::json!({ + "mapId": 1, + "x": 100.0, + "y": 100.0, + "z": 0.0 + }); + + let result = sqlx::query!( + r#" + INSERT INTO character ( + "userId", name, money, inventory, stats, skills, looks, position, + "createdAt", "updatedAt", "isActive" + ) + VALUES ($1, $2, 0, $3, $4, $5, $6, $7, NOW(), NOW(), true) + RETURNING id + "#, + user_id, + name, + inventory, + stats, + skills, + looks, + position + ) + .fetch_one(pool) + .await + .expect("Failed to create test character"); + + result.id +} + +// Helper function to create a test session in the database +pub async fn create_test_session(pool: &sqlx::PgPool, user_id: i32) -> String { + let session_id = uuid::Uuid::new_v4().to_string(); + + sqlx::query!( + r#" + INSERT INTO session (id, "userId", "createdAt", "expiresAt") + VALUES ($1, $2, NOW(), NOW() + INTERVAL '1 hour') + "#, + session_id, + user_id + ) + .execute(pool) + .await + .expect("Failed to create test session"); + + session_id +} + +// Helper function to clean up test user data +pub async fn cleanup_test_user(pool: &sqlx::PgPool, user_id: i32) { + sqlx::query!(r#"DELETE FROM "user" WHERE id = $1"#, user_id) + .execute(pool) + .await + .expect("Failed to delete test user"); +} + +// Helper function to clean up test character data +pub async fn cleanup_test_character(pool: &sqlx::PgPool, character_id: i32) { + sqlx::query!(r#"DELETE FROM character WHERE id = $1"#, character_id) + .execute(pool) + .await + .expect("Failed to delete test character"); +} + +// Helper function to clean up test session data +pub async fn cleanup_test_session(pool: &sqlx::PgPool, session_id: &str) { + sqlx::query!(r#"DELETE FROM session WHERE id = $1"#, session_id) + .execute(pool) + .await + .expect("Failed to delete test session"); +} + +// Helper function to clean up all test data +pub async fn cleanup_test_data(pool: &sqlx::PgPool, user_id: i32, character_id: i32, session_id: &str) { + cleanup_test_session(pool, session_id).await; + cleanup_test_character(pool, character_id).await; + cleanup_test_user(pool, user_id).await; +} diff --git a/tests/packet-service/bufferpool_tests.rs b/tests/packet-service/bufferpool_tests.rs new file mode 100644 index 0000000..d65e805 --- /dev/null +++ b/tests/packet-service/bufferpool_tests.rs @@ -0,0 +1,96 @@ +use packet_service::bufferpool::BufferPool; +use std::sync::Arc; + +#[tokio::test] +async fn test_buffer_pool_creation() { + let pool_size = 5; + let pool = BufferPool::new(pool_size); + + // Verify we can acquire the expected number of buffers + for _ in 0..pool_size { + let buffer = pool.acquire().await; + assert!(buffer.is_some()); + } + + // The next acquire should return None since all buffers are in use + let buffer = pool.acquire().await; + assert!(buffer.is_none()); +} + +#[tokio::test] +async fn test_buffer_pool_release() { + let pool_size = 3; + let pool = BufferPool::new(pool_size); + + // Acquire all buffers + let mut buffers = Vec::new(); + for _ in 0..pool_size { + let buffer = pool.acquire().await.unwrap(); + buffers.push(buffer); + } + + // Release one buffer + let buffer = buffers.pop().unwrap(); + pool.release(buffer).await; + + // We should be able to acquire one buffer now + let buffer = pool.acquire().await; + assert!(buffer.is_some()); + + // But not two + let buffer = pool.acquire().await; + assert!(buffer.is_none()); +} + +#[tokio::test] +async fn test_buffer_pool_concurrent_access() { + let pool_size = 10; + let pool = Arc::new(BufferPool::new(pool_size)); + + // Spawn multiple tasks to acquire and release buffers + let mut handles = Vec::new(); + + for i in 0..pool_size { + let pool_clone = pool.clone(); + let handle = tokio::spawn(async move { + // Acquire a buffer + let mut buffer = pool_clone.acquire().await.unwrap(); + + // Write some data to the buffer + buffer[0] = i as u8; + + // Simulate some work + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Release the buffer + pool_clone.release(buffer).await; + }); + + handles.push(handle); + } + + // Wait for all tasks to complete + for handle in handles { + handle.await.unwrap(); + } + + // All buffers should be available again + for _ in 0..pool_size { + let buffer = pool.acquire().await; + assert!(buffer.is_some()); + } +} + +#[tokio::test] +async fn test_buffer_pool_buffer_size() { + let pool = BufferPool::new(1); + + // Acquire a buffer + let buffer = pool.acquire().await.unwrap(); + + // Buffer should be large enough for maximum packet size + assert!(buffer.len() >= 0xFFF); + + // Release the buffer + pool.release(buffer).await; +} diff --git a/tests/packet-service/connection_service_tests.rs b/tests/packet-service/connection_service_tests.rs new file mode 100644 index 0000000..2823ac7 --- /dev/null +++ b/tests/packet-service/connection_service_tests.rs @@ -0,0 +1,108 @@ +use packet_service::connection_service::ConnectionService; +use packet_service::connection_state::ConnectionState; +use std::collections::HashSet; + +#[test] +fn test_connection_service_add_connection() { + let service = ConnectionService::new(); + + // Add a connection + let connection_id = service.add_connection(); + + // Verify the connection exists + let connection = service.get_connection(&connection_id); + assert!(connection.is_some()); +} + +#[test] +fn test_connection_service_remove_connection() { + let service = ConnectionService::new(); + + // Add a connection + let connection_id = service.add_connection(); + + // Verify the connection exists + let connection = service.get_connection(&connection_id); + assert!(connection.is_some()); + + // Remove the connection + service.remove_connection(&connection_id); + + // Verify the connection no longer exists + let connection = service.get_connection(&connection_id); + assert!(connection.is_none()); +} + +#[test] +fn test_connection_service_get_connection_mut() { + let service = ConnectionService::new(); + + // Add a connection + let connection_id = service.add_connection(); + + // Get a mutable reference to the connection + let mut connection = service.get_connection_mut(&connection_id).unwrap(); + + // Modify the connection + connection.user_id = Some("test_user".to_string()); + connection.session_id = Some("test_session".to_string()); + connection.character_id = Some(123); + + // Drop the mutable reference + drop(connection); + + // Verify the changes were saved + let connection = service.get_connection(&connection_id).unwrap(); + assert_eq!(connection.user_id, Some("test_user".to_string())); + assert_eq!(connection.session_id, Some("test_session".to_string())); + assert_eq!(connection.character_id, Some(123)); +} + +#[test] +fn test_connection_service_multiple_connections() { + let service = ConnectionService::new(); + + // Add multiple connections + let connection_ids: Vec = (0..10).map(|_| service.add_connection()).collect(); + + // Verify all connections exist + for connection_id in &connection_ids { + let connection = service.get_connection(connection_id); + assert!(connection.is_some()); + } + + // Verify all connection IDs are unique + let unique_ids: HashSet = connection_ids.iter().cloned().collect(); + assert_eq!(unique_ids.len(), connection_ids.len()); +} + +#[test] +fn test_connection_state_new() { + let state = ConnectionState::new(); + + // Verify initial state + assert_eq!(state.user_id, None); + assert_eq!(state.session_id, None); + assert_eq!(state.character_id, None); + assert_eq!(state.character_list, None); + assert!(state.additional_data.is_empty()); +} + +#[test] +fn test_connection_state_additional_data() { + let mut state = ConnectionState::new(); + + // Add some additional data + state.additional_data.insert("key1".to_string(), "value1".to_string()); + state.additional_data.insert("key2".to_string(), "value2".to_string()); + + // Verify the data was added + assert_eq!(state.additional_data.get("key1"), Some(&"value1".to_string())); + assert_eq!(state.additional_data.get("key2"), Some(&"value2".to_string())); + + // Update a value + state.additional_data.insert("key1".to_string(), "updated".to_string()); + + // Verify the value was updated + assert_eq!(state.additional_data.get("key1"), Some(&"updated".to_string())); +} diff --git a/tests/packet-service/packet_tests.rs b/tests/packet-service/packet_tests.rs new file mode 100644 index 0000000..e6c947d --- /dev/null +++ b/tests/packet-service/packet_tests.rs @@ -0,0 +1,98 @@ +use bincode::{Decode, Encode}; +use packet_service::packet::{Packet, PacketPayload}; +use packet_service::packet_type::PacketType; + +// Define a test payload struct +#[derive(Debug, Encode, Decode, PartialEq)] +struct TestPayload { + id: u32, + name: String, + value: f32, +} + +impl PacketPayload for TestPayload {} + +#[test] +fn test_packet_creation() { + let payload = TestPayload { + id: 123, + name: "test".to_string(), + value: 3.14, + }; + + let packet = Packet::new(PacketType::PakcsAlive, &payload).unwrap(); + + // Check packet fields + assert_eq!(packet.packet_type, PacketType::PakcsAlive); + assert_eq!(packet.packet_crc, 0); // CRC is currently not implemented + assert!(!packet.payload.is_empty()); +} + +#[test] +fn test_packet_serialization_deserialization() { + let original_payload = TestPayload { + id: 456, + name: "serialization_test".to_string(), + value: 2.71, + }; + + // Create a packet + let packet = Packet::new(PacketType::PakcsAlive, &original_payload).unwrap(); + + // Serialize to raw bytes + let raw_data = packet.to_raw(); + + // Deserialize from raw bytes + let deserialized_packet = Packet::from_raw(&raw_data).unwrap(); + + // Check packet fields match + assert_eq!(deserialized_packet.packet_type, packet.packet_type); + assert_eq!(deserialized_packet.packet_size, packet.packet_size); + assert_eq!(deserialized_packet.packet_crc, packet.packet_crc); + + // Parse the payload + let deserialized_payload: TestPayload = deserialized_packet.parse().unwrap(); + + // Check payload fields match + assert_eq!(deserialized_payload, original_payload); +} + +#[test] +fn test_packet_from_raw_invalid_size() { + // Create a packet with invalid size (too small) + let raw_data = vec![0, 0, 0, 0]; // Only 4 bytes, less than minimum 6 bytes + + let result = Packet::from_raw(&raw_data); + assert!(result.is_err()); +} + +#[test] +fn test_packet_from_raw_size_mismatch() { + // Create a packet with size mismatch + let mut raw_data = vec![0; 10]; // 10 bytes + + // Set packet size to 20 (more than actual data) + raw_data[0] = 20; + raw_data[1] = 0; + + let result = Packet::from_raw(&raw_data); + assert!(result.is_err()); +} + +#[test] +fn test_packet_payload_encoding_decoding() { + let original_payload = TestPayload { + id: 789, + name: "encoding_test".to_string(), + value: 1.618, + }; + + // Encode payload + let encoded = bincode::encode_to_vec(&original_payload, bincode::config::standard()).unwrap(); + + // Decode payload + let decoded: TestPayload = bincode::decode_from_slice(&encoded, bincode::config::standard()).unwrap().0; + + // Check payload fields match + assert_eq!(decoded, original_payload); +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs new file mode 100644 index 0000000..a97e4ad --- /dev/null +++ b/tests/src/lib.rs @@ -0,0 +1,18 @@ +// This file is required to make the tests directory a proper Rust library crate +// It will only be compiled when running tests + +#[cfg(test)] +pub mod test_utils { + // Common test utilities can go here + pub fn setup() { + // Common test setup code + } + + pub fn teardown() { + // Common test teardown code + } +} + +// This ensures the crate is only compiled during testing +#[cfg(not(test))] +compile_error!("This crate is only meant to be used in test mode"); diff --git a/tests/utils/health_check_tests.rs b/tests/utils/health_check_tests.rs new file mode 100644 index 0000000..321783c --- /dev/null +++ b/tests/utils/health_check_tests.rs @@ -0,0 +1,64 @@ +use reqwest::StatusCode; +use std::env; +use std::time::Duration; +use tokio::time::sleep; +use utils::health_check::start_health_check; + +#[tokio::test] +async fn test_health_check_endpoint() { + // Set a custom port for this test to avoid conflicts + env::set_var("HEALTH_CHECK_PORT", "8099"); + + // Start the health check endpoint + let result = start_health_check("127.0.0.1").await; + assert!(result.is_ok(), "Failed to start health check: {:?}", result.err()); + + // Give the server a moment to start + sleep(Duration::from_millis(100)).await; + + // Make a request to the health check endpoint + let client = reqwest::Client::new(); + let response = client.get("http://127.0.0.1:8099/health") + .timeout(Duration::from_secs(2)) + .send() + .await; + + // Verify the response + assert!(response.is_ok(), "Failed to connect to health check endpoint: {:?}", response.err()); + let response = response.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = response.text().await.unwrap(); + assert_eq!(body, "OK"); + + // Clean up + env::remove_var("HEALTH_CHECK_PORT"); +} + +#[tokio::test] +async fn test_health_check_invalid_path() { + // Set a custom port for this test to avoid conflicts + env::set_var("HEALTH_CHECK_PORT", "8098"); + + // Start the health check endpoint + let result = start_health_check("127.0.0.1").await; + assert!(result.is_ok(), "Failed to start health check: {:?}", result.err()); + + // Give the server a moment to start + sleep(Duration::from_millis(100)).await; + + // Make a request to an invalid path + let client = reqwest::Client::new(); + let response = client.get("http://127.0.0.1:8098/invalid") + .timeout(Duration::from_secs(2)) + .send() + .await; + + // Verify the response + assert!(response.is_ok(), "Failed to connect to health check endpoint: {:?}", response.err()); + let response = response.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // Clean up + env::remove_var("HEALTH_CHECK_PORT"); +} diff --git a/tests/utils/logging_tests.rs b/tests/utils/logging_tests.rs new file mode 100644 index 0000000..fefc4fe --- /dev/null +++ b/tests/utils/logging_tests.rs @@ -0,0 +1,42 @@ +use std::env; +use utils::logging::setup_logging; + +#[test] +fn test_logging_setup() { + // Test with default log level + env::remove_var("LOG_LEVEL"); + setup_logging("test_app", &["test_crate"]); + + // Test with custom log level + env::set_var("LOG_LEVEL", "debug"); + setup_logging("test_app", &["test_crate"]); + + // Test with invalid log level (should default to info) + env::set_var("LOG_LEVEL", "invalid_level"); + setup_logging("test_app", &["test_crate"]); + + // Test with multiple additional crates + setup_logging("test_app", &["test_crate1", "test_crate2", "test_crate3"]); + + // Clean up + env::remove_var("LOG_LEVEL"); +} + +#[test] +fn test_logging_output() { + // This test is more of a smoke test to ensure logging doesn't panic + // Actual log output verification would require capturing stdout/stderr + + env::set_var("LOG_LEVEL", "trace"); + setup_logging("test_logging", &[]); + + // Log at different levels + tracing::error!("This is an error message"); + tracing::warn!("This is a warning message"); + tracing::info!("This is an info message"); + tracing::debug!("This is a debug message"); + tracing::trace!("This is a trace message"); + + // Clean up + env::remove_var("LOG_LEVEL"); +} diff --git a/tests/utils/multi_service_load_balancer_tests.rs b/tests/utils/multi_service_load_balancer_tests.rs new file mode 100644 index 0000000..a3b0543 --- /dev/null +++ b/tests/utils/multi_service_load_balancer_tests.rs @@ -0,0 +1,151 @@ +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use utils::multi_service_load_balancer::{LoadBalancingStrategy, MultiServiceLoadBalancer, ServiceId}; + +// Mock implementation for testing without actual service discovery +mod mock { + use super::*; + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + // Mock version of the load balancer for testing + pub struct MockMultiServiceLoadBalancer { + strategy: LoadBalancingStrategy, + services: Arc>>>, + } + + impl MockMultiServiceLoadBalancer { + pub fn new(strategy: LoadBalancingStrategy) -> Self { + MockMultiServiceLoadBalancer { + strategy, + services: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn add_service(&self, service_name: &str, service_protocol: &str, endpoints: Vec) { + let service_id = ServiceId::new(service_name, service_protocol); + let mut services = self.services.lock().unwrap(); + services.insert(service_id, endpoints); + } + + pub fn get_endpoint(&self, service_name: &str, service_protocol: &str) -> Option { + let service_id = ServiceId::new(service_name, service_protocol); + let services = self.services.lock().unwrap(); + + if let Some(endpoints) = services.get(&service_id) { + if endpoints.is_empty() { + return None; + } + + match self.strategy { + LoadBalancingStrategy::Random => { + let index = rand::random::() % endpoints.len(); + Some(endpoints[index]) + }, + LoadBalancingStrategy::RoundRobin => { + // For simplicity in tests, just return the first endpoint + Some(endpoints[0]) + } + } + } else { + None + } + } + } +} + +#[test] +fn test_service_id() { + let service_id1 = ServiceId::new("service1", "http"); + let service_id2 = ServiceId::new("service1", "http"); + let service_id3 = ServiceId::new("service2", "http"); + let service_id4 = ServiceId::new("service1", "https"); + + // Test equality + assert_eq!(service_id1, service_id2); + assert_ne!(service_id1, service_id3); + assert_ne!(service_id1, service_id4); + + // Test hash implementation + let mut set = HashSet::new(); + set.insert(service_id1); + assert!(set.contains(&service_id2)); + assert!(!set.contains(&service_id3)); + assert!(!set.contains(&service_id4)); +} + +#[test] +fn test_mock_load_balancer_random() { + let lb = mock::MockMultiServiceLoadBalancer::new(LoadBalancingStrategy::Random); + + // Add a service with multiple endpoints + let endpoints = vec![ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 8080), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)), 8080), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 3)), 8080), + ]; + lb.add_service("test-service", "http", endpoints.clone()); + + // Get an endpoint + let endpoint = lb.get_endpoint("test-service", "http"); + assert!(endpoint.is_some()); + assert!(endpoints.contains(&endpoint.unwrap())); + + // Test non-existent service + let endpoint = lb.get_endpoint("non-existent", "http"); + assert!(endpoint.is_none()); +} + +#[test] +fn test_mock_load_balancer_round_robin() { + let lb = mock::MockMultiServiceLoadBalancer::new(LoadBalancingStrategy::RoundRobin); + + // Add a service with multiple endpoints + let endpoints = vec![ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 8080), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)), 8080), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 3)), 8080), + ]; + lb.add_service("test-service", "http", endpoints); + + // Get an endpoint + let endpoint = lb.get_endpoint("test-service", "http"); + assert!(endpoint.is_some()); + + // Test empty service + lb.add_service("empty-service", "http", vec![]); + let endpoint = lb.get_endpoint("empty-service", "http"); + assert!(endpoint.is_none()); +} + +// Integration test with the actual MultiServiceLoadBalancer +// This test is disabled by default as it requires a Consul server +#[tokio::test] +async fn test_multi_service_load_balancer() { + use std::env; + + // Skip test if CONSUL_TEST_ENABLED is not set to true + if env::var("CONSUL_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping MultiServiceLoadBalancer test. Set CONSUL_TEST_ENABLED=true to run."); + return; + } + + let consul_url = env::var("TEST_CONSUL_URL").unwrap_or_else(|_| "http://localhost:8500".to_string()); + let service_name = env::var("TEST_CONSUL_SERVICE_NAME").unwrap_or_else(|_| "database-service".to_string()); + let protocol = "tcp"; + + let lb = MultiServiceLoadBalancer::new(&consul_url, LoadBalancingStrategy::Random); + + // Refresh service endpoints + let result = lb.refresh_service_endpoints(&service_name, protocol).await; + assert!(result.is_ok(), "Failed to refresh service endpoints: {:?}", result.err()); + + // Get an endpoint + let result = lb.get_endpoint(&service_name, protocol).await; + assert!(result.is_ok(), "Failed to get endpoint: {:?}", result.err()); + + let endpoint = result.unwrap(); + assert!(endpoint.is_some(), "No endpoint found for service {}", service_name); + + println!("Found endpoint for service {}: {:?}", service_name, endpoint); +} diff --git a/tests/utils/redis_cache_tests.rs b/tests/utils/redis_cache_tests.rs new file mode 100644 index 0000000..c9d6003 --- /dev/null +++ b/tests/utils/redis_cache_tests.rs @@ -0,0 +1,152 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use tokio::sync::Mutex; +use utils::redis_cache::{Cache, RedisCache}; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct TestData { + id: i32, + name: String, + value: f64, +} + +#[tokio::test] +async fn test_redis_cache_set_get() { + // Skip test if REDIS_TEST_ENABLED is not set to true + if env::var("REDIS_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Redis test. Set REDIS_TEST_ENABLED=true to run."); + return; + } + + let redis_url = env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let cache = RedisCache::new(&redis_url); + + let key = "test_key_set_get".to_string(); + let test_data = TestData { + id: 1, + name: "test".to_string(), + value: 3.14, + }; + + // Test setting a value + let set_result = cache.set(&key, &test_data, 10).await; + assert!(set_result.is_ok(), "Failed to set value: {:?}", set_result.err()); + + // Test getting the value + let get_result: Result, _> = cache.get(&key).await; + assert!(get_result.is_ok(), "Failed to get value: {:?}", get_result.err()); + + let retrieved_data = get_result.unwrap(); + assert!(retrieved_data.is_some(), "Retrieved data is None"); + assert_eq!(retrieved_data.unwrap(), test_data); +} + +#[tokio::test] +async fn test_redis_cache_update() { + // Skip test if REDIS_TEST_ENABLED is not set to true + if env::var("REDIS_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Redis test. Set REDIS_TEST_ENABLED=true to run."); + return; + } + + let redis_url = env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let cache = RedisCache::new(&redis_url); + + let key = "test_key_update".to_string(); + let initial_data = TestData { + id: 2, + name: "initial".to_string(), + value: 2.71, + }; + let updated_data = TestData { + id: 2, + name: "updated".to_string(), + value: 2.71, + }; + + // Set initial value + let set_result = cache.set(&key, &initial_data, 10).await; + assert!(set_result.is_ok(), "Failed to set initial value: {:?}", set_result.err()); + + // Update the value + let update_result = cache.update(&key, Some(&updated_data), Some(10)).await; + assert!(update_result.is_ok(), "Failed to update value: {:?}", update_result.err()); + + // Get the updated value + let get_result: Result, _> = cache.get(&key).await; + assert!(get_result.is_ok(), "Failed to get updated value: {:?}", get_result.err()); + + let retrieved_data = get_result.unwrap(); + assert!(retrieved_data.is_some(), "Retrieved data is None"); + assert_eq!(retrieved_data.unwrap(), updated_data); +} + +#[tokio::test] +async fn test_redis_cache_delete() { + // Skip test if REDIS_TEST_ENABLED is not set to true + if env::var("REDIS_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Redis test. Set REDIS_TEST_ENABLED=true to run."); + return; + } + + let redis_url = env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let mut cache = RedisCache::new(&redis_url); + + let key = "test_key_delete".to_string(); + let test_data = TestData { + id: 3, + name: "delete_test".to_string(), + value: 1.618, + }; + + // Set a value + let set_result = cache.set(&key, &test_data, 10).await; + assert!(set_result.is_ok(), "Failed to set value: {:?}", set_result.err()); + + // Delete the value + let delete_result = cache.delete(&key).await; + assert!(delete_result.is_ok(), "Failed to delete value: {:?}", delete_result.err()); + + // Verify the value is gone + let get_result: Result, _> = cache.get(&key).await; + assert!(get_result.is_ok(), "Failed to get value after deletion: {:?}", get_result.err()); + assert!(get_result.unwrap().is_none(), "Value still exists after deletion"); +} + +#[tokio::test] +async fn test_redis_cache_refresh() { + // Skip test if REDIS_TEST_ENABLED is not set to true + if env::var("REDIS_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Redis test. Set REDIS_TEST_ENABLED=true to run."); + return; + } + + let redis_url = env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let cache = RedisCache::new(&redis_url); + + let key = "test_key_refresh".to_string(); + let test_data = TestData { + id: 4, + name: "refresh_test".to_string(), + value: 0.577, + }; + + // Set a value with a short TTL + let set_result = cache.set(&key, &test_data, 5).await; + assert!(set_result.is_ok(), "Failed to set value: {:?}", set_result.err()); + + // Refresh the TTL + let refresh_result = cache.refresh(&key, 30).await; + assert!(refresh_result.is_ok(), "Failed to refresh TTL: {:?}", refresh_result.err()); + + // Wait for the original TTL to expire + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + + // Verify the value still exists due to the refresh + let get_result: Result, _> = cache.get(&key).await; + assert!(get_result.is_ok(), "Failed to get value after refresh: {:?}", get_result.err()); + + let retrieved_data = get_result.unwrap(); + assert!(retrieved_data.is_some(), "Value expired despite TTL refresh"); + assert_eq!(retrieved_data.unwrap(), test_data); +} diff --git a/tests/utils/service_discovery_tests.rs b/tests/utils/service_discovery_tests.rs new file mode 100644 index 0000000..8c9295c --- /dev/null +++ b/tests/utils/service_discovery_tests.rs @@ -0,0 +1,84 @@ +use std::env; +use std::net::SocketAddr; +use utils::service_discovery::{get_kube_service_endpoints_by_dns, get_service_endpoints_by_dns}; + +#[tokio::test] +async fn test_get_kube_service_endpoints_by_dns() { + // Skip test if KUBE_TEST_ENABLED is not set to true + if env::var("KUBE_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Kubernetes DNS test. Set KUBE_TEST_ENABLED=true to run."); + return; + } + + // Test with a known Kubernetes service + let service_name = env::var("TEST_K8S_SERVICE_NAME").unwrap_or_else(|_| "database-service".to_string()); + let port_name = env::var("TEST_K8S_PORT_NAME").unwrap_or_else(|_| "database-service".to_string()); + let protocol = "tcp"; + + let result = get_kube_service_endpoints_by_dns(&port_name, protocol, &service_name).await; + + assert!(result.is_ok(), "Failed to get Kubernetes service endpoints: {:?}", result.err()); + + let endpoints = result.unwrap(); + assert!(!endpoints.is_empty(), "No endpoints found for service {}", service_name); + + // Verify that the endpoints are valid socket addresses + for endpoint in &endpoints { + assert!(endpoint.port() > 0, "Invalid port in endpoint: {}", endpoint); + } + + println!("Found {} endpoints for service {}: {:?}", endpoints.len(), service_name, endpoints); +} + +#[tokio::test] +async fn test_get_service_endpoints_by_dns() { + // Skip test if CONSUL_TEST_ENABLED is not set to true + if env::var("CONSUL_TEST_ENABLED").unwrap_or_else(|_| "false".to_string()) != "true" { + println!("Skipping Consul DNS test. Set CONSUL_TEST_ENABLED=true to run."); + return; + } + + // Test with a known Consul service + let consul_url = env::var("TEST_CONSUL_URL").unwrap_or_else(|_| "127.0.0.1:8600".to_string()); + let service_name = env::var("TEST_CONSUL_SERVICE_NAME").unwrap_or_else(|_| "database-service".to_string()); + let protocol = "tcp"; + + let result = get_service_endpoints_by_dns(&consul_url, protocol, &service_name).await; + + assert!(result.is_ok(), "Failed to get Consul service endpoints: {:?}", result.err()); + + let endpoints = result.unwrap(); + assert!(!endpoints.is_empty(), "No endpoints found for service {}", service_name); + + // Verify that the endpoints are valid socket addresses + for endpoint in &endpoints { + assert!(endpoint.port() > 0, "Invalid port in endpoint: {}", endpoint); + } + + println!("Found {} endpoints for service {}: {:?}", endpoints.len(), service_name, endpoints); +} + +// Mock tests that don't require actual infrastructure +mod mock_tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + #[test] + fn test_socket_addr_parsing() { + // Test that we can parse socket addresses correctly + let addr_str = "127.0.0.1:8080"; + let addr = SocketAddr::from_str(addr_str).unwrap(); + + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(addr.port(), 8080); + } + + #[test] + fn test_socket_addr_formatting() { + // Test that we can format socket addresses correctly + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 9000); + let addr_str = format!("{}", addr); + + assert_eq!(addr_str, "192.168.1.1:9000"); + } +} diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000..03b6515 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,81 @@ +# Utils Module + +This module provides shared utilities used by all microservices in the MMORPG server architecture. + +## Components + +### Service Discovery + +The `service_discovery.rs` module provides functionality for discovering services in both Kubernetes and Consul environments: + +- `get_service_endpoints_by_dns`: Discovers service endpoints using Consul DNS +- `get_kube_service_endpoints_by_dns`: Discovers service endpoints using Kubernetes DNS +- `get_service_info`: Retrieves detailed information about a Kubernetes service +- `get_services_by_label`: Finds Kubernetes services matching specific labels + +### Redis Cache + +The `redis_cache.rs` module provides a caching layer using Redis: + +- Implements the `Cache` trait for standardized cache operations +- Provides methods for getting, setting, and deleting cached values +- Supports TTL (Time To Live) for cached entries + +### Multi-Service Load Balancer + +The `multi_service_load_balancer.rs` module provides load balancing across multiple service instances: + +- Supports Random and Round-Robin load balancing strategies +- Dynamically refreshes service endpoints +- Provides failover capabilities + +### Signal Handler + +The `signal_handler.rs` module provides graceful shutdown capabilities: + +- `wait_for_signal`: Waits for termination signals (SIGINT, SIGTERM) +- Cross-platform support for Unix and Windows signals + +### Consul Registration + +The `consul_registration.rs` module provides service registration with Consul: + +- `register_service`: Registers a service with Consul +- `generate_service_id`: Generates unique service IDs +- `get_or_generate_service_id`: Retrieves or creates service IDs + +### Health Check + +The `health_check.rs` module provides HTTP health check endpoints: + +- `start_health_check`: Starts a health check endpoint on a specified port + +### Logging + +The `logging.rs` module provides standardized logging setup: + +- `setup_logging`: Configures tracing with appropriate log levels + +## Usage + +Import the required utilities in your service: + +```rust +use utils::logging; +use utils::service_discovery::get_kube_service_endpoints_by_dns; +use utils::signal_handler::wait_for_signal; + +// Setup logging +logging::setup_logging("my-service", &["my_service"]); + +// Discover services +let db_url = format!("http://{}", + get_kube_service_endpoints_by_dns("database-service", "tcp", "database-service") + .await? + .get(0) + .unwrap() +); + +// Wait for termination signal +wait_for_signal().await; +``` diff --git a/utils/src/consul_registration.rs b/utils/src/consul_registration.rs index 5e5b683..ec9615c 100644 --- a/utils/src/consul_registration.rs +++ b/utils/src/consul_registration.rs @@ -88,19 +88,11 @@ pub async fn register_service( Ok(()) } -pub async fn deregister_service( - consul_url: &str, - service_id: &str, -) -> Result<(), Box> { +pub async fn deregister_service(consul_url: &str, service_id: &str) -> Result<(), Box> { let client = Client::new(); - let consul_deregister_url = - format!("{}/v1/agent/service/deregister/{}", consul_url, service_id); + let consul_deregister_url = format!("{}/v1/agent/service/deregister/{}", consul_url, service_id); - client - .put(&consul_deregister_url) - .send() - .await? - .error_for_status()?; // Ensure response is successful + client.put(&consul_deregister_url).send().await?.error_for_status()?; // Ensure response is successful Ok(()) } diff --git a/utils/src/health_check.rs b/utils/src/health_check.rs index c858106..768b4eb 100644 --- a/utils/src/health_check.rs +++ b/utils/src/health_check.rs @@ -12,14 +12,7 @@ pub async fn start_health_check(service_address: &str) -> Result<(), Box Self { - ServiceId { - name: name.to_string(), - protocol: protocol.to_string(), + pub fn new(name: &str, protocol: &str) -> Self { + ServiceId { + name: name.to_string(), + protocol: protocol.to_string(), + } } - } } // Per-service state struct ServiceState { - endpoints: Vec, - current_index: usize, + endpoints: Vec, + current_index: usize, } impl ServiceState { - fn new(endpoints: Vec) -> Self { - ServiceState { - endpoints, - current_index: 0, - } - } - - fn get_endpoint(&mut self, strategy: &LoadBalancingStrategy) -> Option { - if self.endpoints.is_empty() { - return None; + fn new(endpoints: Vec) -> Self { + ServiceState { + endpoints, + current_index: 0, + } } - match strategy { - LoadBalancingStrategy::Random => { - let mut rng = rand::thread_rng(); - self.endpoints.choose(&mut rng).copied() - } - LoadBalancingStrategy::RoundRobin => { - let endpoint = self.endpoints[self.current_index].clone(); - self.current_index = (self.current_index + 1) % self.endpoints.len(); - Some(endpoint) - } + fn get_endpoint(&mut self, strategy: &LoadBalancingStrategy) -> Option { + if self.endpoints.is_empty() { + return None; + } + + match strategy { + LoadBalancingStrategy::Random => { + let mut rng = rand::thread_rng(); + self.endpoints.choose(&mut rng).copied() + } + LoadBalancingStrategy::RoundRobin => { + let endpoint = self.endpoints[self.current_index].clone(); + self.current_index = (self.current_index + 1) % self.endpoints.len(); + Some(endpoint) + } + } } - } } pub struct MultiServiceLoadBalancer { - consul_url: String, - strategy: LoadBalancingStrategy, - services: Arc>>, + consul_url: String, + strategy: LoadBalancingStrategy, + services: Arc>>, } impl MultiServiceLoadBalancer { - pub fn new(consul_url: &str, strategy: LoadBalancingStrategy) -> Self { - MultiServiceLoadBalancer { - consul_url: consul_url.to_string(), - strategy, - services: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub async fn get_endpoint( - &self, - service_name: &str, - service_protocol: &str, - ) -> Result, Box> { - let service_id = ServiceId::new(service_name, service_protocol); - - // Try to get an endpoint from the cache first - { - let mut services = self.services.lock().unwrap(); - if let Some(service_state) = services.get_mut(&service_id) { - if let Some(endpoint) = service_state.get_endpoint(&self.strategy) { - return Ok(Some(endpoint)); + pub fn new(consul_url: &str, strategy: LoadBalancingStrategy) -> Self { + MultiServiceLoadBalancer { + consul_url: consul_url.to_string(), + strategy, + services: Arc::new(Mutex::new(HashMap::new())), } - } } - // If we don't have endpoints or they're all unavailable, refresh them - self.refresh_service_endpoints(service_name, service_protocol).await?; + pub async fn get_endpoint( + &self, + service_name: &str, + service_protocol: &str, + ) -> Result, Box> { + let service_id = ServiceId::new(service_name, service_protocol); - // Try again after refresh - let mut services = self.services.lock().unwrap(); - if let Some(service_state) = services.get_mut(&service_id) { - return Ok(service_state.get_endpoint(&self.strategy)); + // Try to get an endpoint from the cache first + { + let mut services = self.services.lock().unwrap(); + if let Some(service_state) = services.get_mut(&service_id) { + if let Some(endpoint) = service_state.get_endpoint(&self.strategy) { + return Ok(Some(endpoint)); + } + } + } + + // If we don't have endpoints or they're all unavailable, refresh them + self.refresh_service_endpoints(service_name, service_protocol).await?; + + // Try again after refresh + let mut services = self.services.lock().unwrap(); + if let Some(service_state) = services.get_mut(&service_id) { + return Ok(service_state.get_endpoint(&self.strategy)); + } + + Ok(None) } - Ok(None) - } + pub async fn refresh_service_endpoints( + &self, + service_name: &str, + service_protocol: &str, + ) -> Result<(), Box> { + let endpoints = get_service_endpoints_by_dns(&self.consul_url, service_protocol, service_name).await?; - pub async fn refresh_service_endpoints( - &self, - service_name: &str, - service_protocol: &str, - ) -> Result<(), Box> { - let endpoints = get_service_endpoints_by_dns( - &self.consul_url, - service_protocol, - service_name, - ).await?; + let service_id = ServiceId::new(service_name, service_protocol); + let mut services = self.services.lock().unwrap(); - let service_id = ServiceId::new(service_name, service_protocol); - let mut services = self.services.lock().unwrap(); - - services.insert(service_id, ServiceState::new(endpoints)); - Ok(()) - } - - pub async fn refresh_all_services(&self) -> Result<(), Box> { - let service_ids: Vec = { - let services = self.services.lock().unwrap(); - services.keys().cloned().collect() - }; - - for service_id in service_ids { - self.refresh_service_endpoints(&service_id.name, &service_id.protocol).await?; + services.insert(service_id, ServiceState::new(endpoints)); + Ok(()) } - Ok(()) - } -} \ No newline at end of file + pub async fn refresh_all_services(&self) -> Result<(), Box> { + let service_ids: Vec = { + let services = self.services.lock().unwrap(); + services.keys().cloned().collect() + }; + + for service_id in service_ids { + self.refresh_service_endpoints(&service_id.name, &service_id.protocol) + .await?; + } + + Ok(()) + } +} diff --git a/utils/src/redis_cache.rs b/utils/src/redis_cache.rs index ae63dcf..6f6021b 100644 --- a/utils/src/redis_cache.rs +++ b/utils/src/redis_cache.rs @@ -1,16 +1,12 @@ use async_trait::async_trait; use deadpool_redis::{Config, Pool, Runtime}; -use redis::{AsyncCommands, Commands, RedisError}; +use redis::{AsyncCommands, RedisError}; use serde::{Deserialize, Serialize}; #[async_trait] pub trait Cache { - async fn set( - &self, - key: &String, - value: &T, - ttl: u64, - ) -> Result<(), redis::RedisError>; + async fn set(&self, key: &String, value: &T, ttl: u64) + -> Result<(), redis::RedisError>; async fn update( &self, diff --git a/utils/src/service_discovery.rs b/utils/src/service_discovery.rs index 64af356..b8782ae 100644 --- a/utils/src/service_discovery.rs +++ b/utils/src/service_discovery.rs @@ -1,14 +1,18 @@ use hickory_resolver::config::*; -use hickory_resolver::{Resolver, TokioAsyncResolver}; +use hickory_resolver::system_conf::read_system_conf; +use hickory_resolver::{TokioAsyncResolver}; +use k8s_openapi::api::core::v1::Service; +use kube::{Api, Client}; +use std::collections::BTreeMap; use std::net::SocketAddr; use std::str::FromStr; -use kube::{Client, Api}; -use k8s_openapi::api::core::v1::Service; -use std::collections::{BTreeMap}; -use hickory_resolver::system_conf::read_system_conf; use tracing::debug; -pub async fn get_service_endpoints_by_dns(consul_url: &str, service_protocol: &str, service_name: &str) -> Result, Box> { +pub async fn get_service_endpoints_by_dns( + consul_url: &str, + service_protocol: &str, + service_name: &str, +) -> Result, Box> { let mut rc = ResolverConfig::new(); let url = consul_url.parse()?; rc.add_name_server(NameServerConfig::new(url, Protocol::Tcp)); @@ -23,14 +27,22 @@ pub async fn get_service_endpoints_by_dns(consul_url: &str, service_protocol: &s let hostname = record.target(); let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?; for response in lookup_responses { - endpoints.push(SocketAddr::from_str(&format!("{}:{}", &response.to_string(), record.port()))?); + endpoints.push(SocketAddr::from_str(&format!( + "{}:{}", + &response.to_string(), + record.port() + ))?); } } Ok(endpoints) } -pub async fn get_kube_service_endpoints_by_dns(port_name: &str, service_protocol: &str, service_name: &str) -> Result, Box> { +pub async fn get_kube_service_endpoints_by_dns( + port_name: &str, + service_protocol: &str, + service_name: &str, +) -> Result, Box> { let (config, options) = read_system_conf()?; let resolver = TokioAsyncResolver::tokio(config, options); @@ -42,7 +54,11 @@ pub async fn get_kube_service_endpoints_by_dns(port_name: &str, service_protocol let hostname = record.target(); let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?; for response in lookup_responses { - endpoints.push(SocketAddr::from_str(&format!("{}:{}", &response.to_string(), record.port()))?); + endpoints.push(SocketAddr::from_str(&format!( + "{}:{}", + &response.to_string(), + record.port() + ))?); } } @@ -58,7 +74,7 @@ pub struct ServiceInfo { } pub async fn get_service_info( - namespace: &str, + _namespace: &str, service_name: &str, ) -> Result> { let client = Client::try_default().await?; diff --git a/utils/src/signal_handler.rs b/utils/src/signal_handler.rs index 121961a..f98eb7f 100644 --- a/utils/src/signal_handler.rs +++ b/utils/src/signal_handler.rs @@ -16,15 +16,13 @@ async fn terminate_signal() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = - signal(SignalKind::terminate()).expect("Failed to set up SIGTERM handler"); + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to set up SIGTERM handler"); sigterm.recv().await; } #[cfg(windows)] { - let mut ctrlbreak = - signal::windows::ctrl_break().expect("Failed to set up CTRL_BREAK handler"); + let mut ctrlbreak = signal::windows::ctrl_break().expect("Failed to set up CTRL_BREAK handler"); ctrlbreak.recv().await; } } diff --git a/world-service/README.md b/world-service/README.md new file mode 100644 index 0000000..682a80c --- /dev/null +++ b/world-service/README.md @@ -0,0 +1,95 @@ +# World Service + +The World Service manages the game world state and character interactions in the MMORPG server architecture. + +## Overview + +The World Service is responsible for: +- Managing character positions and movement +- Handling map changes +- Processing character interactions +- Managing NPCs, monsters, and objects +- Handling combat and skills + +## Architecture + +The service is built using the following components: + +- **gRPC Server**: Exposes world management endpoints +- **World State Manager**: Maintains the current state of the game world +- **Map Manager**: Handles map data and transitions +- **Entity Manager**: Manages characters, NPCs, and monsters + +## Service Endpoints + +The World Service exposes the following gRPC endpoints: + +### GetCharacter +Retrieves a character's world state. + +```protobuf +rpc GetCharacter(CharacterRequest) returns (CharacterResponse); +``` + +### ChangeMap +Handles a character changing maps. + +```protobuf +rpc ChangeMap(ChangeMapRequest) returns (ChangeMapResponse); +``` + +### MoveCharacter +Updates a character's position. + +```protobuf +rpc MoveCharacter(CharacterMoveRequest) returns (CharacterMoveResponse); +``` + +### GetTargetHp +Retrieves an object's current HP. + +```protobuf +rpc GetTargetHp(ObjectHpRequest) returns (ObjectHpResponse); +``` + +## World Data Structure + +The world consists of: + +- **Maps**: Different areas with unique IDs +- **Spawn Points**: Locations where characters can appear +- **NPCs**: Non-player characters with fixed positions +- **Monsters**: Hostile entities that can move and attack +- **Objects**: Interactive items in the world + +## Configuration + +The service can be configured using environment variables: + +- `LISTEN_ADDR`: The address to listen on (default: "0.0.0.0") +- `SERVICE_PORT`: The port to listen on (default: "50054") +- `LOG_LEVEL`: Logging level (default: "info") + +## Running the Service + +### Local Development + +```bash +cargo run +``` + +### Docker + +```bash +docker build -t world-service . +docker run -p 50054:50054 world-service +``` + +## Integration with External Systems + +The World Service integrates with: + +- **Database Service**: For retrieving and storing world state +- **Auth Service**: For user authentication and authorization +- **Character Service**: For character data +- **Packet Service**: For handling client requests related to the world diff --git a/world-service/build.rs b/world-service/build.rs index 1ca8db6..14166c5 100644 --- a/world-service/build.rs +++ b/world-service/build.rs @@ -11,9 +11,6 @@ fn main() { tonic_build::configure() .build_server(false) // Generate gRPC client code .compile_well_known_types(true) - .compile_protos( - &["../proto/user_db_api.proto", "../proto/auth.proto"], - &["../proto"], - ) + .compile_protos(&["../proto/user_db_api.proto", "../proto/auth.proto"], &["../proto"]) .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); } diff --git a/world-service/src/main.rs b/world-service/src/main.rs index 46fbdbf..03f713d 100644 --- a/world-service/src/main.rs +++ b/world-service/src/main.rs @@ -1,7 +1,7 @@ use dotenv::dotenv; use std::env; -use utils::{health_check, logging}; use utils::service_discovery::{get_kube_service_endpoints_by_dns, get_service_endpoints_by_dns}; +use utils::{health_check, logging}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -12,7 +12,13 @@ async fn main() -> Result<(), Box> { // Set the gRPC server address let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "50054".to_string()); - let db_url = format!("http://{}",get_kube_service_endpoints_by_dns("database-service","tcp","database-service").await?.get(0).unwrap()); + let db_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("database-service", "tcp", "database-service") + .await? + .get(0) + .unwrap() + ); // Register service with Consul health_check::start_health_check(addr.as_str()).await?; -- 2.49.1 From 85d41c02391e99f6d07464d90a9fa552f7067318cd4d8da7a83d9e74f2be5e9c Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:39:43 -0400 Subject: [PATCH 2/3] Fixed Readme Flow Typo --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fe0df58..bb77ba1 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ The server architecture consists of the following microservices: ## Communication Flow -1. **Client → Launcher**: User launches the game via the launcher -2. **Launcher → Packet Service**: Game client connects to the packet service +1. **Launcher → Client**: User launches the game via the launcher +2. **Client → Packet Service**: Game client connects to the packet service 3. **Packet Service → Auth Service**: Validates user session 4. **Packet Service → Character Service**: Retrieves character data 5. **Packet Service → World Service**: Manages game world interactions -- 2.49.1 From ad6ba2c8e6d193e66d58a8f92d3d4d3b9a5d40af9bff041fb72ff9ad6de2962f Mon Sep 17 00:00:00 2001 From: raven <7156279+RavenX8@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:52:29 -0400 Subject: [PATCH 3/3] More work. Added chat service Updated packet service to pass the tcp stream around in a Arc type. Updated character position data to not require multiplying the coords Added more debug logs Added an interceptor for gRPC comms with the chat server Updated build and push script for the chat server changes --- Cargo.toml | 1 + character-service/src/character_db_client.rs | 4 +- charts/osirose-new/values.yaml | 18 ++- chat-service/Cargo.toml | 22 +++ chat-service/Dockerfile | 25 ++++ chat-service/build.rs | 16 +++ chat-service/src/chat_channels/guild_chat.rs | 22 +++ chat-service/src/chat_channels/local_chat.rs | 22 +++ chat-service/src/chat_channels/mod.rs | 12 ++ chat-service/src/chat_channels/shout_chat.rs | 20 +++ chat-service/src/chat_service.rs | 130 +++++++++++++++++ chat-service/src/main.rs | 51 +++++++ database-service/Cargo.toml | 1 + database-service/src/sessions.rs | 9 +- packet-service/Cargo.toml | 2 + packet-service/build.rs | 1 + packet-service/src/connection_state.rs | 21 ++- packet-service/src/handlers/auth.rs | 100 ++++++++----- packet-service/src/handlers/character.rs | 60 +++++--- packet-service/src/handlers/chat.rs | 136 ++++++++++++++++++ packet-service/src/handlers/chat_client.rs | 91 ++++++++++++ packet-service/src/handlers/mod.rs | 2 + packet-service/src/handlers/world.rs | 12 +- .../src/interceptors/auth_interceptor.rs | 20 +++ packet-service/src/interceptors/mod.rs | 1 + packet-service/src/lib.rs | 4 + packet-service/src/main.rs | 10 +- packet-service/src/router.rs | 41 +++--- proto/chat.proto | 9 +- scripts/build_and_push.py | 6 +- utils/src/service_discovery.rs | 3 +- world-service/src/main.rs | 7 + 32 files changed, 787 insertions(+), 92 deletions(-) create mode 100644 chat-service/Cargo.toml create mode 100644 chat-service/Dockerfile create mode 100644 chat-service/build.rs create mode 100644 chat-service/src/chat_channels/guild_chat.rs create mode 100644 chat-service/src/chat_channels/local_chat.rs create mode 100644 chat-service/src/chat_channels/mod.rs create mode 100644 chat-service/src/chat_channels/shout_chat.rs create mode 100644 chat-service/src/chat_service.rs create mode 100644 chat-service/src/main.rs create mode 100644 packet-service/src/handlers/chat.rs create mode 100644 packet-service/src/handlers/chat_client.rs create mode 100644 packet-service/src/interceptors/auth_interceptor.rs create mode 100644 packet-service/src/interceptors/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 37717d4..05ed338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "auth-service", + "chat-service", "character-service", "database-service", "packet-service", diff --git a/character-service/src/character_db_client.rs b/character-service/src/character_db_client.rs index 3876dcf..88d92ca 100644 --- a/character-service/src/character_db_client.rs +++ b/character-service/src/character_db_client.rs @@ -209,8 +209,8 @@ impl CharacterDbClient { }; let position = Position { map_id: 20, - x: 5200.00, - y: 5200.00, + x: 520000.00, + y: 520000.00, z: 1.0, spawn_id: 1, }; diff --git a/charts/osirose-new/values.yaml b/charts/osirose-new/values.yaml index 5a8e8d5..c24d266 100644 --- a/charts/osirose-new/values.yaml +++ b/charts/osirose-new/values.yaml @@ -6,7 +6,7 @@ autoscaling: global: env: - LOG_LEVEL: "debug" + LOG_LEVEL: "info" APP_ENV: "dev" DATABASE_URL: "" # This is a placeholder. Will be dynamically constructed REDIS_URL: "redis://valkey:6379/0" @@ -26,6 +26,20 @@ services: targetPort: 50051 protocol: TCP + - name: chat-service + replicas: 1 + serviceAccount: azgstudio-serviceaccount + image: chat-service:latest + port: 50055 + env: + SERVICE_PORT: 50055 + LOG_LEVEL: "debug" + service: + portName: chat-service + port: 50055 + targetPort: 50055 + protocol: TCP + - name: character-service replicas: 1 serviceAccount: azgstudio-serviceaccount @@ -61,6 +75,7 @@ services: port: 29000 env: SERVICE_PORT: 29000 + LOG_LEVEL: "debug" service: type: LoadBalancer portName: game-packet-service @@ -75,6 +90,7 @@ services: port: 50054 env: SERVICE_PORT: 50054 + LOG_LEVEL: "debug" service: annotations: name: "Athena" diff --git a/chat-service/Cargo.toml b/chat-service/Cargo.toml new file mode 100644 index 0000000..f3a9aa2 --- /dev/null +++ b/chat-service/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "chat-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +utils = { path = "../utils" } +dotenv = "0.15" +tokio = { version = "1.41.1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "chrono"] } +tonic = "0.12.3" +prost = "0.13.4" +warp = "0.3.7" +tonic-health = "0.12.3" +futures = "0.3.31" +uuid = "1.15.1" +tokio-stream = "0.1.17" + +[build-dependencies] +tonic-build = "0.12.3" \ No newline at end of file diff --git a/chat-service/Dockerfile b/chat-service/Dockerfile new file mode 100644 index 0000000..8c17f5a --- /dev/null +++ b/chat-service/Dockerfile @@ -0,0 +1,25 @@ +FROM rust:alpine AS builder +LABEL authors="raven" + +RUN apk add --no-cache musl-dev libressl-dev protobuf-dev + +WORKDIR /usr/src/utils +COPY ./utils . + +WORKDIR /usr/src/proto +COPY ./proto . + +WORKDIR /usr/src/chat-service +COPY ./chat-service . + +RUN cargo build --release + +FROM alpine:3 + +RUN apk add --no-cache libssl3 libgcc + +COPY --from=builder /usr/src/chat-service/target/release/chat-service /usr/local/bin/chat-service + +EXPOSE 50054 + +CMD ["chat-service"] \ No newline at end of file diff --git a/chat-service/build.rs b/chat-service/build.rs new file mode 100644 index 0000000..9b6fa18 --- /dev/null +++ b/chat-service/build.rs @@ -0,0 +1,16 @@ +fn main() { + // gRPC Server code + tonic_build::configure() + .build_server(true) // Generate gRPC server code + .compile_well_known_types(true) + .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") + .compile_protos(&["../proto/chat.proto"], &["../proto"]) + .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); + + // gRPC Client code + // tonic_build::configure() + // .build_server(false) // Generate gRPC client code + // .compile_well_known_types(true) + // .compile_protos(&["../proto/user_db_api.proto", "../proto/auth.proto"], &["../proto"]) + // .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); +} diff --git a/chat-service/src/chat_channels/guild_chat.rs b/chat-service/src/chat_channels/guild_chat.rs new file mode 100644 index 0000000..49332cc --- /dev/null +++ b/chat-service/src/chat_channels/guild_chat.rs @@ -0,0 +1,22 @@ +use crate::chat_channels::ChatChannel; +use crate::chat_service::Clients; +use crate::chat_service::chat::ChatMessage; + +/// A dedicated module for guild chat. +#[derive(Debug)] +pub struct GuildChat; + +impl ChatChannel for GuildChat { + fn handle_message(&self, message: ChatMessage, sender_id: &str, clients: &Clients) { + // This is a placeholder. In a real implementation, verify + // guild membership by consulting your Character or Guild service. + let clients_lock = clients.lock().unwrap(); + for (id, tx) in clients_lock.iter() { + // For demonstration, send only to clients whose IDs contain + // "guild". Replace this logic with your actual membership check. + if id != sender_id && id.contains("guild") { + let _ = tx.try_send(message.clone()); + } + } + } +} \ No newline at end of file diff --git a/chat-service/src/chat_channels/local_chat.rs b/chat-service/src/chat_channels/local_chat.rs new file mode 100644 index 0000000..778091c --- /dev/null +++ b/chat-service/src/chat_channels/local_chat.rs @@ -0,0 +1,22 @@ +use tracing::debug; +use crate::chat_channels::ChatChannel; +use crate::chat_service::Clients; +use crate::chat_service::chat::ChatMessage; + +/// A dedicated module for local chat. +#[derive(Debug)] +pub struct LocalChat; + +impl ChatChannel for LocalChat { + fn handle_message(&self, message: ChatMessage, sender_id: &str, clients: &Clients) { + // In a full implementation, you might query for nearby clients. + // For demo purposes, we simply broadcast to all clients except the sender. + debug!("LocalChat::handle_message: {:?}", message); + let clients_lock = clients.lock().unwrap(); + for (id, tx) in clients_lock.iter() { + // if id != sender_id { + let _ = tx.try_send(message.clone()); + // } + } + } +} \ No newline at end of file diff --git a/chat-service/src/chat_channels/mod.rs b/chat-service/src/chat_channels/mod.rs new file mode 100644 index 0000000..cff6da3 --- /dev/null +++ b/chat-service/src/chat_channels/mod.rs @@ -0,0 +1,12 @@ +pub mod local_chat; +pub mod shout_chat; +pub mod guild_chat; + +/// Define a common trait for all dedicated chat channels. +use crate::chat_service::Clients; +use crate::chat_service::chat::ChatMessage; + +pub trait ChatChannel: Send + Sync { + /// Process and route the chat message. + fn handle_message(&self, message: ChatMessage, sender_id: &str, clients: &Clients); +} \ No newline at end of file diff --git a/chat-service/src/chat_channels/shout_chat.rs b/chat-service/src/chat_channels/shout_chat.rs new file mode 100644 index 0000000..437e068 --- /dev/null +++ b/chat-service/src/chat_channels/shout_chat.rs @@ -0,0 +1,20 @@ +use crate::chat_channels::ChatChannel; +use crate::chat_service::Clients; +use crate::chat_service::chat::ChatMessage; + +/// A dedicated module for shout chat. +#[derive(Debug)] +pub struct ShoutChat; + +impl ChatChannel for ShoutChat { + fn handle_message(&self, message: ChatMessage, sender_id: &str, clients: &Clients) { + // For demo purposes, we simply broadcast to all clients except the sender. + // TODO: make sure the clients are on the same map + let clients_lock = clients.lock().unwrap(); + for (id, tx) in clients_lock.iter() { + if id != sender_id { + let _ = tx.try_send(message.clone()); + } + } + } +} \ No newline at end of file diff --git a/chat-service/src/chat_service.rs b/chat-service/src/chat_service.rs new file mode 100644 index 0000000..6e398d4 --- /dev/null +++ b/chat-service/src/chat_service.rs @@ -0,0 +1,130 @@ +use futures::{Stream, StreamExt}; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use tonic::{Request, Response, Status}; +use tracing::debug; + +pub mod common { + tonic::include_proto!("common"); +} +pub mod chat { + tonic::include_proto!("chat"); +} + +use chat::chat_service_server::{ChatService, ChatServiceServer}; +use chat::{ChatMessage, MessageType}; + +/// Type alias for storing client connections. +pub type Clients = +Arc>>>; + +use crate::chat_channels::ChatChannel; + +/// Our chat service struct. +#[derive(Clone)] +pub struct MyChatService { + pub clients: Clients, + pub local_channel: Arc, + pub shout_channel: Arc, + pub guild_channel: Arc, +} + +impl MyChatService { + /// Wrap our service as a gRPC service. + pub fn into_service(self) -> ChatServiceServer { + ChatServiceServer::new(self) + } +} + +#[tonic::async_trait] +impl ChatService for MyChatService { + type ChatStreamStream = + Pin> + Send + Sync + 'static>>; + + async fn chat_stream( + &self, + request: Request>, + ) -> Result, Status> { + debug!("New chat client connected"); + debug!("request: {:?}", request); + + let mut inbound = request.into_inner(); + + // Create a new client ID. In production, use authenticated IDs. + let client_id = format!("client-{}", uuid::Uuid::new_v4()); + + // Create a channel for sending outbound messages to this client. + let (tx, rx) = tokio::sync::mpsc::channel(32); + + { + let mut clients = self.clients.lock().unwrap(); + clients.insert(client_id.clone(), tx); + } + + // Clone shared resources for the spawned task. + let clients_clone = self.clients.clone(); + let local_channel = self.local_channel.clone(); + let shout_channel = self.shout_channel.clone(); + let guild_channel = self.guild_channel.clone(); + + tokio::spawn(async move { + while let Some(result) = inbound.next().await { + match result { + Ok(message) => { + debug!("message: {:?}", message); + // Dispatch based on the chat type. + match TryFrom::try_from(message.r#type) + .unwrap_or(MessageType::Normal) + { + MessageType::Normal => { + local_channel.handle_message( + message, + &client_id, + &clients_clone, + ); + } + MessageType::Shout => { + shout_channel.handle_message( + message, + &client_id, + &clients_clone, + ); + } + MessageType::Clan => { + guild_channel.handle_message( + message, + &client_id, + &clients_clone, + ); + } + // For other types, we simply broadcast as default. + _ => { + let clients_lock = clients_clone.lock().unwrap(); + for (id, tx) in clients_lock.iter() { + if id != &client_id { + let _ = tx.try_send(message.clone()); + } + } + } + } + } + Err(e) => { + eprintln!("Error receiving message from {}: {:?}", client_id, e); + break; + } + } + } + + // Remove the client when the stream ends. + let mut clients = clients_clone.lock().unwrap(); + clients.remove(&client_id); + println!("Client {} disconnected", client_id); + }); + + // Convert the rx half into a stream for the response. + let out_stream = tokio_stream::wrappers::ReceiverStream::new(rx) + .map(|msg| Ok(msg)); + Ok(Response::new(Box::pin(out_stream) as Self::ChatStreamStream)) + } +} \ No newline at end of file diff --git a/chat-service/src/main.rs b/chat-service/src/main.rs new file mode 100644 index 0000000..082d278 --- /dev/null +++ b/chat-service/src/main.rs @@ -0,0 +1,51 @@ +mod chat_service; +mod chat_channels; + +use dotenv::dotenv; +use std::env; +use utils::service_discovery::{get_kube_service_endpoints_by_dns, get_service_endpoints_by_dns}; +use utils::{health_check, logging}; +use chat_service::MyChatService; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tonic::transport::Server; +use crate::chat_service::chat::chat_service_server::ChatServiceServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenv().ok(); + let app_name = env!("CARGO_PKG_NAME"); + logging::setup_logging(app_name, &["chat_service", "health_check"]); + + // Set the gRPC server address + let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "50055".to_string()); + + let clients = Arc::new(Mutex::new(HashMap::new())); + + let chat_service = MyChatService { + clients: clients.clone(), + local_channel: Arc::new(chat_channels::local_chat::LocalChat), + shout_channel: Arc::new(chat_channels::shout_chat::ShoutChat), + guild_channel: Arc::new(chat_channels::guild_chat::GuildChat), + }; + + // Register service with Consul + let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + let address = SocketAddr::new(addr.parse()?, port.parse()?); + tokio::spawn( + Server::builder() + .add_service(chat_service.into_service()) + .add_service(health_service) + .serve(address), + ); + + println!("Chat Service listening on {}", address); + + utils::signal_handler::wait_for_signal().await; + Ok(()) +} diff --git a/database-service/Cargo.toml b/database-service/Cargo.toml index 6b84728..2321360 100644 --- a/database-service/Cargo.toml +++ b/database-service/Cargo.toml @@ -21,6 +21,7 @@ serde_json = "1.0.133" async-trait = "0.1.83" utils = { path = "../utils" } tonic-health = "0.12.3" +log = "0.4.26" [build-dependencies] tonic-build = "0.12.3" diff --git a/database-service/src/sessions.rs b/database-service/src/sessions.rs index 673664e..5498063 100644 --- a/database-service/src/sessions.rs +++ b/database-service/src/sessions.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Row}; use std::sync::Arc; use tokio::sync::Mutex; -use tracing::debug; +use log::debug; use utils::redis_cache::{Cache, RedisCache}; #[derive(Debug, FromRow, Serialize, Deserialize)] @@ -24,6 +24,8 @@ impl SessionRepository { pub async fn get_session(&self, session_id: &str) -> Result { let cache_key = format!("session:{}", session_id); + debug!("get_session: {:?}", session_id); + if let Some(session) = self .cache .lock() @@ -32,10 +34,13 @@ impl SessionRepository { .await .map_err(|_| sqlx::Error::RowNotFound)? { + debug!("Found session in cache: {:?}", session); return Ok(session); } - // Fetch from database + debug!("Session not found in cache, fetching from database"); + + // Fetch from the database let session = sqlx::query_as::<_, Session>("SELECT id, \"userId\" as user_id FROM session WHERE id = $1") .bind(session_id) .fetch_one(&self.pool) diff --git a/packet-service/Cargo.toml b/packet-service/Cargo.toml index 33edbd3..7ca8fa1 100644 --- a/packet-service/Cargo.toml +++ b/packet-service/Cargo.toml @@ -36,6 +36,8 @@ dashmap = "6.1.0" uuid = { version = "1.11.0", features = ["v4"] } chrono = "0.4.39" prometheus_exporter = "0.8.5" +futures = "0.3.31" +tokio-stream = "0.1.17" [build-dependencies] tonic-build = "0.12.3" diff --git a/packet-service/build.rs b/packet-service/build.rs index feec78b..6a69942 100644 --- a/packet-service/build.rs +++ b/packet-service/build.rs @@ -7,6 +7,7 @@ fn main() { .compile_protos( &[ "../proto/auth.proto", + "../proto/chat.proto", "../proto/character.proto", "../proto/character_common.proto", ], diff --git a/packet-service/src/connection_state.rs b/packet-service/src/connection_state.rs index a98306c..dd496cb 100644 --- a/packet-service/src/connection_state.rs +++ b/packet-service/src/connection_state.rs @@ -1,12 +1,17 @@ use std::collections::HashMap; +use std::sync::Arc; +use std::fmt; -#[derive(Clone, Debug)] +use crate::handlers::chat_client::ChatClientHandler; + +#[derive(Clone)] pub struct ConnectionState { pub user_id: Option, pub session_id: Option, pub character_id: Option, pub character_list: Option>, pub additional_data: HashMap, // Flexible data storage + pub chat_handler: Option>, } impl ConnectionState { @@ -17,6 +22,20 @@ impl ConnectionState { character_id: None, character_list: None, additional_data: HashMap::new(), + chat_handler: None, } } } + +impl fmt::Debug for ConnectionState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConnectionState") + .field("user_id", &self.user_id) + .field("session_id", &self.session_id) + .field("character_id", &self.character_id) + .field("character_list", &self.character_list) + .field("additional_data", &self.additional_data) + .field("chat_handler", &self.chat_handler.as_ref().map(|_| "")) + .finish() + } +} \ No newline at end of file diff --git a/packet-service/src/handlers/auth.rs b/packet-service/src/handlers/auth.rs index fa9d6c2..b642798 100644 --- a/packet-service/src/handlers/auth.rs +++ b/packet-service/src/handlers/auth.rs @@ -2,16 +2,6 @@ use crate::auth_client::AuthClient; use crate::connection_service::ConnectionService; use crate::packet::{send_packet, Packet, PacketPayload}; use crate::packet_type::PacketType; -use crate::packets::cli_channel_list_req::CliChannelListReq; -use crate::packets::cli_join_server_token_req::CliJoinServerTokenReq; -use crate::packets::cli_login_token_req::CliLoginTokenReq; -use crate::packets::cli_srv_select_req::CliSrvSelectReq; -use crate::packets::srv_accept_reply::SrvAcceptReply; -use crate::packets::srv_channel_list_reply::{ChannelInfo, SrvChannelListReply}; -use crate::packets::srv_join_server_reply::SrvJoinServerReply; -use crate::packets::srv_login_reply::{ServerInfo, SrvLoginReply}; -use crate::packets::srv_logout_reply::SrvLogoutReply; -use crate::packets::srv_srv_select_reply::SrvSrvSelectReply; use crate::packets::*; use std::collections::HashMap; use std::env; @@ -26,9 +16,11 @@ use tracing::{debug, error, info, warn}; use utils::null_string::NullTerminatedString; use utils::service_discovery; use utils::service_discovery::{get_kube_service_endpoints_by_dns, get_service_info}; +use crate::handlers::chat::create_chat_client_handler; +use crate::handlers::chat_client::ChatClientHandler; pub(crate) async fn handle_alive_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, auth_client: Arc>, connection_service: Arc, @@ -50,26 +42,30 @@ pub(crate) async fn handle_alive_req( } pub(crate) async fn handle_accept_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, ) -> Result<(), Box> { + use crate::packets::srv_accept_reply::SrvAcceptReply; let data = SrvAcceptReply { result: srv_accept_reply::Result::Accepted, rand_value: 0, }; let response_packet = Packet::new(PacketType::PakssAcceptReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } pub(crate) async fn handle_join_server_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, auth_client: Arc>, connection_service: Arc, connection_id: String, ) -> Result<(), Box> { + use crate::packets::cli_join_server_token_req::CliJoinServerTokenReq; + use crate::packets::srv_join_server_reply::SrvJoinServerReply; let request = CliJoinServerTokenReq::decode(packet.payload.as_slice()); debug!("{:?}", request); @@ -86,7 +82,8 @@ pub(crate) async fn handle_join_server_req( pay_flag: 0, }; let response_packet = Packet::new(PacketType::PakscJoinServerReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; return Err("Session not valid".into()); } @@ -96,7 +93,8 @@ pub(crate) async fn handle_join_server_req( pay_flag: 0, }; let response_packet = Packet::new(PacketType::PakscJoinServerReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } else { Err("Unable to find connection state".into()) @@ -104,12 +102,13 @@ pub(crate) async fn handle_join_server_req( } pub(crate) async fn handle_logout_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, auth_client: Arc>, connection_service: Arc, connection_id: String, ) -> Result<(), Box> { + use crate::packets::srv_logout_reply::SrvLogoutReply; if let Some(mut state) = connection_service.get_connection(&connection_id) { let session_id = state.session_id.clone().unwrap(); let mut auth_client = auth_client.lock().await; @@ -117,8 +116,9 @@ pub(crate) async fn handle_logout_req( let data = SrvLogoutReply { wait_time: 1 }; let response_packet = Packet::new(PacketType::PakwcLogoutReply, &data)?; - send_packet(stream, &response_packet).await?; - stream.shutdown().await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + locked_stream.shutdown().await?; Ok(()) } else { Err("Unable to find connection state".into()) @@ -126,13 +126,14 @@ pub(crate) async fn handle_logout_req( } pub(crate) async fn handle_login_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, auth_client: Arc>, connection_service: Arc, connection_id: String, - addr: SocketAddr, ) -> Result<(), Box> { + use crate::packets::cli_login_token_req::CliLoginTokenReq; + use crate::packets::srv_login_reply::{ServerInfo, SrvLoginReply}; debug!("decoding packet payload of size {}", packet.payload.as_slice().len()); let data = CliLoginTokenReq::decode(packet.payload.as_slice())?; debug!("{:?}", data); @@ -140,6 +141,7 @@ pub(crate) async fn handle_login_req( let mut auth_client = auth_client.lock().await; match auth_client.validate_session(&data.token.0).await { Ok(response) => { + debug!("Response: {:?}", response); if response.valid == false { info!("Login failed: Invalid credentials"); @@ -150,14 +152,32 @@ pub(crate) async fn handle_login_req( servers_info: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } else { debug!("Successfully logged in"); if let Some(mut state) = connection_service.get_connection_mut(&connection_id) { - debug!("Response: {:?}", response); state.user_id = Some(response.user_id); - state.session_id = Some(response.session_id); + state.session_id = Some(response.session_id.clone()); + } + + let chat_url = format!( + "http://{}", + get_kube_service_endpoints_by_dns("chat-service", "tcp", "chat-service") + .await + .expect("Failed to get chat service endpoints") + .get(0) + .unwrap() + ); + + let handler = ChatClientHandler::new(chat_url, connection_id.clone(), response.session_id.clone()).await?; + let chat_handler = Arc::new(handler); + + create_chat_client_handler(stream.clone(), chat_handler.clone()).await?; + + if let Some(mut state) = connection_service.get_connection_mut(&connection_id) { + state.chat_handler = Some(chat_handler); } let mut id = 0; @@ -196,7 +216,8 @@ pub(crate) async fn handle_login_req( servers_info: server_info, }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } } Err(err) => { @@ -208,7 +229,8 @@ pub(crate) async fn handle_login_req( servers_info: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; return Ok(()); } } @@ -227,7 +249,8 @@ pub(crate) async fn handle_login_req( servers_info: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } Code::Unavailable => { warn!("Login failed: Service is unavailable"); @@ -238,7 +261,8 @@ pub(crate) async fn handle_login_req( servers_info: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } _ => { error!("Unexpected error: {}", tonic_status.message()); @@ -249,7 +273,8 @@ pub(crate) async fn handle_login_req( servers_info: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcLoginReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } } } @@ -260,11 +285,13 @@ pub(crate) async fn handle_login_req( } pub(crate) async fn handle_server_select_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, connection_service: Arc, connection_id: String, ) -> Result<(), Box> { + use crate::packets::cli_srv_select_req::CliSrvSelectReq; + use crate::packets::srv_srv_select_reply::SrvSrvSelectReply; let request = CliSrvSelectReq::decode(packet.payload.as_slice())?; debug!("{:?}", request); @@ -286,14 +313,17 @@ pub(crate) async fn handle_server_select_req( }; let response_packet = Packet::new(PacketType::PaklcSrvSelectReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } pub(crate) async fn handle_channel_list_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, ) -> Result<(), Box> { + use crate::packets::cli_channel_list_req::CliChannelListReq; + use crate::packets::srv_channel_list_reply::{ChannelInfo, SrvChannelListReply}; let request = CliChannelListReq::decode(packet.payload.as_slice()); debug!("{:?}", request); @@ -326,7 +356,8 @@ pub(crate) async fn handle_channel_list_req( channels: channel_info, }; let response_packet = Packet::new(PacketType::PaklcChannelListReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; } } Err(err) => { @@ -335,7 +366,8 @@ pub(crate) async fn handle_channel_list_req( channels: Vec::new(), }; let response_packet = Packet::new(PacketType::PaklcChannelListReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; return Ok(()); } } diff --git a/packet-service/src/handlers/character.rs b/packet-service/src/handlers/character.rs index 4734c43..6658781 100644 --- a/packet-service/src/handlers/character.rs +++ b/packet-service/src/handlers/character.rs @@ -17,7 +17,7 @@ use tonic::{Code, Status}; use tracing::{debug, error, info, warn}; use utils::null_string::NullTerminatedString; -fn string_to_u32(s: &str) -> u32 { +pub(crate) fn string_to_u32(s: &str) -> u32 { let mut hasher = DefaultHasher::new(); s.hash(&mut hasher); // Convert the 64-bit hash to a 32-bit number. @@ -68,7 +68,7 @@ pub(crate) fn convert_type_to_body_part(slot: i32) -> ItemType { } pub(crate) async fn handle_char_list_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, character_client: Arc>, connection_service: Arc, @@ -91,6 +91,8 @@ pub(crate) async fn handle_char_list_req( let character_list = character_client.get_character_list(&user_id).await?; let mut characters = vec![]; let mut character_id_list: Vec = Vec::new(); + + // Build the visible inventory for character in character_list.characters { let mut item_list: [EquippedItem; (MAX_VISIBLE_ITEMS as usize)] = core::array::from_fn(|i| EquippedItem::default()); @@ -128,13 +130,14 @@ pub(crate) async fn handle_char_list_req( let data = SrvCharListReply { characters }; let response_packet = Packet::new(PacketType::PakccCharListReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } pub(crate) async fn handle_create_char_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, character_client: Arc>, connection_service: Arc, @@ -176,13 +179,14 @@ pub(crate) async fn handle_create_char_req( let data = SrvCreateCharReply { result, platininum: 0 }; let response_packet = Packet::new(PacketType::PakccCreateCharReply, &data)?; - send_packet(stream, &response_packet).await?; - + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + Ok(()) } pub(crate) async fn handle_delete_char_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, character_client: Arc>, connection_service: Arc, @@ -218,13 +222,14 @@ pub(crate) async fn handle_delete_char_req( name: character_name, }; let response_packet = Packet::new(PacketType::PakccDeleteCharReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } pub(crate) async fn handle_select_char_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, character_client: Arc>, connection_service: Arc, @@ -255,7 +260,10 @@ pub(crate) async fn handle_select_char_req( ip: NullTerminatedString("".to_string()), }; let response_packet = Packet::new(PacketType::PakccSwitchServer, &data)?; - send_packet(stream, &response_packet).await?; + { + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + } let mut character_client = character_client.lock().await; let character_data = character_client @@ -275,12 +283,14 @@ pub(crate) async fn handle_select_char_req( core::array::from_fn(|i| EquippedItem::default()); let mut inventory: [srv_inventory_data::Item; (MAX_ITEMS as usize)] = core::array::from_fn(|i| srv_inventory_data::Item::default()); + + // Build the character learned skill list let mut skill_list: [u16; (MAX_SKILL_COUNT as usize)] = [0u16; MAX_SKILL_COUNT as usize]; - for index in 0..skills.len() { skill_list[index] = skills[index].id as u16; } + // Build the character inventory list for item in items { if item.slot < MAX_VISIBLE_ITEMS as i32 { let slot = convert_type_to_body_part(item.slot) as isize - 2; @@ -318,8 +328,8 @@ pub(crate) async fn handle_select_char_req( let data = SrvSelectCharReply { race: looks.race as u8, map: position.map_id as u16, - x: position.x * 100.0, - y: position.y * 100.0, + x: position.x, + y: position.y, spawn: position.spawn_id as u16, body_face: looks.face as u32, body_hair: looks.hair as u32, @@ -362,7 +372,10 @@ pub(crate) async fn handle_select_char_req( name, }; let response_packet = Packet::new(PacketType::PakwcSelectCharReply, &data)?; - send_packet(stream, &response_packet).await?; + { + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + } // here we build the inventory let data = SrvInventoryData { @@ -370,7 +383,10 @@ pub(crate) async fn handle_select_char_req( items: inventory, }; let response_packet = Packet::new(PacketType::PakwcInventoryData, &data)?; - send_packet(stream, &response_packet).await?; + { + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + } // Now we need to build the Quest data let mut quests: [srv_quest_data::Quest; (MAX_QUESTS as usize)] = @@ -388,15 +404,21 @@ pub(crate) async fn handle_select_char_req( wishlist, }; let response_packet = Packet::new(PacketType::PakwcQuestData, &data)?; - send_packet(stream, &response_packet).await?; - - // Send the billing message (we don't actually use this so we just send the defaults to allow) + { + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + } + + // Send the billing message (we don't use this, so we just send the defaults to allow) let data = SrvBillingMessage { function_type: 0x1001, pay_flag: 2, }; let response_packet = Packet::new(PacketType::PakwcBillingMessage, &data)?; - send_packet(stream, &response_packet).await?; + { + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; + } Ok(()) } diff --git a/packet-service/src/handlers/chat.rs b/packet-service/src/handlers/chat.rs new file mode 100644 index 0000000..8a1ef64 --- /dev/null +++ b/packet-service/src/handlers/chat.rs @@ -0,0 +1,136 @@ +use crate::character_client::CharacterClient; +use crate::connection_service::ConnectionService; +use crate::packet::{send_packet, Packet, PacketPayload}; +use crate::packet_type::PacketType; +use chrono::{Local, Timelike}; +use std::error::Error; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tonic::transport::Channel; +use tracing::{debug, error}; +use utils::null_string::NullTerminatedString; +use utils::service_discovery::get_kube_service_endpoints_by_dns; +use crate::handlers::chat_client::chat::{ChatMessage, MessageType}; +use crate::handlers::chat_client::ChatClientHandler; + +pub async fn create_chat_client_handler( + stream_for_task: Arc>, + task_chat_handler: Arc, +) -> Result<(), Box> { + use crate::packets::srv_normal_chat::SrvNormalChat; + use crate::packets::srv_shout_chat::SrvShoutChat; + use crate::packets::srv_party_chat::SrvPartyChat; + use crate::packets::srv_whisper_chat::SrvWhisperChat; + use crate::packets::srv_clan_chat::SrvClanChat; + use crate::packets::srv_allied_chat::SrvAlliedChat; + tokio::spawn({ + async move { + debug!("Spawning chat handler task"); + loop { + let mut rx = task_chat_handler.inbound_rx.lock().await; + while let Some(chat_msg) = rx.recv().await { + debug!("Packet-Service received chat message: {} (client_id: {}, type {})", chat_msg.message, chat_msg.client_id, chat_msg.r#type); + + debug!("Locking stream"); + let mut locked_stream = stream_for_task.lock().await; + debug!("Locked stream"); + match chat_msg.r#type { + 1 => { + // Normal Chat + let data = SrvNormalChat { + char_id: chat_msg.client_id.parse().unwrap_or(696), + message: NullTerminatedString(chat_msg.message), + }; + + // Send the packet to the client + let response_packet = Packet::new(PacketType::PakwcNormalChat, &data); + debug!("Attempting to send normal chat to client"); + if let Err(e) = send_packet(&mut locked_stream, &response_packet.unwrap()).await + { + error!("unable to send normal chat: {:?}", e); + } + } + 2 => { + // Shout Chat + let data = SrvShoutChat { + sender: Default::default(), + message: NullTerminatedString(chat_msg.message), + }; + let response_packet = Packet::new(PacketType::PakwcShoutChat, &data); + debug!("Attempting to send shout chat to client"); + if let Err(e) = send_packet(&mut locked_stream, &response_packet.unwrap()).await + { + error!("unable to send shout chat: {:?}", e); + } + } + 3 => { + // Party Chat + } + 4 => { + // Whisper Chat + } + 5 => { + // Clan Chat + } + 6 => { + // Allied Chat + } + _ => { + // Normal Chat + let data = SrvNormalChat { + char_id: 0, + message: NullTerminatedString(chat_msg.message), + }; + + // Send the packet to the client + let response_packet = Packet::new(PacketType::PakwcNormalChat, &data); + if let Err(e) = send_packet(&mut locked_stream, &response_packet.unwrap()).await + { + error!("unable to send normal chat: {:?}", e); + } + } + } + } + } + debug!("Chat handler task exiting"); + } + }); + + Ok(()) +} + +pub(crate) async fn handle_normal_chat( + stream: Arc>, + packet: Packet, + connection_service: Arc, + connection_id: String, +) -> Result<(), Box> { + use crate::packets::cli_normal_chat::*; + use crate::packets::srv_normal_chat::*; + let request = CliNormalChat::decode(packet.payload.as_slice())?; + debug!("{:?}", request); + + if let Some(mut state) = connection_service.get_connection(&connection_id) { + let user_id = state.user_id.clone().expect("Missing user id in connection state"); + let message = ChatMessage { + client_id: crate::handlers::character::string_to_u32(&user_id).to_string(), + r#type: MessageType::Normal as i32, + message: request.message.clone().0, + target_id: "".to_string(), + }; + state.chat_handler.unwrap().send_message(message).await; + } + + + // We're not sending here because we should get a message back from the chat service + // let data = SrvNormalChat { + // char_id: 0, + // message: request.message, + // }; + // let response_packet = Packet::new(PacketType::PakwcNormalChat, &data)?; + // let mut locked_stream = stream.lock().await; + // send_packet(&mut locked_stream, &response_packet).await?; + + Ok(()) +} diff --git a/packet-service/src/handlers/chat_client.rs b/packet-service/src/handlers/chat_client.rs new file mode 100644 index 0000000..8ec123f --- /dev/null +++ b/packet-service/src/handlers/chat_client.rs @@ -0,0 +1,91 @@ +use tonic::{Request, transport::Channel}; +use futures::StreamExt; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::wrappers::ReceiverStream; +use std::error::Error; + +pub mod chat { + tonic::include_proto!("chat"); +} + +use chat::chat_service_client::ChatServiceClient; +use chat::ChatMessage; +use crate::interceptors::auth_interceptor::AuthInterceptor; + +/// ChatClientHandler encapsulates the bidirectional chat stream. +/// In addition to providing an API to send messages, it also spawns a +/// background task which forwards incoming chat messages through an inbound channel. +pub struct ChatClientHandler { + outbound_tx: mpsc::Sender, + /// Inbound messages from the chat service are sent here. + pub inbound_rx: Mutex>, +} + +impl ChatClientHandler { + /// Creates and returns a new ChatClientHandler. + /// + /// * `chat_url` - Full URL of the Chat Service (for example, "http://127.0.0.1:50051") + /// * `client_id` - The authenticated client ID to be injected into each request. + /// * `session_id` - The authenticated session token to be injected into each request. + pub async fn new( + chat_url: String, + client_id: String, + session_id: String, + ) -> Result> { + // Create a channel to the Chat Service. + let channel = Channel::from_shared(chat_url)?.connect().await + .map_err(|e| Box::new(e) as Box)?; + let interceptor = AuthInterceptor { client_id, session_id }; + + // Create ChatService client with interceptor. + let mut chat_client = ChatServiceClient::with_interceptor(channel, interceptor); + + // Create an mpsc channel for outbound messages. + let (out_tx, out_rx) = mpsc::channel(32); + let outbound_stream = ReceiverStream::new(out_rx); + + // This channel will be used to forward inbound messages to the packet-service. + let (in_tx, in_rx) = mpsc::channel(32); + + // Establish the bidirectional chat stream. + let request = Request::new(outbound_stream); + let mut response = chat_client.chat_stream(request).await + .map_err(|e| Box::new(e) as Box)?.into_inner(); + + // Spawn a task to continuously receive messages from the Chat Service. + // Each received message is sent through the 'in_tx' channel. + tokio::spawn(async move { + while let Some(result) = response.next().await { + match result { + Ok(chat_msg) => { + // You might translate or process the chat_msg here, + // then forward it to your packet-service logic. + if let Err(e) = in_tx.send(chat_msg).await { + eprintln!("Failed to forward chat message: {:?}", e); + break; + } + } + Err(e) => { + eprintln!("Error receiving chat stream message: {:?}", e); + break; + } + } + } + println!("Chat inbound stream closed"); + }); + + Ok(Self { + outbound_tx: out_tx, + inbound_rx: Mutex::new(in_rx), + }) + } + + /// Sends a chat message to the Chat Service. + pub async fn send_message( + &self, + message: ChatMessage, + ) -> Result<(), Box> { + self.outbound_tx.send(message).await?; + Ok(()) + } +} \ No newline at end of file diff --git a/packet-service/src/handlers/mod.rs b/packet-service/src/handlers/mod.rs index fb98300..97c1a36 100644 --- a/packet-service/src/handlers/mod.rs +++ b/packet-service/src/handlers/mod.rs @@ -1,3 +1,5 @@ pub mod auth; pub mod character; pub mod world; +pub mod chat; +pub mod chat_client; diff --git a/packet-service/src/handlers/world.rs b/packet-service/src/handlers/world.rs index cdcdb8d..72db0b0 100644 --- a/packet-service/src/handlers/world.rs +++ b/packet-service/src/handlers/world.rs @@ -7,7 +7,9 @@ use std::error::Error; use std::sync::Arc; use tokio::net::TcpStream; use tokio::sync::Mutex; +use tonic::transport::Channel; use tracing::debug; +use utils::service_discovery::get_kube_service_endpoints_by_dns; fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> u16 { let dist = ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt(); @@ -15,7 +17,7 @@ fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> u16 { } pub(crate) async fn handle_change_map_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, character_client: Arc>, connection_service: Arc, @@ -64,13 +66,14 @@ pub(crate) async fn handle_change_map_req( team_number: 10, }; let response_packet = Packet::new(PacketType::PakwcChangeMapReply, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } pub(crate) async fn handle_mouse_cmd_req( - stream: &mut TcpStream, + stream: Arc>, packet: Packet, connection_service: Arc, connection_id: String, @@ -96,6 +99,7 @@ pub(crate) async fn handle_mouse_cmd_req( z: request.z, }; let response_packet = Packet::new(PacketType::PakwcMouseCmd, &data)?; - send_packet(stream, &response_packet).await?; + let mut locked_stream = stream.lock().await; + send_packet(&mut locked_stream, &response_packet).await?; Ok(()) } diff --git a/packet-service/src/interceptors/auth_interceptor.rs b/packet-service/src/interceptors/auth_interceptor.rs new file mode 100644 index 0000000..51fa3fd --- /dev/null +++ b/packet-service/src/interceptors/auth_interceptor.rs @@ -0,0 +1,20 @@ +use tonic::{Request, Status, service::Interceptor}; + +#[derive(Clone, Debug)] +pub struct AuthInterceptor { + pub client_id: String, + pub session_id: String, +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + // Attach the authenticated client ID into the metadata. + request + .metadata_mut() + .insert("x-client-id", self.client_id.parse().unwrap()); + request + .metadata_mut() + .insert("x-session-id", self.session_id.parse().unwrap()); + Ok(request) + } +} \ No newline at end of file diff --git a/packet-service/src/interceptors/mod.rs b/packet-service/src/interceptors/mod.rs new file mode 100644 index 0000000..ca05488 --- /dev/null +++ b/packet-service/src/interceptors/mod.rs @@ -0,0 +1 @@ +pub mod auth_interceptor; \ No newline at end of file diff --git a/packet-service/src/lib.rs b/packet-service/src/lib.rs index 1f8322d..be0a3e1 100644 --- a/packet-service/src/lib.rs +++ b/packet-service/src/lib.rs @@ -5,3 +5,7 @@ pub mod connection_state; pub mod metrics; pub mod packet; pub mod packet_type; +pub mod handlers { + pub mod chat_client; +} +pub mod interceptors; diff --git a/packet-service/src/main.rs b/packet-service/src/main.rs index e426fe7..9dc187d 100644 --- a/packet-service/src/main.rs +++ b/packet-service/src/main.rs @@ -39,18 +39,19 @@ mod packet_type; mod packets; mod router; mod types; +mod interceptors; pub mod common { tonic::include_proto!("common"); } pub mod auth { - tonic::include_proto!("auth"); // Path matches the package name in auth.proto + tonic::include_proto!("auth"); } pub mod character_common { - tonic::include_proto!("character_common"); // Path matches the package name in auth.proto + tonic::include_proto!("character_common"); } pub mod character { - tonic::include_proto!("character"); // Path matches the package name in auth.proto + tonic::include_proto!("character"); } const BUFFER_POOL_SIZE: usize = 1000; @@ -110,13 +111,14 @@ async fn main() -> Result<(), Box> { let pool = buffer_pool.clone(); let permit = semaphore.clone().acquire_owned().await.unwrap(); + let stream = Arc::new(Mutex::new(socket)); // Spawn a new task for each connection tokio::spawn(async move { let _permit = permit; let connection_id = packet_router.connection_service.add_connection(); if let Err(e) = packet_router - .handle_connection(&mut socket, pool, connection_id.clone()) + .handle_connection(stream, pool, connection_id.clone()) .await { error!("Error handling connection: {}", e); diff --git a/packet-service/src/router.rs b/packet-service/src/router.rs index 6a3414e..4e16059 100644 --- a/packet-service/src/router.rs +++ b/packet-service/src/router.rs @@ -23,24 +23,30 @@ pub struct PacketRouter { impl PacketRouter { pub async fn handle_connection( &self, - stream: &mut TcpStream, + stream: Arc>, pool: Arc, connection_id: String, ) -> Result<(), Box> { ACTIVE_CONNECTIONS.inc(); while let Some(mut buffer) = pool.acquire().await { // Read data into the buffer - let mut header_handle = stream.take(6); - let n = header_handle.read(&mut buffer).await?; - if n == 0 { - break; // Connection closed - } - let packet_size = u16::from_le_bytes(buffer[0..2].try_into()?) as usize; - if packet_size > 6 { - let mut body_handle = stream.take((packet_size - 6) as u64); - let n = body_handle.read(&mut buffer[6..]).await?; - if n == 0 { - break; // Connection closed + let packet_size: usize; + { + let mut locked_stream = stream.lock().await; + locked_stream.read_exact(&mut buffer[..6]).await?; + // let mut header_handle = locked_stream.take(6); + // let n = header_handle.read(&mut buffer).await?; + // if n == 0 { + // break; // Connection closed + // } + packet_size = u16::from_le_bytes(buffer[0..2].try_into()?) as usize; + if packet_size > 6 { + locked_stream.read_exact(&mut buffer[6..packet_size]).await?; + // let mut body_handle = locked_stream.take((packet_size - 6) as u64); + // let n = body_handle.read(&mut buffer[6..]).await?; + // if n == 0 { + // break; // Connection closed + // } } } @@ -52,7 +58,7 @@ impl PacketRouter { Ok(packet) => { debug!("Parsed Packet: {:?}", packet); // Handle the parsed packet (route it, process it, etc.) - self.route_packet(stream, packet, connection_id.clone()).await?; + self.route_packet(stream.clone(), packet, connection_id.clone()).await?; } Err(e) => warn!("Failed to parse packet: {}", e), } @@ -67,7 +73,8 @@ impl PacketRouter { let mut auth_client = self.auth_client.lock().await; auth_client.logout(&session_id).await?; } else { - warn!("No session found for {}", stream.peer_addr()?); + let mut locked_stream = stream.lock().await; + warn!("No session found for {}", locked_stream.peer_addr()?); } } ACTIVE_CONNECTIONS.dec(); @@ -77,7 +84,7 @@ impl PacketRouter { #[rustfmt::skip] pub async fn route_packet( &self, - stream: &mut TcpStream, + stream: Arc>, packet: Packet, connection_id: String, ) -> Result<(), Box> { @@ -88,7 +95,7 @@ impl PacketRouter { PacketType::PakcsAcceptReq => auth::handle_accept_req(stream, packet).await, PacketType::PakcsJoinServerTokenReq => auth::handle_join_server_req(stream, packet, self.auth_client.clone(), self.connection_service.clone(), connection_id).await, // Login Packets - PacketType::PakcsLoginTokenReq => auth::handle_login_req(stream, packet, self.auth_client.clone(), self.connection_service.clone(), connection_id, stream.peer_addr()?).await, + PacketType::PakcsLoginTokenReq => auth::handle_login_req(stream, packet, self.auth_client.clone(), self.connection_service.clone(), connection_id).await, PacketType::PakcsLogoutReq => auth::handle_logout_req(stream, packet, self.auth_client.clone(), self.connection_service.clone(), connection_id).await, PacketType::PakcsSrvSelectReq => auth::handle_server_select_req(stream, packet, self.connection_service.clone(), connection_id).await, PacketType::PakcsChannelListReq => auth::handle_channel_list_req(stream, packet).await, @@ -103,6 +110,8 @@ impl PacketRouter { PacketType::PakcsChangeMapReq => world::handle_change_map_req(stream, packet, self.character_client.clone(), self.connection_service.clone(), connection_id).await, PacketType::PakcsMouseCmd => world::handle_mouse_cmd_req(stream, packet, self.connection_service.clone(), connection_id).await, + // Chat Packets + PacketType::PakcsNormalChat => chat::handle_normal_chat(stream, packet, self.connection_service.clone(), connection_id).await, // 1 => chat::handle_chat(packet).await?, // 2 => movement::handle_movement(packet).await?, _ => { diff --git a/proto/chat.proto b/proto/chat.proto index 79465b3..c2bc00b 100644 --- a/proto/chat.proto +++ b/proto/chat.proto @@ -5,7 +5,7 @@ package chat; import "common.proto"; service ChatService { - rpc SendMessage(ChatMessage) returns (common.Empty); + rpc ChatStream(stream ChatMessage) returns (stream ChatMessage); } enum MessageType { @@ -19,7 +19,8 @@ enum MessageType { } message ChatMessage { - MessageType type = 1; - string message = 2; - string target = 3; + string client_id = 1; + MessageType type = 2; + string message = 3; + string target_id = 4; } diff --git a/scripts/build_and_push.py b/scripts/build_and_push.py index 9ae754b..1a49c3d 100644 --- a/scripts/build_and_push.py +++ b/scripts/build_and_push.py @@ -3,21 +3,19 @@ import os # Define your images, tags, and Dockerfile paths images = [ - # "api-service", "auth-service", + "chat-service", "character-service", "database-service", "packet-service", - # "session-service", "world-service" ] dockerfile_paths = [ - # "../api-service/Dockerfile", "../auth-service/Dockerfile", + "../chat-service/Dockerfile", "../character-service/Dockerfile", "../database-service/Dockerfile", "../packet-service/Dockerfile", - # "../session-service/Dockerfile", "../world-service/Dockerfile", ] diff --git a/utils/src/service_discovery.rs b/utils/src/service_discovery.rs index b8782ae..6d3bfbf 100644 --- a/utils/src/service_discovery.rs +++ b/utils/src/service_discovery.rs @@ -43,6 +43,7 @@ pub async fn get_kube_service_endpoints_by_dns( service_protocol: &str, service_name: &str, ) -> Result, Box> { + debug!("Looking up service '{}'", service_name); let (config, options) = read_system_conf()?; let resolver = TokioAsyncResolver::tokio(config, options); @@ -61,7 +62,7 @@ pub async fn get_kube_service_endpoints_by_dns( ))?); } } - + debug!("Got endpoints: {:?}", endpoints); Ok(endpoints) } diff --git a/world-service/src/main.rs b/world-service/src/main.rs index 03f713d..d0c73dd 100644 --- a/world-service/src/main.rs +++ b/world-service/src/main.rs @@ -19,6 +19,13 @@ async fn main() -> Result<(), Box> { .get(0) .unwrap() ); + let chat_service = format!( + "http://{}", + get_kube_service_endpoints_by_dns("chat-service", "tcp", "chat-service") + .await? + .get(0) + .unwrap() + ); // Register service with Consul health_check::start_health_check(addr.as_str()).await?; -- 2.49.1