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?;