Add comprehensive documentation and unit tests

Documentation:
- Add detailed README files for all services (auth, character, database, launcher, packet, utils, world)
- Create API documentation for the database service with detailed endpoint specifications
- Document database schema and relationships
- Add service architecture overviews and configuration instructions

Unit Tests:
- Implement comprehensive test suite for database repositories (user, character, session)
- Add gRPC service tests for database interactions
- Create tests for packet service components (bufferpool, connection, packets)
- Add utility service tests (health check, logging, load balancer, redis cache, service discovery)
- Implement auth service user tests
- Add character service tests

Code Structure:
- Reorganize test files into a more consistent structure
- Create a dedicated tests crate for integration testing
- Add test helpers and mock implementations for easier testing
This commit is contained in:
2025-04-09 13:29:38 -04:00
parent d47d5f44b1
commit a8755bd3de
85 changed files with 4218 additions and 764 deletions

View File

@@ -1,4 +1,5 @@
[workspace] [workspace]
resolver = "2"
members = [ members = [
"auth-service", "auth-service",
"character-service", "character-service",
@@ -6,6 +7,12 @@ members = [
"packet-service", "packet-service",
"world-service", "world-service",
"utils", "utils",
"launcher" "launcher",
] ]
resolver = "2"
[workspace.package]
edition = "2021"
# Include tests directory only when testing
[workspace.metadata.test]
members = ["tests"]

111
README.md
View File

@@ -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.

94
auth-service/README.md Normal file
View File

@@ -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

View File

@@ -12,10 +12,7 @@ fn main() {
.build_server(false) // Generate gRPC client code .build_server(false) // Generate gRPC client code
.compile_well_known_types(true) .compile_well_known_types(true)
.compile_protos( .compile_protos(
&[ &["../proto/user_db_api.proto", "../proto/session_db_api.proto"],
"../proto/user_db_api.proto",
"../proto/session_db_api.proto",
],
&["../proto"], &["../proto"],
) )
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));

View File

