#![warn(clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::must_use_candidate)]
use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};
mod assets;
mod auth;
mod battle_ticket;
mod config;
mod db;
mod models;
mod pokeapi;
mod session;
mod view;
use db::{DuckDb, SqliteDb};
use session::SessionStore;
#[get("/up")]
async fn up() -> impl Responder {
HttpResponse::Ok().body("OK")
}
fn init_logging() {
let mut builder = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info,duckdb=warn,libsql=warn,turso=warn"),
);
let env_logger = builder.build();
let _ = log::set_boxed_logger(Box::new(env_logger));
log::set_max_level(log::LevelFilter::Debug);
}
fn spawn_duckdb_init(db: DuckDb) {
actix_web::rt::spawn(async move {
loop {
match db.init().await {
Ok(()) => {
log::info!("DuckDB initialized successfully");
if let Err(e) = db.seed_if_empty().await {
log::warn!("DuckDB seed warning: {e}");
}
return;
}
Err(e) => {
log::warn!("DuckDB init failed (retrying in 1s): {e}");
actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
});
}
fn spawn_sqlite_init(db: SqliteDb) {
actix_web::rt::spawn(async move {
loop {
match db.init().await {
Ok(()) => {
log::info!("SQLite initialized successfully");
return;
}
Err(e) => {
log::warn!("SQLite init failed (retrying in 1s): {e}");
actix_web::rt::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
}
});
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
init_logging();
let config = config::from_env();
config.maybe_reset_databases();
let duck_db = DuckDb::new(&config.duckdb_path).map_err(std::io::Error::other)?;
spawn_duckdb_init(duck_db.clone());
let sqlite_db = SqliteDb::new(&config.sqlite_path);
spawn_sqlite_init(sqlite_db.clone());
let port = config.port;
let config_data = web::Data::new(config);
let session_store = web::Data::new(SessionStore::new());
let duck_data = web::Data::new(duck_db);
let sqlite_data = web::Data::new(sqlite_db);
log::info!("Starting Pokédex & AutoBattler server on port {port}...");
HttpServer::new(move || {
App::new()
.app_data(config_data.clone())
.app_data(session_store.clone())
.app_data(duck_data.clone())
.app_data(sqlite_data.clone())
.service(up)
.service(assets::serve_asset)
.service(view::index)
.service(view::pokedex_tab)
.service(view::pokedex_search)
.service(view::pokedex_page)
.service(view::pokemon_detail)
.service(view::battler_tab)
.service(view::battler_ticket)
.service(view::battle_entry)
.service(view::debug_tab)
.service(view::verify_pin)
.service(view::logout)
.service(view::debug_sync)
})
.bind(("0.0.0.0", port))?
.run()
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::SESSION_COOKIE_NAME;
use actix_web::test as aw_test;
async fn create_test_databases() -> (config::Config, DuckDb, SqliteDb) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let config = config::Config::new(
8090,
None,
":memory:".to_string(),
format!("/tmp/test_main_{nanos}.db"),
"123456".to_string(),
);
let duck_db = DuckDb::new(":memory:").unwrap();
duck_db.init().await.unwrap();
duck_db.seed_test_data().await.unwrap();
let sqlite_db = SqliteDb::new(&config.sqlite_path);
sqlite_db.init().await.unwrap();
(config, duck_db, sqlite_db)
}
macro_rules! init_test_app {
($config:expr, $duck_db:expr, $sqlite_db:expr) => {
aw_test::init_service(
App::new()
.app_data(web::Data::new($config.clone()))
.app_data(web::Data::new(SessionStore::new()))
.app_data(web::Data::new($duck_db.clone()))
.app_data(web::Data::new($sqlite_db.clone()))
.service(up)
.service(assets::serve_asset)
.service(view::index)
.service(view::pokedex_tab)
.service(view::pokedex_search)
.service(view::pokedex_page)
.service(view::pokemon_detail)
.service(view::battler_tab)
.service(view::battler_ticket)
.service(view::battle_entry)
.service(view::debug_tab)
.service(view::verify_pin)
.service(view::logout)
.service(view::debug_sync),
)
.await
};
}
#[actix_web::test]
async fn test_up_endpoint() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let app = init_test_app!(config, duck_db, sqlite_db);
let req = aw_test::TestRequest::get().uri("/up").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
}
#[actix_web::test]
async fn test_home_and_pokedex_view() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let app = init_test_app!(config, duck_db, sqlite_db);
let req = aw_test::TestRequest::get().uri("/").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Pokédex"));
assert!(body_str.contains("Bulbasaur"));
assert!(body_str.contains("/assets/h.js"));
assert!(body_str.contains("/assets/app.css"));
}
#[actix_web::test]
async fn test_pokedex_search_endpoint() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let app = init_test_app!(config, duck_db, sqlite_db);
let req = aw_test::TestRequest::get()
.uri("/pokedex/search?q=pikachu")
.to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Pikachu"));
assert!(!body_str.contains("Bulbasaur"));
}
#[actix_web::test]
async fn test_pokemon_detail_modal() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let app = init_test_app!(config, duck_db, sqlite_db);
// Direct full page load
let req = aw_test::TestRequest::get().uri("/pokemon/1").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Bulbasaur"));
assert!(body_str.contains("Seed Pokémon"));
assert!(body_str.contains("Combat Characteristics"));
assert!(body_str.contains("Level-Up Moves"));
assert!(body_str.contains("Back to Pokédex"));
// HTMX fragment request
let req_htmx = aw_test::TestRequest::get()
.uri("/pokemon/1")
.insert_header(("hx-request", "true"))
.to_request();
let resp_htmx = aw_test::call_service(&app, req_htmx).await;
assert!(resp_htmx.status().is_success());
let body_htmx = aw_test::read_body(resp_htmx).await;
let body_htmx_str = String::from_utf8(body_htmx.to_vec()).unwrap();
assert!(body_htmx_str.contains("Bulbasaur"));
assert!(body_htmx_str.contains("Back to Pokédex"));
// Fragment should not contain DOCTYPE or full page shell
assert!(!body_htmx_str.contains("<!DOCTYPE html>"));
}
#[actix_web::test]
async fn test_battler_coming_soon() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let app = init_test_app!(config, duck_db, sqlite_db);
let req = aw_test::TestRequest::get().uri("/battler").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("AutoBattler Arena"));
assert!(
body_str.to_lowercase().contains("coming soon") || body_str.contains("Comming soon")
);
// Generated QR code and player PIN under it
assert!(body_str.contains("<svg"));
assert!(body_str.contains("Player Battle PIN"));
// Test dynamic ticket generation
let req_ticket = aw_test::TestRequest::get()
.uri("/battler/ticket")
.to_request();
let resp_ticket = aw_test::call_service(&app, req_ticket).await;
assert!(resp_ticket.status().is_success());
let body_ticket = aw_test::read_body(resp_ticket).await;
let body_ticket_str = String::from_utf8(body_ticket.to_vec()).unwrap();
assert!(body_ticket_str.contains("<svg"));
assert!(body_ticket_str.contains("Player Battle PIN"));
// Test player arriving at battle entry says coming soon
let req_entry = aw_test::TestRequest::get()
.uri("/battle?pin=987654")
.to_request();
let resp_entry = aw_test::call_service(&app, req_entry).await;
assert!(resp_entry.status().is_success());
let body_entry = aw_test::read_body(resp_entry).await;
let body_entry_str = String::from_utf8(body_entry.to_vec()).unwrap();
assert!(body_entry_str.to_lowercase().contains("coming soon"));
assert!(body_entry_str.contains("987654"));
}
#[actix_web::test]
async fn test_debug_guard_and_unlock() {
let (config, duck_db, sqlite_db) = create_test_databases().await;
let sqlite_path = config.sqlite_path.clone();
let app = init_test_app!(config, duck_db, sqlite_db);
// Unauthenticated debug returns guard
let req = aw_test::TestRequest::get().uri("/debug").to_request();
let resp = aw_test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = aw_test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("Admin Access"));
assert!(body_str.contains("Unlock Admin Area"));
// Valid pin unlocks debug
let req_pin = aw_test::TestRequest::post()
.uri("/pin")
.set_form(view::PinForm {
pin: "123456".to_string(),
})
.to_request();
let resp_pin = aw_test::call_service(&app, req_pin).await;
assert!(resp_pin.status().is_success());
let cookie = resp_pin
.response()
.cookies()
.find(|c| c.name() == SESSION_COOKIE_NAME);
assert!(cookie.is_some());
let body_pin = aw_test::read_body(resp_pin).await;
let body_pin_str = String::from_utf8(body_pin.to_vec()).unwrap();
assert!(body_pin_str.contains("Architecture Explorer"));
assert!(body_pin_str.contains("DuckDB Database Engine"));
assert!(body_pin_str.contains("SQLite Database Engine"));
let _ = std::fs::remove_file(&sqlite_path);
}
}