From 3012f470fcd146378f04eac230a5dd724a3e8023 Mon Sep 17 00:00:00 2001 From: Leko Date: Sat, 4 Jan 2025 01:35:53 +0800 Subject: [PATCH] ci: add tests and update CI file --- .env | 3 +- .github/workflows/ci.yml | 33 ++-- Cargo.lock | 39 +++- Cargo.toml | 8 +- Dockerfile | 12 +- README.md | 1 + src/handlers/mod.rs | 13 +- src/http_server.rs | 215 +++++++++++++++++++--- src/i18n/mod.rs | 28 ++- src/main.rs | 74 +++++--- src/slash_commands/inviter.rs | 87 +++++---- src/slash_commands/invites.rs | 136 ++++++++++---- src/slash_commands/invites_leaderboard.rs | 45 +++-- src/slash_commands/mod.rs | 6 +- src/slash_commands/ping.rs | 6 +- src/utils/config.rs | 147 ++++++++++++++- src/utils/db.rs | 92 ++++++++- src/utils/i18n.rs | 62 +++++-- src/utils/mod.rs | 4 +- src/utils/test_helpers.rs | 48 +++++ 20 files changed, 873 insertions(+), 186 deletions(-) create mode 100644 src/utils/test_helpers.rs diff --git a/.env b/.env index e91d156..28f25e1 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -DATABASE_URL=sqlite:data/bot.db \ No newline at end of file +DATABASE_URL=sqlite:data/bot.db +CONFIG_PATH=data/config.yaml \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7ef212..45b1433 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,8 @@ on: env: CARGO_TERM_COLOR: always - DATABASE_URL: sqlite:data/bot.db + DATABASE_URL: sqlite:/tmp/bot.db + CONFIG_PATH: /tmp/config.yaml REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -28,12 +29,12 @@ jobs: - name: Cache dependencies uses: Swatinem/rust-cache@v2 - - name: Create data directory - run: mkdir -p data + - name: Copy config + run: cp config.example.yaml /tmp/config.yaml - name: Install sqlx-cli run: cargo install sqlx-cli - + - name: Create database run: | sqlx database create @@ -59,6 +60,17 @@ jobs: - name: Cache dependencies uses: Swatinem/rust-cache@v2 + + - name: Copy config + run: cp config.example.yaml /tmp/config.yaml + + - name: Install sqlx-cli + run: cargo install sqlx-cli + + - name: Create database + run: | + sqlx database create + sqlx migrate run - name: Build run: cargo build --release @@ -66,8 +78,8 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: invitationbot - path: target/release/invitationbot + name: InvitationBot + path: target/release/InvitationBot release: name: Release @@ -82,20 +94,17 @@ jobs: - name: Download artifact uses: actions/download-artifact@v4 with: - name: invitationbot + name: InvitationBot path: ./ - name: Make binary executable - run: chmod +x invitationbot + run: chmod +x InvitationBot - name: Create Release uses: softprops/action-gh-release@v1 with: files: | - invitationbot - LICENSE - README.md - config.example.yaml + InvitationBot generate_release_notes: true docker: diff --git a/Cargo.lock b/Cargo.lock index bcfe090..874dd4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,9 @@ dependencies = [ "serde_yaml", "serenity", "sqlx", + "tempfile", "tokio", + "tower 0.4.13", "tower-http", "uuid", ] @@ -140,7 +142,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -1478,6 +1480,26 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pin-project" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.94", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -2618,6 +2640,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 9257e58..7c8e38d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,10 @@ axum = "0.8.1" tower-http = { version = "0.6.2", features = ["cors"] } chrono = { version = "0.4", features = ["serde"] } once_cell = "1.20.2" -rust-embed = "8.0" \ No newline at end of file +rust-embed = "8.0" + +[dev-dependencies] +tempfile = "3.9" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } +uuid = { version = "1.0", features = ["v4"] } +tower = { version = "0.4", features = ["util"] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 59234fc..72c8fda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.75-slim-bookworm as builder +FROM rust:1.83-slim-bookworm as builder WORKDIR /usr/src/app COPY . . @@ -8,6 +8,11 @@ RUN apt-get update && apt-get install -y \ libssl-dev \ && rm -rf /var/lib/apt/lists/* +ENV DATABASE_URL=sqlite:/tmp/bot.db +RUN cargo install sqlx-cli && \ + sqlx database create && \ + sqlx migrate run + RUN cargo build --release # Use distroless as runtime image @@ -15,11 +20,12 @@ FROM gcr.io/distroless/cc-debian12 WORKDIR /app -COPY --from=builder /usr/src/app/target/release/invitationbot /app/ +COPY --from=builder /usr/src/app/target/release/InvitationBot /app/ COPY --from=builder /usr/src/app/migrations /app/migrations ENV DATABASE_URL=sqlite:data/bot.db +ENV CONFIG_PATH=data/config.yaml VOLUME ["/app/data"] USER nonroot -ENTRYPOINT ["/app/invitationbot"] \ No newline at end of file +ENTRYPOINT ["/app/InvitationBot"] \ No newline at end of file diff --git a/README.md b/README.md index c7e1a3a..58e3e22 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ cp config.example.yaml data/config.yaml # Set up the database echo "DATABASE_URL=sqlite:data/bot.db" > .env +echo "CONFIG_PATH=data/config.yaml" >> .env cargo install sqlx-cli sqlx database create sqlx migrate run diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c8e8978..0ec8184 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,5 @@ -use poise::serenity_prelude::{self as serenity}; use crate::Data; +use poise::serenity_prelude::{self as serenity}; pub async fn handle_guild_member_addition( ctx: &serenity::Context, @@ -11,7 +11,9 @@ pub async fn handle_guild_member_addition( if let Ok(invites) = guild_id.invites(&ctx.http).await { for invite in invites { // Check if this is our invite code - if let Ok(Some(invite_id)) = crate::utils::db::find_invite_by_code(&data.db, &invite.code).await { + if let Ok(Some(invite_id)) = + crate::utils::db::find_invite_by_code(&data.db, &invite.code).await + { // Check if this invite is set to 2 uses, has been used once, and was created by us // If these conditions are met, we can assume this invite was used by the new member if invite.max_uses == 2 && invite.uses == 1 { @@ -20,8 +22,9 @@ pub async fn handle_guild_member_addition( &data.db, &invite_id, &new_member.user.id.to_string(), - ).await; - + ) + .await; + // Delete the invite link let _ = invite.delete(&ctx.http).await; break; @@ -29,4 +32,4 @@ pub async fn handle_guild_member_addition( } } } -} \ No newline at end of file +} diff --git a/src/http_server.rs b/src/http_server.rs index 5df6cbe..939a1f0 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -1,18 +1,15 @@ +use crate::t; use axum::{ extract::{Path, State}, response::Redirect, routing::get, Router, }; +use poise::serenity_prelude::{self as serenity, builder::CreateInvite}; use sqlx::SqlitePool; -use std::{sync::Arc, net::SocketAddr}; -use tower_http::cors::CorsLayer; -use poise::serenity_prelude::{ - self as serenity, - builder::CreateInvite, -}; -use crate::t; use std::collections::HashMap; +use std::{net::SocketAddr, sync::Arc}; +use tower_http::cors::CorsLayer; use crate::utils::config::Config; @@ -34,12 +31,21 @@ pub async fn run_server(config: Config, db: SqlitePool) { .layer(CorsLayer::permissive()) .with_state(app_state); - let addr: SocketAddr = bind_addr.parse() + let addr: SocketAddr = bind_addr + .parse() .expect(&t!("en", "errors.server.invalid_address")); - println!("{}", t!("en", "server.running", HashMap::from([("addr", addr.to_string())]))); + println!( + "{}", + t!( + "en", + "server.running", + HashMap::from([("addr", addr.to_string())]) + ) + ); axum::serve( - tokio::net::TcpListener::bind(&addr).await + tokio::net::TcpListener::bind(&addr) + .await .expect(&t!("en", "errors.server.bind_failed")), app.into_make_service(), ) @@ -47,28 +53,67 @@ pub async fn run_server(config: Config, db: SqlitePool) { .expect(&t!("en", "errors.server.start_failed")); } +use axum::http::StatusCode; + pub async fn handle_invite( Path(invite_id): Path, State(state): State, -) -> Result { +) -> Result { // Check if invite exists and hasn't been used let invite_record = crate::utils::db::get_unused_invite(&state.db, &invite_id) .await - .map_err(|e| t!(&state.config.i18n.default_locale, "http.errors.internal", HashMap::from([("error", e.to_string())])))? - .ok_or(t!(&state.config.i18n.default_locale, "http.errors.invalid_invite"))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + t!( + &state.config.i18n.default_locale, + "http.errors.internal", + HashMap::from([("error", e.to_string())]) + ), + ) + })? + .ok_or(( + StatusCode::BAD_REQUEST, + t!( + &state.config.i18n.default_locale, + "http.errors.invalid_invite" + ), + ))?; + + // If invite already has a code, redirect to it + if let Some(code) = invite_record.code { + return Ok(Redirect::temporary(&format!("https://discord.gg/{}", code))); + } // Get guild configuration - let guild_config = state.config.guilds.allowed.iter() + let guild_config = state + .config + .guilds + .allowed + .iter() .find(|g| g.id == invite_record.guild_id) - .ok_or(t!(&state.config.i18n.default_locale, "http.errors.server_not_found"))?; + .ok_or(( + StatusCode::BAD_REQUEST, + t!( + &state.config.i18n.default_locale, + "http.errors.server_not_found" + ), + ))?; - let channel_id = serenity::ChannelId::new( - guild_config.invite_channel.parse() - .map_err(|_| t!(&state.config.i18n.default_locale, "http.errors.invalid_channel"))? - ); + let channel_id = + serenity::ChannelId::new(guild_config.invite_channel.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + t!( + &state.config.i18n.default_locale, + "http.errors.invalid_channel" + ), + ) + })?); // Get invite expiration settings - let max_age = guild_config.max_age + let max_age = guild_config + .max_age .unwrap_or(state.config.bot.default_invite_max_age); // Create Discord invite link @@ -82,15 +127,139 @@ pub async fn handle_invite( .temporary(false), ) .await - .map_err(|e| t!(&state.config.i18n.default_locale, "http.errors.create_failed", HashMap::from([("error", e.to_string())])))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + t!( + &state.config.i18n.default_locale, + "http.errors.create_failed", + HashMap::from([("error", e.to_string())]) + ), + ) + })?; // Update invite information in database crate::utils::db::update_invite_code(&state.db, &invite_id, &invite.code) .await - .map_err(|e| t!(&state.config.i18n.default_locale, "http.errors.update_failed", HashMap::from([("error", e.to_string())])))?; + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + t!( + &state.config.i18n.default_locale, + "http.errors.update_failed", + HashMap::from([("error", e.to_string())]) + ), + ) + })?; Ok(Redirect::temporary(&format!( "https://discord.gg/{}", invite.code ))) -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::config::{ + BotConfig, Config, DatabaseConfig, GuildConfig, I18nConfig, ServerConfig, + }; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use std::sync::Arc; + use tower::ServiceExt; + use uuid::Uuid; + + async fn setup_test_app() -> (Router, SqlitePool) { + let db_url = format!("sqlite:file:{}?mode=memory", Uuid::new_v4()); + let pool = crate::utils::db::create_pool(&db_url).await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + + let config = Arc::new(Config { + bot: BotConfig { + token: "test_token".to_string(), + default_invite_max_age: 300, + }, + database: DatabaseConfig { + path: "test.db".to_string(), + }, + server: ServerConfig { + external_url: "http://localhost:8080".to_string(), + bind: "127.0.0.1:8080".to_string(), + }, + i18n: I18nConfig { + default_locale: "en".to_string(), + available_locales: vec!["en".to_string()], + }, + guilds: GuildConfig { allowed: vec![] }, + }); + + let app_state = AppState { + db: pool.clone(), + config, + }; + + let app = Router::new() + .route("/invite/{id}", get(handle_invite)) + .layer(CorsLayer::permissive()) + .with_state(app_state); + + (app, pool) + } + + #[tokio::test] + async fn test_invalid_invite() { + let (app, _) = setup_test_app().await; + + let response = app + .oneshot( + Request::builder() + .uri("/invite/invalid-id") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_valid_invite() { + let (app, pool) = setup_test_app().await; + let invite_id = Uuid::new_v4().to_string(); + let invite_code = Uuid::new_v4().to_string(); + + // Create test invite + crate::utils::db::create_invite(&pool, &invite_id, "123456789", "987654321") + .await + .unwrap(); + + crate::utils::db::update_invite_code(&pool, &invite_id, &invite_code) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .uri(&format!("/invite/{}", invite_id)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + dbg!(response.status()); + dbg!(response.headers()); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get("Location") + .unwrap() + .to_str() + .unwrap(), + format!("https://discord.gg/{}", invite_code) + ); + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index f2464da..724c7d3 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -9,4 +9,30 @@ pub struct I18nAssets; pub fn get_yaml(locale: &str) -> Option { I18nAssets::get(&format!("{}.yaml", locale)) .map(|f| String::from_utf8_lossy(f.data.as_ref()).into_owned()) -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_yaml() { + // Test existing locale + let content = get_yaml("en").expect("Failed to load en.yaml"); + assert!(content.contains("commands")); + + // Test non-existing locale + assert!(get_yaml("invalid").is_none()); + } + + #[test] + fn test_available_locales() { + for locale in AVAILABLE_LOCALES.iter() { + assert!( + get_yaml(locale).is_some(), + "Missing yaml file for locale: {}", + locale + ); + } + } +} diff --git a/src/main.rs b/src/main.rs index c74b459..d957acc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,13 @@ -use poise::serenity_prelude::{self as serenity, async_trait}; use poise::serenity_prelude::CreateEmbedFooter; +use poise::serenity_prelude::{self as serenity, async_trait}; use sqlx::sqlite::SqlitePool; use std::collections::HashMap; -mod slash_commands; -mod utils; -mod http_server; mod handlers; +mod http_server; mod i18n; +mod slash_commands; +mod utils; use utils::config::Config; @@ -24,10 +24,15 @@ async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { match error { poise::FrameworkError::Setup { error, .. } => { let locale = "en"; - panic!("{}", t!(locale, "errors.setup", HashMap::from([ - ("error", format!("{:?}", error)) - ]))); - }, + panic!( + "{}", + t!( + locale, + "errors.setup", + HashMap::from([("error", format!("{:?}", error))]) + ) + ); + } poise::FrameworkError::Command { error, ctx, .. } => { println!("Command error: {:?}", error); let guild_id = ctx.guild_id().unwrap(); @@ -38,25 +43,28 @@ async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { .description(t!(locale, "errors.command.description")) .color(0xFF3333) .footer(CreateEmbedFooter::new(t!(locale, "errors.command.footer"))); - - let reply = poise::CreateReply::default() - .embed(embed) - .ephemeral(true); + + let reply = poise::CreateReply::default().embed(embed).ephemeral(true); let _ = ctx.send(reply).await; } error => { let locale = "en"; - println!("{}", t!(locale, "errors.unknown", HashMap::from([ - ("error", format!("{:?}", error)) - ]))); + println!( + "{}", + t!( + locale, + "errors.unknown", + HashMap::from([("error", format!("{:?}", error))]) + ) + ); } } } #[tokio::main] async fn main() -> Result<(), Error> { - let config = Config::load()?; - + let config = Config::load(std::env::var("CONFIG_PATH").unwrap().as_str())?; + // Initialize database connection pool let db = utils::db::create_pool(&config.database.path).await?; @@ -85,23 +93,36 @@ async fn main() -> Result<(), Error> { .setup(|ctx, _ready, framework| { Box::pin(async move { let locale = config_clone1.i18n.default_locale.as_str(); - println!("{}", t!(locale, "bot.logged_in", HashMap::from([ - ("name", _ready.user.name.clone()) - ]))); + println!( + "{}", + t!( + locale, + "bot.logged_in", + HashMap::from([("name", _ready.user.name.clone())]) + ) + ); poise::builtins::register_globally(ctx, &framework.options().commands).await?; - Ok(Data { db, config: config_clone1 }) + Ok(Data { + db, + config: config_clone1, + }) }) }) .build(); - let intents = serenity::GatewayIntents::non_privileged() + let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT | serenity::GatewayIntents::GUILD_MEMBERS | serenity::GatewayIntents::GUILD_INVITES; let mut client = serenity::ClientBuilder::new(&config.bot.token, intents) .framework(framework) - .event_handler(Handler { data: Data { db: db_clone, config: config_clone2 } }) + .event_handler(Handler { + data: Data { + db: db_clone, + config: config_clone2, + }, + }) .await?; client.start().await?; @@ -116,16 +137,17 @@ struct Handler { #[async_trait] impl serenity::EventHandler for Handler { async fn guild_member_addition(&self, ctx: serenity::Context, new_member: serenity::Member) { - handlers::handle_guild_member_addition(&ctx, new_member.guild_id, &new_member, &self.data).await; + handlers::handle_guild_member_addition(&ctx, new_member.guild_id, &new_member, &self.data) + .await; } } #[macro_export] macro_rules! t { ($locale:expr, $key:expr) => { - crate::utils::i18n::get_text($locale, $key, None) + $crate::utils::i18n::get_text($locale, $key, None) }; ($locale:expr, $key:expr, $params:expr) => { - crate::utils::i18n::get_text($locale, $key, Some($params)) + $crate::utils::i18n::get_text($locale, $key, Some($params)) }; } diff --git a/src/slash_commands/inviter.rs b/src/slash_commands/inviter.rs index dd38bde..6ceb14d 100644 --- a/src/slash_commands/inviter.rs +++ b/src/slash_commands/inviter.rs @@ -1,7 +1,7 @@ -use crate::{Context, Error, t}; -use poise::serenity_prelude::{User, CreateEmbed, CreateEmbedFooter}; -use poise::CreateReply; +use crate::{t, Context, Error}; use chrono::Utc; +use poise::serenity_prelude::{CreateEmbed, CreateEmbedFooter, User}; +use poise::CreateReply; use std::collections::HashMap; /// View who invited a user @@ -13,41 +13,56 @@ pub async fn inviter( let guild_id = ctx.guild_id().unwrap(); let locale = ctx.data().config.get_guild_locale(&guild_id.to_string()); - let invite_info = match crate::utils::db::get_user_invite_info(&ctx.data().db, &user.id.to_string()).await? { - Some(info) => info, - None => { - let mut params = HashMap::new(); - params.insert("user", format!("<@{}>", user.id)); + let invite_info = + match crate::utils::db::get_user_invite_info(&ctx.data().db, &user.id.to_string()).await? { + Some(info) => info, + None => { + let mut params = HashMap::new(); + params.insert("user", format!("<@{}>", user.id)); - let embed = CreateEmbed::default() - .title(t!(locale, "commands.inviter.errors.no_record.title")) - .description(t!(locale, "commands.inviter.errors.no_record.description", params)) - .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.inviter.errors.no_record.footer"))); - - ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; - return Ok(()); - } - }; + let embed = CreateEmbed::default() + .title(t!(locale, "commands.inviter.errors.no_record.title")) + .description(t!( + locale, + "commands.inviter.errors.no_record.description", + params + )) + .color(0xFF3333) + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.inviter.errors.no_record.footer" + ))); + + ctx.send(CreateReply::default().embed(embed).ephemeral(true)) + .await?; + return Ok(()); + } + }; let creator = match invite_info.creator_id.unwrap().parse() { Ok(id) => ctx.http().get_user(id).await?, Err(_) => { let embed = CreateEmbed::default() .title(t!(locale, "commands.inviter.errors.invalid_user.title")) - .description(t!(locale, "commands.inviter.errors.invalid_user.description")) + .description(t!( + locale, + "commands.inviter.errors.invalid_user.description" + )) .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.inviter.errors.invalid_user.footer"))); - - ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.inviter.errors.invalid_user.footer" + ))); + + ctx.send(CreateReply::default().embed(embed).ephemeral(true)) + .await?; return Ok(()); } }; - let used_at = chrono::DateTime::::from_timestamp( - invite_info.used_at.unwrap().unix_timestamp(), - 0 - ).unwrap(); + let used_at = + chrono::DateTime::::from_timestamp(invite_info.used_at.unwrap().unix_timestamp(), 0) + .unwrap(); let time_ago = { let duration = Utc::now().signed_duration_since(used_at); if duration.num_days() > 0 { @@ -70,17 +85,25 @@ pub async fn inviter( .title(t!(locale, "commands.inviter.success.title")) .description(format!( "**{}**: {}\n**{}**: {}\n**{}**: {} ({})\n**{}**: {}", - t!(locale, "commands.inviter.success.user"), params["user"], - t!(locale, "commands.inviter.success.invited_by"), params["inviter"], - t!(locale, "commands.inviter.success.date"), params["date"], params["time_ago"], - t!(locale, "commands.inviter.success.invite_code"), params["code"] + t!(locale, "commands.inviter.success.user"), + params["user"], + t!(locale, "commands.inviter.success.invited_by"), + params["inviter"], + t!(locale, "commands.inviter.success.date"), + params["date"], + params["time_ago"], + t!(locale, "commands.inviter.success.invite_code"), + params["code"] )) .color(0x4CACEE) .thumbnail(user.avatar_url().unwrap_or_default()) - .footer(CreateEmbedFooter::new(t!(locale, "commands.inviter.success.footer"))); + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.inviter.success.footer" + ))); let reply = CreateReply::default().embed(embed); ctx.send(reply).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/slash_commands/invites.rs b/src/slash_commands/invites.rs index 6ae0887..6008cd8 100644 --- a/src/slash_commands/invites.rs +++ b/src/slash_commands/invites.rs @@ -1,8 +1,8 @@ -use crate::{Context, Error, t}; +use crate::{t, Context, Error}; use poise::serenity_prelude::{CreateEmbed, CreateEmbedFooter}; use poise::CreateReply; -use uuid::Uuid; use std::collections::HashMap; +use uuid::Uuid; /// Create an invite link #[poise::command(slash_command, guild_only)] @@ -11,38 +11,65 @@ pub async fn invites(ctx: Context<'_>) -> Result<(), Error> { let guild = ctx.guild().unwrap().clone(); let member = ctx.author_member().await.unwrap(); let locale = ctx.data().config.get_guild_locale(&guild_id.to_string()); - + // Check if guild is allowed - let guild_config = match ctx.data().config.guilds.allowed.iter() - .find(|g| g.id == guild_id.to_string()) { + let guild_config = match ctx + .data() + .config + .guilds + .allowed + .iter() + .find(|g| g.id == guild_id.to_string()) + { Some(config) => config, None => { let embed = CreateEmbed::default() - .title(t!(locale, "commands.invites.errors.server_not_allowed.title")) - .description(t!(locale, "commands.invites.errors.server_not_allowed.description")) + .title(t!( + locale, + "commands.invites.errors.server_not_allowed.title" + )) + .description(t!( + locale, + "commands.invites.errors.server_not_allowed.description" + )) .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites.errors.server_not_allowed.footer"))); - - ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites.errors.server_not_allowed.footer" + ))); + + ctx.send(CreateReply::default().embed(embed).ephemeral(true)) + .await?; return Ok(()); } }; - + // Check if user has allowed role - let role_with_limit = match member.roles.iter() - .find_map(|role_id| { - guild_config.allowed_roles.iter() - .find(|allowed_role| allowed_role.id == role_id.to_string()) - }) { + let role_with_limit = match member.roles.iter().find_map(|role_id| { + guild_config + .allowed_roles + .iter() + .find(|allowed_role| allowed_role.id == role_id.to_string()) + }) { Some(role) => role, None => { let embed = CreateEmbed::default() - .title(t!(locale, "commands.invites.errors.missing_permissions.title")) - .description(t!(locale, "commands.invites.errors.missing_permissions.description")) + .title(t!( + locale, + "commands.invites.errors.missing_permissions.title" + )) + .description(t!( + locale, + "commands.invites.errors.missing_permissions.description" + )) .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites.errors.missing_permissions.footer"))); - - ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites.errors.missing_permissions.footer" + ))); + + ctx.send(CreateReply::default().embed(embed).ephemeral(true)) + .await?; return Ok(()); } }; @@ -53,41 +80,62 @@ pub async fn invites(ctx: Context<'_>) -> Result<(), Error> { &ctx.author().id.to_string(), &guild_id.to_string(), role_with_limit.invite_limit.days, - ).await?; + ) + .await?; if used_invites >= role_with_limit.invite_limit.count as i64 { let mut params = HashMap::new(); params.insert("count", role_with_limit.invite_limit.count.to_string()); params.insert("days", role_with_limit.invite_limit.days.to_string()); params.insert("used", used_invites.to_string()); - params.insert("remaining", (role_with_limit.invite_limit.count as i64 - used_invites).to_string()); + params.insert( + "remaining", + (role_with_limit.invite_limit.count as i64 - used_invites).to_string(), + ); let embed = CreateEmbed::default() .title(t!(locale, "commands.invites.errors.limit_reached.title")) .description(format!( "{}\n\n**{}**:\n• {}\n• {}", - t!(locale, "commands.invites.errors.limit_reached.description", params.clone()), + t!( + locale, + "commands.invites.errors.limit_reached.description", + params.clone() + ), t!(locale, "commands.invites.errors.limit_reached.status"), - t!(locale, "commands.invites.errors.limit_reached.used", params.clone()), - t!(locale, "commands.invites.errors.limit_reached.remaining", params) + t!( + locale, + "commands.invites.errors.limit_reached.used", + params.clone() + ), + t!( + locale, + "commands.invites.errors.limit_reached.remaining", + params + ) )) .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites.errors.limit_reached.footer"))); + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites.errors.limit_reached.footer" + ))); - ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + ctx.send(CreateReply::default().embed(embed).ephemeral(true)) + .await?; return Ok(()); } let invite_id = Uuid::new_v4().to_string(); - + // Record basic information crate::utils::db::create_invite( &ctx.data().db, &invite_id, &guild_id.to_string(), &ctx.author().id.to_string(), - ).await?; - + ) + .await?; + let bot_invite_url = format!( "{}/invite/{}", ctx.data().config.server.external_url, @@ -99,24 +147,38 @@ pub async fn invites(ctx: Context<'_>) -> Result<(), Error> { params.insert("count", role_with_limit.invite_limit.count.to_string()); params.insert("days", role_with_limit.invite_limit.days.to_string()); params.insert("used", used_invites.to_string()); - params.insert("remaining", (role_with_limit.invite_limit.count as i64 - used_invites).to_string()); + params.insert( + "remaining", + (role_with_limit.invite_limit.count as i64 - used_invites).to_string(), + ); let embed = CreateEmbed::default() .title(t!(locale, "commands.invites.success.title")) .description(format!( "{}\n\n{}\n\n**{}**:\n• {}\n• {}", - t!(locale, "commands.invites.success.description", params.clone()), + t!( + locale, + "commands.invites.success.description", + params.clone() + ), bot_invite_url, t!(locale, "commands.invites.success.limits"), - t!(locale, "commands.invites.success.invites_per_days", params.clone()), + t!( + locale, + "commands.invites.success.invites_per_days", + params.clone() + ), t!(locale, "commands.invites.success.used_remaining", params) )) .color(0x4CACEE) .thumbnail(guild.icon_url().unwrap_or_default()) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites.success.footer"))); + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites.success.footer" + ))); let reply = CreateReply::default().embed(embed); ctx.send(reply).await?; - + Ok(()) -} \ No newline at end of file +} diff --git a/src/slash_commands/invites_leaderboard.rs b/src/slash_commands/invites_leaderboard.rs index a4f8c33..90d2f4e 100644 --- a/src/slash_commands/invites_leaderboard.rs +++ b/src/slash_commands/invites_leaderboard.rs @@ -1,4 +1,4 @@ -use crate::{Context, Error, t}; +use crate::{t, Context, Error}; use poise::serenity_prelude::{CreateEmbed, CreateEmbedFooter}; use poise::CreateReply; use std::collections::HashMap; @@ -14,25 +14,31 @@ pub async fn invites_leaderboard( let locale = ctx.data().config.get_guild_locale(&guild_id.to_string()); let days = days.unwrap_or(30); - let entries = crate::utils::db::get_invite_leaderboard( - &ctx.data().db, - &guild_id.to_string(), - days - ).await?; + let entries = + crate::utils::db::get_invite_leaderboard(&ctx.data().db, &guild_id.to_string(), days) + .await?; if entries.is_empty() { let mut params = HashMap::new(); params.insert("days", days.to_string()); let embed = CreateEmbed::default() - .title(t!(locale, "commands.invites_leaderboard.errors.no_invites.title")) - .description(t!(locale, "commands.invites_leaderboard.errors.no_invites.description", params)) + .title(t!( + locale, + "commands.invites_leaderboard.errors.no_invites.title" + )) + .description(t!( + locale, + "commands.invites_leaderboard.errors.no_invites.description", + params + )) .color(0xFF3333) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites_leaderboard.errors.no_invites.footer"))); - - let reply = CreateReply::default() - .embed(embed) - .ephemeral(true); + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites_leaderboard.errors.no_invites.footer" + ))); + + let reply = CreateReply::default().embed(embed).ephemeral(true); ctx.send(reply).await?; return Ok(()); } @@ -51,14 +57,21 @@ pub async fn invites_leaderboard( params.insert("guild", guild.name.clone()); let embed = CreateEmbed::default() - .title(t!(locale, "commands.invites_leaderboard.success.title", params)) + .title(t!( + locale, + "commands.invites_leaderboard.success.title", + params + )) .description(description) .color(0x4CACEE) .thumbnail(guild.icon_url().unwrap_or_default()) - .footer(CreateEmbedFooter::new(t!(locale, "commands.invites_leaderboard.success.footer"))); + .footer(CreateEmbedFooter::new(t!( + locale, + "commands.invites_leaderboard.success.footer" + ))); let reply = CreateReply::default().embed(embed); ctx.send(reply).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/slash_commands/mod.rs b/src/slash_commands/mod.rs index 39fabc1..c41bd69 100644 --- a/src/slash_commands/mod.rs +++ b/src/slash_commands/mod.rs @@ -1,4 +1,4 @@ -pub mod ping; -pub mod invites; pub mod inviter; -pub mod invites_leaderboard; \ No newline at end of file +pub mod invites; +pub mod invites_leaderboard; +pub mod ping; diff --git a/src/slash_commands/ping.rs b/src/slash_commands/ping.rs index 36f76e4..4de7a4e 100644 --- a/src/slash_commands/ping.rs +++ b/src/slash_commands/ping.rs @@ -1,4 +1,4 @@ -use crate::{Context, Error, t}; +use crate::{t, Context, Error}; use poise::serenity_prelude::CreateEmbed; use poise::CreateReply; @@ -12,8 +12,8 @@ pub async fn ping(ctx: Context<'_>) -> Result<(), Error> { .title(t!(locale, "commands.ping.response.title")) .description(t!(locale, "commands.ping.response.description")) .color(0x4CACEE); - + let reply = CreateReply::default().embed(embed); ctx.send(reply).await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 9d6a991..79312ee 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,6 +1,6 @@ +use crate::i18n::AVAILABLE_LOCALES; use serde::{Deserialize, Serialize}; use std::fs; -use crate::i18n::AVAILABLE_LOCALES; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -62,8 +62,8 @@ pub struct InviteLimit { } impl Config { - pub fn load() -> Result> { - let content = fs::read_to_string("data/config.yaml")?; + pub fn load(path: &str) -> Result> { + let content = fs::read_to_string(path)?; let config: Config = serde_yaml::from_str(&content)?; // Validate locales @@ -77,9 +77,146 @@ impl Config { } pub fn get_guild_locale(&self, guild_id: &str) -> &str { - self.guilds.allowed.iter() + self.guilds + .allowed + .iter() .find(|g| g.id == guild_id) .and_then(|g| g.locale.as_deref()) .unwrap_or(&self.i18n.default_locale) } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_test_config() -> (NamedTempFile, Config) { + let config = Config { + bot: BotConfig { + token: "test_token".to_string(), + default_invite_max_age: 300, + }, + database: DatabaseConfig { + path: "test.db".to_string(), + }, + server: ServerConfig { + external_url: "http://localhost:8080".to_string(), + bind: "127.0.0.1:8080".to_string(), + }, + i18n: I18nConfig { + default_locale: "en".to_string(), + available_locales: vec!["en".to_string(), "zh-TW".to_string()], + }, + guilds: GuildConfig { allowed: vec![] }, + }; + + let file = NamedTempFile::new().unwrap(); + write!( + file.as_file(), + "{}", + serde_yaml::to_string(&config).unwrap() + ) + .unwrap(); + (file, config) + } + + #[test] + fn test_config_load() { + let (file, expected_config) = create_test_config(); + let config = Config::load(file.path().to_str().unwrap()).unwrap(); + assert_eq!(config.bot.token, expected_config.bot.token); + assert_eq!( + config.i18n.default_locale, + expected_config.i18n.default_locale + ); + } + + #[test] + fn test_invalid_locale() { + let config = Config { + i18n: I18nConfig { + default_locale: "invalid".to_string(), + available_locales: vec!["invalid".to_string()], + }, + ..create_test_config().1 + }; + + let file = NamedTempFile::new().unwrap(); + write!( + file.as_file(), + "{}", + serde_yaml::to_string(&config).unwrap() + ) + .unwrap(); + + assert!(Config::load(file.path().to_str().unwrap()).is_err()); + } + + #[test] + fn test_get_guild_locale() { + let mut config = create_test_config().1; + + // 添加一個測試用的公會 + config.guilds.allowed.push(AllowedGuild { + id: "123".to_string(), + name: "Test Guild".to_string(), + invite_channel: "456".to_string(), + max_age: None, + locale: Some("zh-TW".to_string()), + allowed_roles: vec![], + }); + + // 測試指定公會的語言設定 + assert_eq!(config.get_guild_locale("123"), "zh-TW"); + + // 測試未知公會使用預設語言 + assert_eq!(config.get_guild_locale("unknown"), "en"); + + // 測試沒有指定語言的公會使用預設語言 + config.guilds.allowed[0].locale = None; + assert_eq!(config.get_guild_locale("123"), "en"); + } + + #[test] + fn test_invite_limits() { + let mut config = create_test_config().1; + + // 添加一個帶有邀請限制的公會 + config.guilds.allowed.push(AllowedGuild { + id: "123".to_string(), + name: "Test Guild".to_string(), + invite_channel: "456".to_string(), + max_age: Some(7200), // 自定義過期時間 + locale: None, + allowed_roles: vec![AllowedRole { + id: "789".to_string(), + invite_limit: InviteLimit { count: 5, days: 7 }, + }], + }); + + let guild = &config.guilds.allowed[0]; + + // 測試自定義過期時間 + assert_eq!(guild.max_age.unwrap(), 7200); + + // 測試角色邀請限制 + let role = &guild.allowed_roles[0]; + assert_eq!(role.invite_limit.count, 5); + assert_eq!(role.invite_limit.days, 7); + } + + #[test] + fn test_default_values() { + let config = create_test_config().1; + + // 測試預設邀請過期時間 + assert_eq!(config.bot.default_invite_max_age, 300); + + // 測試預設語言設定 + assert_eq!(config.i18n.default_locale, "en"); + assert!(config.i18n.available_locales.contains(&"en".to_string())); + assert!(config.i18n.available_locales.contains(&"zh-TW".to_string())); + } +} diff --git a/src/utils/db.rs b/src/utils/db.rs index b6488b9..30e4229 100644 --- a/src/utils/db.rs +++ b/src/utils/db.rs @@ -1,5 +1,5 @@ -use sqlx::SqlitePool; use sqlx::types::time::OffsetDateTime; +use sqlx::SqlitePool; pub async fn setup_database(pool: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::query!( @@ -25,7 +25,6 @@ pub async fn create_pool(database_path: &str) -> Result Ok(pool) } -// 邀請相關操作 pub async fn create_invite( pool: &SqlitePool, invite_id: &str, @@ -51,7 +50,7 @@ pub async fn get_unused_invite( ) -> Result, sqlx::Error> { sqlx::query_as!( InviteRecord, - "SELECT guild_id FROM invites WHERE id = ? AND used_at IS NULL", + "SELECT guild_id, creator_id, discord_invite_code as code FROM invites WHERE id = ? AND used_at IS NULL", invite_id ) .fetch_optional(pool) @@ -98,8 +97,11 @@ pub async fn count_used_invites( } #[derive(Debug)] +#[allow(dead_code)] pub struct InviteRecord { pub guild_id: String, + pub creator_id: String, // Used for invite tracking and permissions + pub code: Option, // Discord invite code, if already created } #[derive(Debug, sqlx::FromRow)] @@ -173,7 +175,10 @@ pub async fn get_invite_leaderboard( guild_id: &str, days: i32, ) -> Result, sqlx::Error> { + // Ensure days is non-negative + let days = days.max(0); let days_str = format!("-{} days", days); + sqlx::query_as!( InviteLeaderboardEntry, r#" @@ -185,7 +190,7 @@ pub async fn get_invite_leaderboard( AND created_at > datetime('now', ?) AND used_at IS NOT NULL GROUP BY creator_id - ORDER BY invite_count DESC + ORDER BY invite_count DESC, creator_id ASC LIMIT 5 "#, guild_id, @@ -193,4 +198,81 @@ pub async fn get_invite_leaderboard( ) .fetch_all(pool) .await -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::SqlitePool; + use uuid::Uuid; + + async fn setup_test_db() -> SqlitePool { + let db_url = format!("sqlite:file:{}?mode=memory", Uuid::new_v4()); + let pool = create_pool(&db_url).await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn test_create_and_get_invite() { + let pool = setup_test_db().await; + let invite_id = Uuid::new_v4().to_string(); + let guild_id = "123456789"; + let creator_id = "987654321"; + + // Test create invite + create_invite(&pool, &invite_id, guild_id, creator_id) + .await + .unwrap(); + + // Test get unused invite + let invite = get_unused_invite(&pool, &invite_id).await.unwrap().unwrap(); + assert_eq!(invite.guild_id, guild_id); + assert_eq!(invite.creator_id, creator_id); + assert!(invite.code.is_none()); + } + + #[tokio::test] + async fn test_invite_leaderboard() { + let pool = setup_test_db().await; + let guild_id = "123456789"; + let creator_id = "987654321"; + let user_id = "111222333"; + + // Create multiple invites + for _ in 0..3 { + let invite_id = Uuid::new_v4().to_string(); + create_invite(&pool, &invite_id, guild_id, creator_id) + .await + .unwrap(); + record_invite_use(&pool, &invite_id, user_id).await.unwrap(); + } + + // Test leaderboard + let entries = get_invite_leaderboard(&pool, guild_id, 30).await.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].creator_id, creator_id); + assert_eq!(entries[0].invite_count, 3); + } + + #[tokio::test] + async fn test_mark_invite_used() { + let pool = setup_test_db().await; + let invite_id = Uuid::new_v4().to_string(); + let guild_id = "123456789"; + let creator_id = "987654321"; + let user_id = "111222333"; + + // Create invite + create_invite(&pool, &invite_id, guild_id, creator_id) + .await + .unwrap(); + + // Mark as used + record_invite_use(&pool, &invite_id, user_id).await.unwrap(); + + // Verify invite is marked as used + let invite = get_unused_invite(&pool, &invite_id).await.unwrap(); + assert!(invite.is_none()); + } +} diff --git a/src/utils/i18n.rs b/src/utils/i18n.rs index eabfe34..53d6f3a 100644 --- a/src/utils/i18n.rs +++ b/src/utils/i18n.rs @@ -1,12 +1,12 @@ -use std::collections::HashMap; use once_cell::sync::Lazy; use serde_yaml::Value; +use std::collections::HashMap; type Translations = HashMap; static TRANSLATIONS: Lazy> = Lazy::new(|| { let mut translations = HashMap::new(); - + // Load all language files from embedded assets for locale in crate::i18n::AVAILABLE_LOCALES.iter() { if let Some(content) = crate::i18n::get_yaml(locale) { @@ -15,20 +15,24 @@ static TRANSLATIONS: Lazy> = Lazy::new(|| { } } } - + translations }); pub fn get_text(locale: &str, key: &str, params: Option>) -> String { let parts: Vec<&str> = key.split('.').collect(); let default_locale = "en"; - - let translations = TRANSLATIONS.get(locale).or_else(|| TRANSLATIONS.get(default_locale)); - + + let translations = TRANSLATIONS + .get(locale) + .or_else(|| TRANSLATIONS.get(default_locale)); + if let Some(trans) = translations { - let mut value = trans.get(&parts[0].to_string()) - .unwrap_or_else(|| return &Value::Null); - + let mut value = trans.get(&parts[0].to_string()).unwrap_or_else(|| { + println!("Translation not found for key: {}", key); + &Value::Null + }); + for &part in &parts[1..] { if let Some(v) = value.get(part) { value = v; @@ -48,6 +52,42 @@ pub fn get_text(locale: &str, key: &str, params: Option>) return text.to_string(); } } - + key.to_string() -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_text() { + // Test simple text + assert_eq!(get_text("en", "commands.ping.name", None), "ping"); + + // Test with parameters + let mut params = HashMap::new(); + params.insert("name", "TestBot".to_string()); + assert_eq!( + get_text("en", "bot.logged_in", Some(params)), + "Logged in as TestBot" + ); + + // Test fallback to default locale + assert_eq!(get_text("invalid", "commands.ping.name", None), "ping"); + + // Test invalid key + assert_eq!(get_text("en", "invalid.key", None), "invalid.key"); + } + + #[test] + fn test_translations_loaded() { + for locale in crate::i18n::AVAILABLE_LOCALES.iter() { + assert!( + TRANSLATIONS.get(*locale).is_some(), + "Missing translations for locale: {}", + locale + ); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d87f56a..ab65b78 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,5 @@ pub mod config; pub mod db; -pub mod i18n; \ No newline at end of file +pub mod i18n; +#[cfg(test)] +pub mod test_helpers; diff --git a/src/utils/test_helpers.rs b/src/utils/test_helpers.rs new file mode 100644 index 0000000..0e78c36 --- /dev/null +++ b/src/utils/test_helpers.rs @@ -0,0 +1,48 @@ +use sqlx::SqlitePool; +use uuid::Uuid; + +pub struct TestContext { + pub db: SqlitePool, + pub config: crate::utils::config::Config, +} + +impl TestContext { + pub async fn new() -> Self { + let db_url = format!("sqlite:file:{}?mode=memory", Uuid::new_v4()); + let pool = crate::utils::db::create_pool(&db_url).await.unwrap(); + sqlx::migrate!().run(&pool).await.unwrap(); + + let config = crate::utils::config::Config { + bot: crate::utils::config::BotConfig { + token: "test_token".to_string(), + default_invite_max_age: 300, + }, + database: crate::utils::config::DatabaseConfig { + path: "test.db".to_string(), + }, + server: crate::utils::config::ServerConfig { + external_url: "http://localhost:8080".to_string(), + bind: "127.0.0.1:8080".to_string(), + }, + i18n: crate::utils::config::I18nConfig { + default_locale: "en".to_string(), + available_locales: vec!["en".to_string()], + }, + guilds: crate::utils::config::GuildConfig { allowed: vec![] }, + }; + + Self { db: pool, config } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_context_creation() { + let test_ctx = TestContext::new().await; + assert!(test_ctx.db.acquire().await.is_ok()); + assert_eq!(test_ctx.config.i18n.default_locale, "en"); + } +}