@@ -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 async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::error::Error; use std::error::Error;
use tonic::transport::Channel; use tonic::transport::Channel;
#[async_trait] #[async_trait]
pub trait DatabaseClientTrait: Sized { pub trait DatabaseClientTrait: Sized {
async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>>; async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>>;
async fn get_user_by_userid( async fn get_user_by_userid(&mut self, user_id: i32) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
&mut self, async fn get_user_by_username(&mut self, user_id: &str) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
user_id: i32, async fn get_user_by_email(&mut self, email: &str) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
async fn get_user_by_username(
&mut self,
user_id: &str,
) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
async fn get_user_by_email(
&mut self,
email: &str,
) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
async fn store_password_reset(
&mut self,
email: &str,
reset_token: &str,
expires_at: DateTime<Utc>,
) -> Result<(), Box<dyn std::error::Error>>;
async fn get_password_reset(
&self,
reset_token: &str,
) -> Result<Option<PasswordReset>, Box<dyn std::error::Error>>;
async fn delete_password_reset(
&self,
reset_token: &str,
) -> Result<(), Box<dyn std::error::Error>>;
async fn update_user_password(
&self,
email: &str,
hashed_password: &str,
) -> Result<(), Box<dyn std::error::Error>>;
} }
#[derive(Clone)] #[derive(Clone)]
pub struct DatabaseClient { pub struct DatabaseClient {
client: UserServiceClient<Channel>, client: UserServiceClient<Channel>,
} }
#[derive(Debug)]
pub struct PasswordReset {
pub email: String,
pub reset_token: String,
pub expires_at: DateTime<Utc>,
}
#[async_trait] #[async_trait]
impl DatabaseClientTrait for DatabaseClient { impl DatabaseClientTrait for DatabaseClient {
async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>> { async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>> {
@@ -58,19 +25,13 @@ impl DatabaseClientTrait for DatabaseClient {
Ok(Self { client }) Ok(Self { client })
} }
async fn get_user_by_userid( async fn get_user_by_userid(&mut self, user_id: i32) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
&mut self,
user_id: i32,
) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(GetUserRequest { user_id }); let request = tonic::Request::new(GetUserRequest { user_id });
let response = self.client.get_user(request).await?; let response = self.client.get_user(request).await?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
async fn get_user_by_username( async fn get_user_by_username(&mut self, username: &str) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
&mut self,
username: &str,
) -> Result<GetUserResponse, Box<dyn std::error::Error>> {
let request = tonic::Request::new(GetUserByUsernameRequest { let request = tonic::Request::new(GetUserByUsernameRequest {
username: username.to_string(), username: username.to_string(),
}); });
@@ -85,35 +46,4 @@ impl DatabaseClientTrait for DatabaseClient {
let response = self.client.get_user_by_email(request).await?; let response = self.client.get_user_by_email(request).await?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
async fn store_password_reset(
&mut self,
_email: &str,
_reset_token: &str,
_expires_at: DateTime<Utc>,
) -> Result<(), Box<dyn Error>> {
Ok(())
}
async fn get_password_reset(
&self,
_reset_token: &str,
) -> Result<Option<PasswordReset>, Box<dyn std::error::Error>> {
todo!()
}
async fn delete_password_reset(
&self,
_reset_token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn update_user_password(
&self,
_email: &str,
_hashed_password: &str,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
} }

View File

@@ -1,16 +1,12 @@
use crate::auth::auth_service_server::AuthService; use crate::auth::auth_service_server::AuthService;
use crate::auth::{ use crate::auth::{
LoginRequest, LoginResponse, LogoutRequest, PasswordResetRequest, PasswordResetResponse, LoginRequest, LoginResponse, LogoutRequest, RefreshSessionResponse, ValidateSessionRequest,
RefreshSessionResponse, RegisterRequest, RegisterResponse, ResetPasswordRequest, ValidateSessionResponse, ValidateTokenRequest, ValidateTokenResponse,
ResetPasswordResponse, ValidateSessionRequest, ValidateSessionResponse, ValidateTokenRequest, ValidateTokenResponse,
}; };
use crate::common::Empty; 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::session_service_client::SessionServiceClient;
use crate::session::{GetSessionRequest, RefreshSessionRequest}; use crate::session::{GetSessionRequest, RefreshSessionRequest};
use crate::users::{hash_password, verify_user};
use chrono::{Duration, Utc};
use rand::Rng;
use std::sync::Arc; use std::sync::Arc;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -22,22 +18,19 @@ pub struct MyAuthService {
#[tonic::async_trait] #[tonic::async_trait]
impl AuthService for MyAuthService { impl AuthService for MyAuthService {
async fn login( async fn login(&self, _request: Request<LoginRequest>) -> Result<Response<LoginResponse>, Status> {
&self,
request: Request<LoginRequest>,
) -> Result<Response<LoginResponse>, Status> {
Err(Status::unimplemented("login not implemented due to changes")) Err(Status::unimplemented("login not implemented due to changes"))
} }
async fn logout(&self, request: Request<LogoutRequest>) -> Result<Response<Empty>, Status> { async fn logout(&self, request: Request<LogoutRequest>) -> Result<Response<Empty>, Status> {
let req = request.into_inner(); let _req = request.into_inner();
Ok(Response::new(Empty {})) Ok(Response::new(Empty {}))
} }
async fn validate_token( async fn validate_token(
&self, &self,
request: Request<ValidateTokenRequest>, _request: Request<ValidateTokenRequest>,
) -> Result<Response<ValidateTokenResponse>, Status> { ) -> Result<Response<ValidateTokenResponse>, Status> {
Ok(Response::new(ValidateTokenResponse { Ok(Response::new(ValidateTokenResponse {
valid: false, valid: false,
@@ -51,20 +44,32 @@ impl AuthService for MyAuthService {
request: Request<ValidateSessionRequest>, request: Request<ValidateSessionRequest>,
) -> Result<Response<ValidateSessionResponse>, Status> { ) -> Result<Response<ValidateSessionResponse>, Status> {
let req = request.into_inner(); let req = request.into_inner();
let response = self.session_client.as_ref().clone() let response = self
.session_client
.as_ref()
.clone()
.get_session(GetSessionRequest { .get_session(GetSessionRequest {
session_id: req.session_id, session_id: req.session_id,
}).await; })
.await;
match response { match response {
Ok(res) => { Ok(res) => {
let res = res.into_inner(); let res = res.into_inner();
debug!("Session valid: {:?}", res); 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) => { Err(error) => {
debug!("Session invalid or not found: {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<RegisterRequest>,
) -> Result<Response<RegisterResponse>, 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<PasswordResetRequest>,
) -> Result<Response<PasswordResetResponse>, 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<ResetPasswordRequest>,
) -> Result<Response<ResetPasswordResponse>, 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"))
}
}
} }

View File

@@ -8,9 +8,8 @@ use std::env;
use std::sync::Arc; use std::sync::Arc;
use tonic::transport::Server; use tonic::transport::Server;
use tracing::info; use tracing::info;
use tracing_subscriber::{fmt, EnvFilter};
use utils::logging; 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] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -23,7 +22,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set the gRPC server address // Set the gRPC server address
let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); 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 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 db_client = Arc::new(DatabaseClient::connect(&db_url).await?);
let session_client = Arc::new(SessionServiceClient::connect(db_url).await?); let session_client = Arc::new(SessionServiceClient::connect(db_url).await?);
@@ -32,7 +37,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = full_addr.parse().expect("Invalid address"); let address = full_addr.parse().expect("Invalid address");
let auth_service = MyAuthService { let auth_service = MyAuthService {
db_client, db_client,
session_client session_client,
}; };
let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); let (mut health_reporter, health_service) = tonic_health::server::health_reporter();

View File

@@ -1,7 +1,6 @@
use crate::database::GetUserResponse; use crate::database::GetUserResponse;
use crate::database_client::{DatabaseClientTrait, PasswordReset}; use crate::database_client::DatabaseClientTrait;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mockall::{mock, predicate::*}; use mockall::{mock, predicate::*};
use std::error::Error; use std::error::Error;
@@ -15,10 +14,6 @@ mock! {
async fn get_user_by_userid(&mut self, user_id: i32) -> Result<GetUserResponse, Box<dyn std::error::Error>>; async fn get_user_by_userid(&mut self, user_id: i32) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
async fn get_user_by_username(&mut self, user_id: &str) -> Result<GetUserResponse, Box<dyn std::error::Error>>; async fn get_user_by_username(&mut self, user_id: &str) -> Result<GetUserResponse, Box<dyn std::error::Error>>;
async fn get_user_by_email(&mut self, email: &str) -> Result<GetUserResponse, Box<dyn Error>>; async fn get_user_by_email(&mut self, email: &str) -> Result<GetUserResponse, Box<dyn Error>>;
async fn store_password_reset(&mut self, email: &str, reset_token: &str, expires_at: DateTime<Utc>) -> Result<(), Box<dyn Error>>;
async fn get_password_reset(&self, reset_token: &str) -> Result<Option<PasswordReset>, Box<dyn Error>>;
async fn delete_password_reset(&self, reset_token: &str) -> Result<(), Box<dyn Error>>;
async fn update_user_password(&self, email: &str, hashed_password: &str) -> Result<(), Box<dyn Error>>;
} }
} }

View File

@@ -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 async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::error::Error; use std::error::Error;
use tonic::transport::Channel; use tonic::transport::Channel;
#[async_trait] #[async_trait]
pub trait SessionClientTrait: Sized { pub trait SessionClientTrait: Sized {
async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>>; async fn connect(endpoint: &str) -> Result<Self, Box<dyn std::error::Error>>;
async fn get_session( async fn get_session(&mut self, session_id: String) -> Result<GetSessionResponse, Box<dyn std::error::Error>>;
&mut self,
session_id: String,
) -> Result<GetSessionResponse, Box<dyn std::error::Error>>;
async fn refresh_session( async fn refresh_session(
&mut self, &mut self,
session_id: String, session_id: String,
@@ -29,16 +28,12 @@ impl SessionClientTrait for SessionClient {
} }
async fn get_session(&mut self, session_id: String) -> Result<GetSessionResponse, Box<dyn Error>> { async fn get_session(&mut self, session_id: String) -> Result<GetSessionResponse, Box<dyn Error>> {
let request = tonic::Request::new(GetSessionRequest { let request = tonic::Request::new(GetSessionRequest { session_id });
session_id,
});
let response = self.client.get_session(request).await?; let response = self.client.get_session(request).await?;
Ok(response.into_inner()) Ok(response.into_inner())
} }
async fn refresh_session(&mut self, session_id: String) -> Result<RefreshSessionResponse, Box<dyn Error>> { async fn refresh_session(&mut self, session_id: String) -> Result<RefreshSessionResponse, Box<dyn Error>> {
let request = tonic::Request::new(RefreshSessionRequest { let request = tonic::Request::new(RefreshSessionRequest { session_id });
session_id,
});
let response = self.client.refresh_session(request).await?; let response = self.client.refresh_session(request).await?;
Ok(response.into_inner()) Ok(response.into_inner())
} }

View File

@@ -9,10 +9,7 @@ use argon2::{
pub fn hash_password(password: &str) -> String { pub fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::default();
argon2 argon2.hash_password(password.as_ref(), &salt).unwrap().to_string()
.hash_password(password.as_ref(), &salt)
.unwrap()
.to_string()
} }
pub fn verify_password(password: &str, hash: &str) -> bool { pub fn verify_password(password: &str, hash: &str) -> bool {

View File

@@ -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");
}
}

View File

@@ -12,7 +12,6 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "chrono"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter", "chrono"] }
tonic = "0.12.3" tonic = "0.12.3"
prost = "0.13.4" prost = "0.13.4"
warp = "0.3.7"
async-trait = "0.1.83" async-trait = "0.1.83"
serde_json = "1.0.133" serde_json = "1.0.133"
tonic-health = "0.12.3" tonic-health = "0.12.3"

View File

@@ -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

View File

@@ -5,10 +5,7 @@ fn main() {
.compile_well_known_types(true) .compile_well_known_types(true)
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
.compile_protos( .compile_protos(
&[ &["../proto/character_common.proto", "../proto/character.proto"],
"../proto/character_common.proto",
"../proto/character.proto",
],
&["../proto"], &["../proto"],
) )
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));

View File

@@ -1,9 +1,8 @@
use crate::database::character_db_service_client::CharacterDbServiceClient;
use crate::database::{ use crate::database::{
Character, CharacterListRequest, CharacterListResponse, CharacterRequest, character_db_service_client::CharacterDbServiceClient, Character, CharacterListRequest, CharacterListResponse,
CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, CharacterRequest, CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse,
DeleteCharacterResponse,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tonic::transport::Channel; use tonic::transport::Channel;

View File

@@ -1,9 +1,8 @@
use crate::character_db_client::CharacterDbClient; use crate::character_db_client::CharacterDbClient;
use crate::character_service::character::character_service_server::CharacterService; use crate::character_service::character::character_service_server::CharacterService;
use crate::character_service::character::{ use crate::character_service::character::{
CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse,
DeleteCharacterResponse, GetCharacterListRequest, GetCharacterListResponse, GetCharacterListRequest, GetCharacterListResponse, GetCharacterRequest, GetCharacterResponse,
GetCharacterRequest, GetCharacterResponse,
}; };
use crate::character_service::character_common::{Character, CharacterFull}; use crate::character_service::character_common::{Character, CharacterFull};
use std::sync::Arc; use std::sync::Arc;
@@ -69,14 +68,7 @@ impl CharacterService for MyCharacterService {
.character_db_client .character_db_client
.as_ref() .as_ref()
.clone() .clone()
.create_character( .create_character(&req.user_id, &req.name, req.race, req.face, req.hair, req.stone)
&req.user_id,
&req.name,
req.race,
req.face,
req.hair,
req.stone,
)
.await .await
.map_err(|_| Status::aborted("Unable to create character"))?; .map_err(|_| Status::aborted("Unable to create character"))?;

View File

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

View File

@@ -0,0 +1,3 @@
pub mod character_db_client;
pub mod character_service;
pub mod database;

View File

@@ -14,7 +14,7 @@ use std::sync::Arc;
use tracing::Level; use tracing::Level;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use utils::logging; 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] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -25,17 +25,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set the gRPC server address // Set the gRPC server address
let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); 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 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 full_addr = format!("{}:{}", &addr, port);
let address = full_addr.parse().expect("Invalid address"); let address = full_addr.parse().expect("Invalid address");
let character_db_client = Arc::new(CharacterDbClient::connect(&db_url).await?); let character_db_client = Arc::new(CharacterDbClient::connect(&db_url).await?);
let character_service = MyCharacterService { let character_service = MyCharacterService { character_db_client };
character_db_client,
};
let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
health_reporter.set_serving::<CharacterServiceServer<MyCharacterService>>().await; health_reporter
.set_serving::<CharacterServiceServer<MyCharacterService>>()
.await;
tonic::transport::Server::builder() tonic::transport::Server::builder()
.add_service(health_service) .add_service(health_service)

229
database-service/API.md Normal file
View File

@@ -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

111
database-service/README.md Normal file
View File

@@ -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.

129
database-service/SCHEMA.md Normal file
View File

@@ -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

View File

@@ -102,19 +102,12 @@ impl CharacterRepository {
Ok(result.get("id")) Ok(result.get("id"))
} }
pub async fn delete_character( pub async fn delete_character(&self, character_id: i32, delete_type: i32) -> Result<i64, sqlx::Error> {
&self,
character_id: i32,
delete_type: i32,
) -> Result<i64, sqlx::Error> {
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"; 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 { if 0 == delete_type {
query = "UPDATE character SET \"updatedAt\" = NOW(), \"deletedAt\" = null WHERE id = $1 RETURNING \"userId\", 0::BIGINT as deleted_at"; query = "UPDATE character SET \"updatedAt\" = NOW(), \"deletedAt\" = null WHERE id = $1 RETURNING \"userId\", 0::BIGINT as deleted_at";
} }
let result = sqlx::query(query) let result = sqlx::query(query).bind(character_id).fetch_one(&self.pool).await?;
.bind(character_id)
.fetch_one(&self.pool)
.await?;
// Invalidate cache // Invalidate cache
let cache_key = format!("character:user:{}", result.get::<i32, &str>("user_id")); let cache_key = format!("character:user:{}", result.get::<i32, &str>("user_id"));
@@ -134,10 +127,7 @@ impl CharacterRepository {
Ok(result.get::<i64, &str>("deleted_at")) Ok(result.get::<i64, &str>("deleted_at"))
} }
pub async fn get_characters_by_user( pub async fn get_characters_by_user(&self, user_id: String) -> Result<Vec<Character>, sqlx::Error> {
&self,
user_id: String,
) -> Result<Vec<Character>, sqlx::Error> {
let cache_key = format!("character:user:{}", user_id); let cache_key = format!("character:user:{}", user_id);
// Try fetching from Redis cache // Try fetching from Redis cache

View File

@@ -1,10 +1,10 @@
use crate::characters::CharacterRepository; use crate::characters::CharacterRepository;
use crate::sessions::SessionRepository;
use crate::users::UserRepository; use crate::users::UserRepository;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use utils::redis_cache::RedisCache; use utils::redis_cache::RedisCache;
use crate::sessions::SessionRepository;
pub struct Database { pub struct Database {
pub user_repo: Arc<UserRepository>, pub user_repo: Arc<UserRepository>,
@@ -21,7 +21,7 @@ impl Database {
Self { Self {
user_repo, user_repo,
character_repo, character_repo,
session_repo session_repo,
} }
} }
} }

View File

@@ -1,18 +1,14 @@
use crate::grpc::character_db_service_server::CharacterDbService; use crate::grpc::character_db_service_server::CharacterDbService;
use crate::grpc::database_service::MyDatabaseService; use crate::grpc::database_service::MyDatabaseService;
use crate::grpc::{ use crate::grpc::{
Character, CharacterListRequest, CharacterListResponse, CharacterRequest, Character, CharacterListRequest, CharacterListResponse, CharacterRequest, CreateCharacterRequest,
CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse,
DeleteCharacterResponse,
}; };
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
#[tonic::async_trait] #[tonic::async_trait]
impl CharacterDbService for MyDatabaseService { impl CharacterDbService for MyDatabaseService {
async fn get_character( async fn get_character(&self, request: Request<CharacterRequest>) -> Result<Response<Character>, Status> {
&self,
request: Request<CharacterRequest>,
) -> Result<Response<Character>, Status> {
let req = request.into_inner(); let req = request.into_inner();
let repo = &self.db.character_repo; let repo = &self.db.character_repo;

View File

@@ -1,7 +1,7 @@
pub mod database_service;
mod character_service; mod character_service;
mod user_service; pub mod database_service;
mod session_service; mod session_service;
mod user_service;
tonic::include_proto!("user_db_api"); tonic::include_proto!("user_db_api");
tonic::include_proto!("character_db_api"); tonic::include_proto!("character_db_api");

View File

@@ -1,19 +1,20 @@
use crate::grpc::database_service::MyDatabaseService; use crate::grpc::database_service::MyDatabaseService;
use crate::grpc::session_service_server::SessionService; use crate::grpc::session_service_server::SessionService;
use crate::grpc::{GetSessionRequest, GetSessionResponse, RefreshSessionRequest, RefreshSessionResponse};
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tracing::debug; use tracing::debug;
use crate::grpc::{GetSessionRequest, GetSessionResponse, RefreshSessionRequest, RefreshSessionResponse};
#[tonic::async_trait] #[tonic::async_trait]
impl SessionService for MyDatabaseService { impl SessionService for MyDatabaseService {
async fn get_session( async fn get_session(&self, request: Request<GetSessionRequest>) -> Result<Response<GetSessionResponse>, Status> {
&self,
request: Request<GetSessionRequest>,
) -> Result<Response<GetSessionResponse>, Status> {
let req = request.into_inner(); let req = request.into_inner();
debug!("get_session: {:?}", req); 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"))?; .map_err(|_| Status::not_found("Session not found"))?;
debug!("session: {:?}", session); debug!("session: {:?}", session);
@@ -23,18 +24,23 @@ impl SessionService for MyDatabaseService {
})) }))
} }
async fn refresh_session(&self, request: Request<RefreshSessionRequest>) -> Result<Response<RefreshSessionResponse>, Status> { async fn refresh_session(
&self,
request: Request<RefreshSessionRequest>,
) -> Result<Response<RefreshSessionResponse>, Status> {
let req = request.into_inner(); let req = request.into_inner();
debug!("get_session: {:?}", req); 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"))?; .map_err(|_| Status::not_found("Session not found"))?;
let valid = true; let valid = true;
debug!("session: {:?}", session); debug!("session: {:?}", session);
Ok(Response::new(RefreshSessionResponse { Ok(Response::new(RefreshSessionResponse { valid }))
valid
}))
} }
} }

View File

@@ -1,17 +1,11 @@
use crate::grpc::database_service::MyDatabaseService; use crate::grpc::database_service::MyDatabaseService;
use crate::grpc::user_service_server::UserService; use crate::grpc::user_service_server::UserService;
use crate::grpc::{ use crate::grpc::{GetUserByEmailRequest, GetUserByUsernameRequest, GetUserRequest, GetUserResponse};
GetUserByEmailRequest, GetUserByUsernameRequest,
GetUserRequest, GetUserResponse,
};
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
#[tonic::async_trait] #[tonic::async_trait]
impl UserService for MyDatabaseService { impl UserService for MyDatabaseService {
async fn get_user( async fn get_user(&self, request: Request<GetUserRequest>) -> Result<Response<GetUserResponse>, Status> {
&self,
request: Request<GetUserRequest>,
) -> Result<Response<GetUserResponse>, Status> {
let req = request.into_inner(); let req = request.into_inner();
let user = self let user = self

View File

@@ -1,5 +1,5 @@
pub mod characters; pub mod characters;
pub mod db; pub mod db;
pub mod grpc; pub mod grpc;
pub mod users;
pub mod sessions; pub mod sessions;
pub mod users;

View File

@@ -1,6 +1,7 @@
use database_service::db::Database; use database_service::db::Database;
use database_service::grpc::character_db_service_server::CharacterDbServiceServer; use database_service::grpc::character_db_service_server::CharacterDbServiceServer;
use database_service::grpc::database_service::MyDatabaseService; use database_service::grpc::database_service::MyDatabaseService;
use database_service::grpc::session_service_server::SessionServiceServer;
use database_service::grpc::user_service_server::UserServiceServer; use database_service::grpc::user_service_server::UserServiceServer;
use dotenv::dotenv; use dotenv::dotenv;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
@@ -12,7 +13,6 @@ use tokio::sync::Mutex;
use tonic::transport::Server; use tonic::transport::Server;
use tracing::{info, Level}; use tracing::{info, Level};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use database_service::grpc::session_service_server::SessionServiceServer;
use utils::logging; use utils::logging;
use utils::redis_cache::RedisCache; use utils::redis_cache::RedisCache;
@@ -39,7 +39,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let my_service = MyDatabaseService { db }; let my_service = MyDatabaseService { db };
let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); let (mut health_reporter, health_service) = tonic_health::server::health_reporter();
health_reporter.set_serving::<UserServiceServer<MyDatabaseService>>().await; health_reporter
.set_serving::<UserServiceServer<MyDatabaseService>>()
.await;
let address = SocketAddr::new(addr.parse()?, port.parse()?); let address = SocketAddr::new(addr.parse()?, port.parse()?);
tokio::spawn( tokio::spawn(

View File

@@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Row}; use sqlx::{FromRow, Row};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use utils::redis_cache::{Cache, RedisCache};
use tracing::debug; use tracing::debug;
use utils::redis_cache::{Cache, RedisCache};
#[derive(Debug, FromRow, Serialize, Deserialize)] #[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct Session { pub struct Session {
@@ -24,25 +24,30 @@ impl SessionRepository {
pub async fn get_session(&self, session_id: &str) -> Result<Session, sqlx::Error> { pub async fn get_session(&self, session_id: &str) -> Result<Session, sqlx::Error> {
let cache_key = format!("session:{}", session_id); let cache_key = format!("session:{}", session_id);
if let Some(session) = self.cache.lock().await if let Some(session) = self
.get::<Session>(&cache_key).await .cache
.lock()
.await
.get::<Session>(&cache_key)
.await
.map_err(|_| sqlx::Error::RowNotFound)? .map_err(|_| sqlx::Error::RowNotFound)?
{ {
return Ok(session); return Ok(session);
} }
// Fetch from database // Fetch from database
let session = sqlx::query_as::<_, Session>( let session = sqlx::query_as::<_, Session>("SELECT id, \"userId\" as user_id FROM session WHERE id = $1")
"SELECT id, \"userId\" as user_id FROM session WHERE id = $1",
)
.bind(session_id) .bind(session_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
debug!("session: {:?}", session); debug!("session: {:?}", session);
self.cache.lock().await self.cache
.set(&cache_key, &session, 300).await .lock()
.await
.set(&cache_key, &session, 300)
.await
.map_err(|_| sqlx::Error::RowNotFound)?; .map_err(|_| sqlx::Error::RowNotFound)?;
Ok(session) Ok(session)
} }
@@ -50,26 +55,34 @@ impl SessionRepository {
pub async fn refresh_session(&self, session_id: &str) -> Result<Session, sqlx::Error> { pub async fn refresh_session(&self, session_id: &str) -> Result<Session, sqlx::Error> {
let cache_key = format!("session:{}", session_id); let cache_key = format!("session:{}", session_id);
if let Some(session) = self.cache.lock().await if let Some(session) = self
.get::<Session>(&cache_key).await .cache
.lock()
.await
.get::<Session>(&cache_key)
.await
.map_err(|_| sqlx::Error::RowNotFound)? .map_err(|_| sqlx::Error::RowNotFound)?
{ {
self.cache.lock().await self.cache
.refresh(&cache_key, 300).await .lock()
.await
.refresh(&cache_key, 300)
.await
.map_err(|_| sqlx::Error::RowNotFound)?; .map_err(|_| sqlx::Error::RowNotFound)?;
return Ok(session); return Ok(session);
} }
// Check to make sure the session is still valid // Check to make sure the session is still valid
let session = sqlx::query_as::<_, Session>( let session = sqlx::query_as::<_, Session>("SELECT id, \"userId\" as user_id FROM session WHERE id = $1")
"SELECT id, \"userId\" as user_id FROM session WHERE id = $1",
)
.bind(session_id) .bind(session_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
self.cache.lock().await self.cache
.set(&cache_key, &session, 300).await .lock()
.await
.set(&cache_key, &session, 300)
.await
.map_err(|_| sqlx::Error::RowNotFound)?; .map_err(|_| sqlx::Error::RowNotFound)?;
Ok(session) Ok(session)
} }

View File

@@ -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<Postgres> {
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<Mutex<RedisCache>> {
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<Postgres>) -> 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<Postgres>, 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<Postgres>, 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");
}
}

View File

@@ -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<Mutex<RedisCache>> {
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;
}
}

View File

@@ -0,0 +1,4 @@
pub mod user_repository_tests;
pub mod character_repository_tests;
pub mod session_repository_tests;
pub mod grpc_tests;

View File

@@ -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<Postgres> {
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<Mutex<RedisCache>> {
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<Postgres>) -> 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<Postgres>, 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<Postgres>, 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");
}
}

View File

@@ -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<Postgres> {
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<Mutex<RedisCache>> {
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<Postgres>, 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<Postgres>, 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");
}
}

View File

@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow}; use sqlx::FromRow;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use utils::redis_cache::{Cache, RedisCache}; use utils::redis_cache::{Cache, RedisCache};
@@ -38,9 +38,8 @@ impl UserRepository {
return Ok(user); return Ok(user);
} }
let user = sqlx::query_as::<_, User>( let user =
"SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE id = $1", sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE id = $1")
)
.bind(user_id) .bind(user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
@@ -68,9 +67,8 @@ impl UserRepository {
return Ok(user); return Ok(user);
} }
let user = sqlx::query_as::<_, User>( let user =
"SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE name = $1", sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE name = $1")
)
.bind(username) .bind(username)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
@@ -98,9 +96,8 @@ impl UserRepository {
return Ok(user); return Ok(user);
} }
let user = sqlx::query_as::<_, User>( let user =
"SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1", sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1")
)
.bind(email) .bind(email)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
@@ -128,9 +125,8 @@ impl UserRepository {
return Ok(user); return Ok(user);
} }
let user = sqlx::query_as::<_, User>( let user =
"SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1", sqlx::query_as::<_, User>("SELECT id, name, email, role, createdAt, updatedAt FROM user WHERE email = $1")
)
.bind(session) .bind(session)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;

View File

@@ -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");
}

View File

@@ -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");
}

78
launcher/README.md Normal file
View File

@@ -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
<a href="launcher://launch?ip=127.0.0.1&port=29000&session=SESSION_TOKEN&username=USERNAME">
Launch Game
</a>
```
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

View File

@@ -1,14 +1,15 @@
use crate::format_shell_command; use crate::format_shell_command;
use crate::wait_for_keypress;
use std::borrow::Cow; use std::borrow::Cow;
use std::env;
use std::process::exit;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use url::Url; use url::Url;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn create_command() -> Command { 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(); let exe_dir_path = env::current_exe().unwrap().parent().unwrap().to_path_buf();
// Change the working directory // Change the working directory
@@ -76,10 +77,7 @@ pub(crate) fn launch_game(url: String) {
} }
} }
command command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
info!("Executing: {:?}", format_shell_command(&command)); info!("Executing: {:?}", format_shell_command(&command));

View File

@@ -3,6 +3,15 @@ name = "packet-service"
version = "0.1.0" version = "0.1.0"
edition = "2021" 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] [features]
mocks = [] mocks = []
consul = [] consul = []

107
packet-service/README.md Normal file
View File

@@ -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

View File

@@ -69,10 +69,7 @@ impl AuthClient {
Ok(response.into_inner()) Ok(response.into_inner())
} }
pub async fn logout( pub async fn logout(&mut self, session_id: &str) -> Result<Empty, Box<dyn std::error::Error + Send + Sync>> {
&mut self,
session_id: &str,
) -> Result<Empty, Box<dyn std::error::Error + Send + Sync>> {
let request = LogoutRequest { let request = LogoutRequest {
session_id: session_id.to_string(), session_id: session_id.to_string(),
}; };

View File

@@ -1,8 +1,7 @@
use crate::character::character_service_client::CharacterServiceClient; use crate::character::character_service_client::CharacterServiceClient;
use crate::character::{ use crate::character::{
CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, CreateCharacterRequest, CreateCharacterResponse, DeleteCharacterRequest, DeleteCharacterResponse,
DeleteCharacterResponse, GetCharacterListRequest, GetCharacterListResponse, GetCharacterListRequest, GetCharacterListResponse, GetCharacterRequest, GetCharacterResponse,
GetCharacterRequest, GetCharacterResponse,
}; };
use tonic::transport::Channel; use tonic::transport::Channel;
use utils::null_string::NullTerminatedString; use utils::null_string::NullTerminatedString;

View File

@@ -17,15 +17,12 @@ impl ConnectionService {
pub fn add_connection(&self) -> String { pub fn add_connection(&self) -> String {
let connection_id = Uuid::new_v4().to_string(); let connection_id = Uuid::new_v4().to_string();
self.connections self.connections.insert(connection_id.clone(), ConnectionState::new());
.insert(connection_id.clone(), ConnectionState::new());
connection_id connection_id
} }
pub fn get_connection(&self, connection_id: &str) -> Option<ConnectionState> { pub fn get_connection(&self, connection_id: &str) -> Option<ConnectionState> {
self.connections self.connections.get(connection_id).map(|entry| entry.clone())
.get(connection_id)
.map(|entry| entry.clone())
} }
pub fn get_connection_mut( pub fn get_connection_mut(

View File

@@ -133,10 +133,7 @@ pub(crate) async fn handle_login_req(
connection_id: String, connection_id: String,
addr: SocketAddr, addr: SocketAddr,
) -> Result<(), Box<dyn Error + Send + Sync>> { ) -> Result<(), Box<dyn Error + Send + Sync>> {
debug!( debug!("decoding packet payload of size {}", packet.payload.as_slice().len());
"decoding packet payload of size {}",
packet.payload.as_slice().len()
);
let data = CliLoginTokenReq::decode(packet.payload.as_slice())?; let data = CliLoginTokenReq::decode(packet.payload.as_slice())?;
debug!("{:?}", data); debug!("{:?}", data);
@@ -175,9 +172,7 @@ pub(crate) async fn handle_login_req(
"name" => { "name" => {
server_name = value; server_name = value;
} }
"tags" => { "tags" => {}
}
_ => {} _ => {}
} }
} }

View File

@@ -7,16 +7,15 @@ use crate::enums::ItemType;
use crate::packet::{send_packet, Packet, PacketPayload}; use crate::packet::{send_packet, Packet, PacketPayload};
use crate::packet_type::PacketType; use crate::packet_type::PacketType;
use crate::packets::*; use crate::packets::*;
use std::collections::hash_map::DefaultHasher;
use std::error::Error; use std::error::Error;
use std::hash::{Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tonic::{Code, Status}; use tonic::{Code, Status};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use utils::null_string::NullTerminatedString; use utils::null_string::NullTerminatedString;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;
fn string_to_u32(s: &str) -> u32 { fn string_to_u32(s: &str) -> u32 {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
@@ -84,16 +83,12 @@ pub(crate) async fn handle_char_list_req(
let session_id; let session_id;
if let Some(mut state) = connection_service.get_connection(&connection_id) { if let Some(mut state) = connection_service.get_connection(&connection_id) {
user_id = state.user_id.expect("Missing user id in connection state"); user_id = state.user_id.expect("Missing user id in connection state");
session_id = state session_id = state.session_id.expect("Missing session id in connection state");
.session_id
.expect("Missing session id in connection state");
} }
// query the character service for the character list for this user // query the character service for the character list for this user
let mut character_client = character_client.lock().await; let mut character_client = character_client.lock().await;
let character_list = character_client let character_list = character_client.get_character_list(&user_id).await?;
.get_character_list(&user_id)
.await?;
let mut characters = vec![]; let mut characters = vec![];
let mut character_id_list: Vec<u32> = Vec::new(); let mut character_id_list: Vec<u32> = Vec::new();
for character in character_list.characters { for character in character_list.characters {
@@ -154,9 +149,7 @@ pub(crate) async fn handle_create_char_req(
let session_id; let session_id;
if let Some(mut state) = connection_service.get_connection(&connection_id) { if let Some(mut state) = connection_service.get_connection(&connection_id) {
user_id = state.user_id.expect("Missing user id in connection state"); user_id = state.user_id.expect("Missing user id in connection state");
session_id = state session_id = state.session_id.expect("Missing session id in connection state");
.session_id
.expect("Missing session id in connection state");
} }
// send the data to the character service to create the character // 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, _ => srv_create_char_reply::Result::Failed,
}; };
let data = SrvCreateCharReply { let data = SrvCreateCharReply { result, platininum: 0 };
result,
platininum: 0,
};
let response_packet = Packet::new(PacketType::PakccCreateCharReply, &data)?; let response_packet = Packet::new(PacketType::PakccCreateCharReply, &data)?;
send_packet(stream, &response_packet).await?; 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) { if let Some(mut state) = connection_service.get_connection(&connection_id) {
user_id = state.user_id.expect("Missing user id in connection state"); user_id = state.user_id.expect("Missing user id in connection state");
session_id = state session_id = state.session_id.expect("Missing session id in connection state");
.session_id
.expect("Missing session id in connection state");
character_id_list = state.character_list.expect("Missing character id list"); 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<u32> = Vec::new(); let mut character_id_list: Vec<u32> = Vec::new();
if let Some(mut state) = connection_service.get_connection_mut(&connection_id) { 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"); user_id = state.user_id.clone().expect("Missing user id in connection state");
character_id_list = state character_id_list = state.character_list.clone().expect("Missing character id list");
.character_list
.clone()
.expect("Missing character id list");
state.character_id = Some(request.char_id as i8); 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 mut character_client = character_client.lock().await;
let character_data = character_client let character_data = character_client
.get_character( .get_character(&user_id.to_string(), character_id_list[request.char_id as usize] as u8)
&user_id.to_string(),
character_id_list[request.char_id as usize] as u8,
)
.await?; .await?;
let character = character_data.character.unwrap_or_default(); 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)] = let mut effect_list: [StatusEffect; (MAX_STATUS_EFFECTS as usize)] =
core::array::from_fn(|i| StatusEffect::default()); core::array::from_fn(|i| StatusEffect::default());
let mut hotbar_list: [HotbarItem; (MAX_HOTBAR_ITEMS as usize)] = let mut hotbar_list: [HotbarItem; (MAX_HOTBAR_ITEMS as usize)] = core::array::from_fn(|i| HotbarItem::default());
core::array::from_fn(|i| HotbarItem::default());
let data = SrvSelectCharReply { let data = SrvSelectCharReply {
race: looks.race as u8, race: looks.race as u8,
map: position.map_id as u16, map: position.map_id as u16,

View File

@@ -1,4 +1,4 @@
use crate::character_client::CharacterClient; use crate::character_client::CharacterClient;
use crate::connection_service::ConnectionService; use crate::connection_service::ConnectionService;
use crate::packet::{send_packet, Packet, PacketPayload}; use crate::packet::{send_packet, Packet, PacketPayload};
use crate::packet_type::PacketType; use crate::packet_type::PacketType;
@@ -32,24 +32,14 @@ pub(crate) async fn handle_change_map_req(
let session_id; let session_id;
if let Some(mut state) = connection_service.get_connection(&connection_id) { if let Some(mut state) = connection_service.get_connection(&connection_id) {
user_id = state.user_id.expect("Missing user id in connection state"); user_id = state.user_id.expect("Missing user id in connection state");
session_id = state session_id = state.session_id.expect("Missing session id in connection state");
.session_id char_id = state.character_id.expect("Missing character id in connection state");
.expect("Missing session 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 mut character_client = character_client.lock().await; let mut character_client = character_client.lock().await;
let character_data = character_client let character_data = character_client
.get_character( .get_character(&user_id.to_string(), character_id_list[char_id as usize] as u8)
&user_id.to_string(),
character_id_list[char_id as usize] as u8,
)
.await?; .await?;
let character = character_data.character.unwrap_or_default(); 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 char_id = 0;
let mut character_id_list: Vec<u32> = Vec::new(); let mut character_id_list: Vec<u32> = Vec::new();
if let Some(mut state) = connection_service.get_connection(&connection_id) { if let Some(mut state) = connection_service.get_connection(&connection_id) {
char_id = state char_id = state.character_id.expect("Missing character id in connection state");
.character_id character_id_list = state.character_list.clone().expect("Missing character id list");
.expect("Missing character id in connection state");
character_id_list = state
.character_list
.clone()
.expect("Missing character id list");
} }
let data = SrvMouseCmd { let data = SrvMouseCmd {
id: character_id_list[char_id as usize] as u16, id: character_id_list[char_id as usize] as u16,
target_id: request.target_id, target_id: request.target_id,
distance: distance( distance: distance(520000 as f64, 520000 as f64, request.x as f64, request.y as f64),
520000 as f64,
520000 as f64,
request.x as f64,
request.y as f64,
),
x: request.x, x: request.x,
y: request.y, y: request.y,
z: request.z, z: request.z,

View File

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

View File

@@ -21,8 +21,8 @@ use tokio::{select, signal};
use tracing::Level; use tracing::Level;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use utils::service_discovery::get_kube_service_endpoints_by_dns;
use utils::{health_check, logging}; use utils::{health_check, logging};
use utils::service_discovery::{get_kube_service_endpoints_by_dns};
use warp::Filter; use warp::Filter;
mod auth_client; mod auth_client;
@@ -66,8 +66,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); 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 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 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 auth_url = format!(
let character_url = format!("http://{}",get_kube_service_endpoints_by_dns("character-service","tcp","character-service").await?.get(0).unwrap()); "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 // Start health-check endpoint
health_check::start_health_check(addr.as_str()).await?; health_check::start_health_check(addr.as_str()).await?;
@@ -109,9 +121,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
{ {
error!("Error handling connection: {}", e); error!("Error handling connection: {}", e);
} }
packet_router packet_router.connection_service.remove_connection(&connection_id);
.connection_service
.remove_connection(&connection_id);
}); });
} }
}); });

View File

@@ -1,7 +1,6 @@
use lazy_static::lazy_static; use lazy_static::lazy_static;
use prometheus::{ use prometheus::{
register_counter, register_gauge, register_histogram, Counter, Encoder, Gauge, Histogram, register_counter, register_gauge, register_histogram, Counter, Encoder, Gauge, Histogram, TextEncoder,
TextEncoder,
}; };
lazy_static! { lazy_static! {

View File

@@ -52,8 +52,7 @@ impl PacketRouter {
Ok(packet) => { Ok(packet) => {
debug!("Parsed Packet: {:?}", packet); debug!("Parsed Packet: {:?}", packet);
// Handle the parsed packet (route it, process it, etc.) // Handle the parsed packet (route it, process it, etc.)
self.route_packet(stream, packet, connection_id.clone()) self.route_packet(stream, packet, connection_id.clone()).await?;
.await?;
} }
Err(e) => warn!("Failed to parse packet: {}", e), Err(e) => warn!("Failed to parse packet: {}", e),
} }

View File

@@ -10,9 +10,6 @@ service AuthService {
rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse); rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse); rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
rpc RefreshSession(ValidateSessionRequest) returns (RefreshSessionResponse); rpc RefreshSession(ValidateSessionRequest) returns (RefreshSessionResponse);
rpc Register (RegisterRequest) returns (RegisterResponse);
rpc RequestPasswordReset (PasswordResetRequest) returns (PasswordResetResponse);
rpc ResetPassword (ResetPasswordRequest) returns (ResetPasswordResponse);
} }
message LoginRequest { message LoginRequest {
@@ -55,30 +52,4 @@ message RefreshSessionResponse {
bool valid = 1; 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;
}

94
tests/Cargo.toml Normal file
View File

@@ -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"

72
tests/README.md Normal file
View File

@@ -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

View File

@@ -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));
}

View File

@@ -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<character_service::database::CharacterListResponse, Box<dyn std::error::Error>> {
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<character_service::database::CreateCharacterResponse, Box<dyn std::error::Error>> {
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<character_service::database::DeleteCharacterResponse, Box<dyn std::error::Error>> {
self.0.delete_character(user_id, char_id, delete_type).await
}
async fn get_character(&mut self, user_id: &str, char_id: &str) -> Result<character_service::database::Character, Box<dyn std::error::Error>> {
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<character_service::database::CharacterListResponse, Box<dyn std::error::Error>>;
async fn create_character(&mut self, user_id: &str, name: &str, race: i32, face: i32, hair: i32, stone: i32) -> Result<character_service::database::CreateCharacterResponse, Box<dyn std::error::Error>>;
async fn delete_character(&mut self, user_id: &str, char_id: &str, delete_type: i32) -> Result<character_service::database::DeleteCharacterResponse, Box<dyn std::error::Error>>;
async fn get_character(&mut self, user_id: &str, char_id: &str) -> Result<character_service::database::Character, Box<dyn std::error::Error>>;
}
#[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"));
}

View File

@@ -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<sqlx::PgPool, sqlx::Error> {
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<Mutex<RedisCache>> {
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;
}

View File

@@ -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<Mutex<RedisCache>> {
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;
}

View File

@@ -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<User, sqlx::Error>;
fn get_user_by_username(&self, username: &str) -> Result<User, sqlx::Error>;
fn get_user_by_email(&self, email: &str) -> Result<User, sqlx::Error>;
}
}
// Mock the CharacterRepository
mock! {
pub CharacterRepository {
fn get_character_by_id(&self, character_id: i32) -> Result<database_service::characters::Character, sqlx::Error>;
fn get_character_list(&self, user_id: String) -> Result<Vec<database_service::characters::Character>, 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<i32, sqlx::Error>;
fn delete_character(&self, character_id: i32, delete_type: i32) -> Result<i64, sqlx::Error>;
}
}
// Mock the SessionRepository
mock! {
pub SessionRepository {
fn get_session(&self, session_id: &str) -> Result<database_service::sessions::Session, sqlx::Error>;
fn refresh_session(&self, session_id: &str) -> Result<database_service::sessions::Session, sqlx::Error>;
}
}
// Mock the Database struct
struct MockDatabase<U, C, S> {
user_repo: Arc<U>,
character_repo: Arc<C>,
session_repo: Arc<S>,
}
type MockDatabaseWithMocks = MockDatabase<MockUserRepository, MockCharacterRepository, MockSessionRepository>;
#[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");
}

View File

@@ -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<sqlx::PgPool, sqlx::Error> {
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<Mutex<RedisCache>> {
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;
}

View File

@@ -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;
}

View File

@@ -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<String> = (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<String> = 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()));
}

View File

@@ -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);
}

18
tests/src/lib.rs Normal file
View File

@@ -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");

View File

@@ -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");
}

View File

@@ -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");
}

View File

@@ -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<Mutex<HashMap<ServiceId, Vec<SocketAddr>>>>,
}
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<SocketAddr>) {
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<SocketAddr> {
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::<usize>() % 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);
}

View File

@@ -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<Option<TestData>, _> = 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<Option<TestData>, _> = 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<Option<TestData>, _> = 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<Option<TestData>, _> = 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);
}

View File

@@ -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");
}
}

81
utils/README.md Normal file
View File

@@ -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;
```

View File

@@ -88,19 +88,11 @@ pub async fn register_service(
Ok(()) Ok(())
} }
pub async fn deregister_service( pub async fn deregister_service(consul_url: &str, service_id: &str) -> Result<(), Box<dyn std::error::Error>> {
consul_url: &str,
service_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new(); let client = Client::new();
let consul_deregister_url = let consul_deregister_url = format!("{}/v1/agent/service/deregister/{}", consul_url, service_id);
format!("{}/v1/agent/service/deregister/{}", consul_url, service_id);
client client.put(&consul_deregister_url).send().await?.error_for_status()?; // Ensure response is successful
.put(&consul_deregister_url)
.send()
.await?
.error_for_status()?; // Ensure response is successful
Ok(()) Ok(())
} }

View File

@@ -12,14 +12,7 @@ pub async fn start_health_check(service_address: &str) -> Result<(), Box<dyn std
.map(|| warp::reply::with_status("OK", warp::http::StatusCode::OK)) .map(|| warp::reply::with_status("OK", warp::http::StatusCode::OK))
.with(log); .with(log);
tokio::spawn( tokio::spawn(warp::serve(health_route).run(health_check_endpoint_addr.to_socket_addrs()?.next().unwrap()));
warp::serve(health_route).run(
health_check_endpoint_addr
.to_socket_addrs()?
.next()
.unwrap(),
),
);
Ok(()) Ok(())
} }

View File

@@ -1,8 +1,8 @@
pub mod consul_registration; pub mod consul_registration;
pub mod health_check;
pub mod logging;
pub mod multi_service_load_balancer;
pub mod null_string; pub mod null_string;
pub mod redis_cache; pub mod redis_cache;
pub mod service_discovery; pub mod service_discovery;
pub mod signal_handler; pub mod signal_handler;
pub mod multi_service_load_balancer;
pub mod logging;
pub mod health_check;

View File

@@ -6,17 +6,15 @@ pub fn setup_logging(app_name: &str, additional_crates: &[&str]) {
let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()); let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
let mut filter_string = format!("{app_name}={log_level},utils={log_level}"); let mut filter_string = format!("{app_name}={log_level},utils={log_level}");
for (crate_name) in additional_crates { for &crate_name in additional_crates {
filter_string.push(','); filter_string.push(',');
filter_string.push_str(&format!("{crate_name}={log_level}")); filter_string.push_str(&format!("{crate_name}={log_level}"));
} }
let filter = EnvFilter::try_new(filter_string) let filter =
.unwrap_or_else(|_| EnvFilter::new(format!("{app_name}=info,utils=info"))); EnvFilter::try_new(filter_string).unwrap_or_else(|_| EnvFilter::new(format!("{app_name}=info,utils=info")));
tracing_subscriber::fmt() tracing_subscriber::fmt().with_env_filter(filter).init();
.with_env_filter(filter)
.init();
error!("Error messages enabled"); error!("Error messages enabled");
warn!("Warning messages enabled"); warn!("Warning messages enabled");

View File

@@ -1,8 +1,8 @@
use crate::service_discovery::get_service_endpoints_by_dns;
use rand::seq::SliceRandom;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use rand::seq::SliceRandom;
use crate::service_discovery::get_service_endpoints_by_dns;
pub enum LoadBalancingStrategy { pub enum LoadBalancingStrategy {
Random, Random,
@@ -10,7 +10,7 @@ pub enum LoadBalancingStrategy {
} }
// Service identifier // Service identifier
#[derive(Clone, PartialEq, Eq, Hash)] #[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct ServiceId { pub struct ServiceId {
pub name: String, pub name: String,
pub protocol: String, pub protocol: String,
@@ -107,11 +107,7 @@ impl MultiServiceLoadBalancer {
service_name: &str, service_name: &str,
service_protocol: &str, service_protocol: &str,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let endpoints = get_service_endpoints_by_dns( let endpoints = get_service_endpoints_by_dns(&self.consul_url, service_protocol, service_name).await?;
&self.consul_url,
service_protocol,
service_name,
).await?;
let service_id = ServiceId::new(service_name, service_protocol); let service_id = ServiceId::new(service_name, service_protocol);
let mut services = self.services.lock().unwrap(); let mut services = self.services.lock().unwrap();
@@ -127,7 +123,8 @@ impl MultiServiceLoadBalancer {
}; };
for service_id in service_ids { for service_id in service_ids {
self.refresh_service_endpoints(&service_id.name, &service_id.protocol).await?; self.refresh_service_endpoints(&service_id.name, &service_id.protocol)
.await?;
} }
Ok(()) Ok(())

View File

@@ -1,16 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use deadpool_redis::{Config, Pool, Runtime}; use deadpool_redis::{Config, Pool, Runtime};
use redis::{AsyncCommands, Commands, RedisError}; use redis::{AsyncCommands, RedisError};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[async_trait] #[async_trait]
pub trait Cache { pub trait Cache {
async fn set<T: Serialize + Send + Sync>( async fn set<T: Serialize + Send + Sync>(&self, key: &String, value: &T, ttl: u64)
&self, -> Result<(), redis::RedisError>;
key: &String,
value: &T,
ttl: u64,
) -> Result<(), redis::RedisError>;
async fn update<T: Serialize + Send + Sync>( async fn update<T: Serialize + Send + Sync>(
&self, &self,

View File

@@ -1,14 +1,18 @@
use hickory_resolver::config::*; 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::net::SocketAddr;
use std::str::FromStr; 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; use tracing::debug;
pub async fn get_service_endpoints_by_dns(consul_url: &str, service_protocol: &str, service_name: &str) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error>> { pub async fn get_service_endpoints_by_dns(
consul_url: &str,
service_protocol: &str,
service_name: &str,
) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error>> {
let mut rc = ResolverConfig::new(); let mut rc = ResolverConfig::new();
let url = consul_url.parse()?; let url = consul_url.parse()?;
rc.add_name_server(NameServerConfig::new(url, Protocol::Tcp)); 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 hostname = record.target();
let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?; let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?;
for response in lookup_responses { 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) Ok(endpoints)
} }
pub async fn get_kube_service_endpoints_by_dns(port_name: &str, service_protocol: &str, service_name: &str) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error>> { pub async fn get_kube_service_endpoints_by_dns(
port_name: &str,
service_protocol: &str,
service_name: &str,
) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error>> {
let (config, options) = read_system_conf()?; let (config, options) = read_system_conf()?;
let resolver = TokioAsyncResolver::tokio(config, options); 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 hostname = record.target();
let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?; let lookup_responses = resolver.lookup_ip(hostname.to_string()).await?;
for response in lookup_responses { 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( pub async fn get_service_info(
namespace: &str, _namespace: &str,
service_name: &str, service_name: &str,
) -> Result<ServiceInfo, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ServiceInfo, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::try_default().await?; let client = Client::try_default().await?;

View File

@@ -16,15 +16,13 @@ async fn terminate_signal() {
#[cfg(unix)] #[cfg(unix)]
{ {
use tokio::signal::unix::{signal, SignalKind}; use tokio::signal::unix::{signal, SignalKind};
let mut sigterm = let mut sigterm = signal(SignalKind::terminate()).expect("Failed to set up SIGTERM handler");
signal(SignalKind::terminate()).expect("Failed to set up SIGTERM handler");
sigterm.recv().await; sigterm.recv().await;
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let mut ctrlbreak = let mut ctrlbreak = signal::windows::ctrl_break().expect("Failed to set up CTRL_BREAK handler");
signal::windows::ctrl_break().expect("Failed to set up CTRL_BREAK handler");
ctrlbreak.recv().await; ctrlbreak.recv().await;
} }
} }

95
world-service/README.md Normal file
View File

@@ -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

View File

@@ -11,9 +11,6 @@ fn main() {
tonic_build::configure() tonic_build::configure()
.build_server(false) // Generate gRPC client code .build_server(false) // Generate gRPC client code
.compile_well_known_types(true) .compile_well_known_types(true)
.compile_protos( .compile_protos(&["../proto/user_db_api.proto", "../proto/auth.proto"], &["../proto"])
&["../proto/user_db_api.proto", "../proto/auth.proto"],
&["../proto"],
)
.unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
} }

View File

@@ -1,7 +1,7 @@
use dotenv::dotenv; use dotenv::dotenv;
use std::env; 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::service_discovery::{get_kube_service_endpoints_by_dns, get_service_endpoints_by_dns};
use utils::{health_check, logging};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -12,7 +12,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set the gRPC server address // Set the gRPC server address
let addr = env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0".to_string()); 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 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 // Register service with Consul
health_check::start_health_check(addr.as_str()).await?; health_check::start_health_check(addr.as_str()).await?;