botserver/src/weba/mod.rs

1164 lines
30 KiB
Rust

use axum::{
extract::{Path, Query, State},
response::{Html, IntoResponse},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebApp {
pub id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub template: WebAppTemplate,
pub status: WebAppStatus,
pub config: WebAppConfig,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum WebAppTemplate {
#[default]
Blank,
Landing,
Dashboard,
Form,
Portal,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum WebAppStatus {
#[default]
Draft,
Published,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebAppConfig {
pub theme: String,
pub layout: String,
pub auth_required: bool,
pub custom_domain: Option<String>,
pub meta_tags: HashMap<String, String>,
pub scripts: Vec<String>,
pub styles: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAppPage {
pub id: Uuid,
pub app_id: Uuid,
pub path: String,
pub title: String,
pub content: String,
pub layout: Option<String>,
pub is_index: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAppComponent {
pub id: Uuid,
pub app_id: Uuid,
pub name: String,
pub component_type: ComponentType,
pub props: serde_json::Value,
pub children: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComponentType {
Container,
Text,
Image,
Button,
Form,
Input,
Table,
Chart,
Custom(String),
}
pub struct WebaState {
apps: RwLock<HashMap<Uuid, WebApp>>,
pages: RwLock<HashMap<Uuid, WebAppPage>>,
_components: RwLock<HashMap<Uuid, WebAppComponent>>,
}
impl WebaState {
pub fn new() -> Self {
Self {
apps: RwLock::new(HashMap::new()),
pages: RwLock::new(HashMap::new()),
_components: RwLock::new(HashMap::new()),
}
}
}
impl Default for WebaState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize)]
pub struct CreateAppRequest {
pub name: String,
pub description: Option<String>,
pub template: Option<WebAppTemplate>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAppRequest {
pub name: Option<String>,
pub description: Option<String>,
pub status: Option<WebAppStatus>,
pub config: Option<WebAppConfig>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePageRequest {
pub path: String,
pub title: String,
pub content: String,
pub layout: Option<String>,
pub is_index: bool,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub limit: Option<usize>,
pub offset: Option<usize>,
pub status: Option<String>,
}
pub fn configure_routes(state: Arc<WebaState>) -> Router {
Router::new()
.route("/apps", get(list_apps).post(create_app))
.route("/apps/:id", get(get_app).put(update_app).delete(delete_app))
.route("/apps/:id/pages", get(list_pages).post(create_page))
.route(
"/apps/:id/pages/:page_id",
get(get_page).put(update_page).delete(delete_page),
)
.route("/apps/:id/publish", post(publish_app))
.route("/apps/:id/preview", get(preview_app))
.route("/render/:slug", get(render_app))
.route("/render/:slug/*path", get(render_page))
.with_state(state)
}
async fn list_apps(
State(state): State<Arc<WebaState>>,
Query(query): Query<ListQuery>,
) -> Json<Vec<WebApp>> {
let apps = state.apps.read().await;
let mut result: Vec<WebApp> = apps.values().cloned().collect();
if let Some(status) = query.status {
result.retain(|app| {
matches!(
(&app.status, status.as_str()),
(WebAppStatus::Draft, "draft")
| (WebAppStatus::Published, "published")
| (WebAppStatus::Archived, "archived")
)
});
}
result.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
let offset = query.offset.unwrap_or(0);
let limit = query.limit.unwrap_or(50);
let result: Vec<WebApp> = result.into_iter().skip(offset).take(limit).collect();
Json(result)
}
async fn create_app(
State(state): State<Arc<WebaState>>,
Json(req): Json<CreateAppRequest>,
) -> Json<WebApp> {
let now = chrono::Utc::now();
let id = Uuid::new_v4();
let slug = slugify(&req.name);
let app = WebApp {
id,
name: req.name,
slug,
description: req.description,
template: req.template.unwrap_or_default(),
status: WebAppStatus::Draft,
config: WebAppConfig::default(),
created_at: now,
updated_at: now,
};
let mut apps = state.apps.write().await;
apps.insert(id, app.clone());
Json(app)
}
async fn get_app(
State(state): State<Arc<WebaState>>,
Path(id): Path<Uuid>,
) -> Result<Json<WebApp>, axum::http::StatusCode> {
let apps = state.apps.read().await;
apps.get(&id)
.cloned()
.map(Json)
.ok_or(axum::http::StatusCode::NOT_FOUND)
}
async fn update_app(
State(state): State<Arc<WebaState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateAppRequest>,
) -> Result<Json<WebApp>, axum::http::StatusCode> {
let mut apps = state.apps.write().await;
let app = apps.get_mut(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?;
if let Some(name) = req.name {
app.name.clone_from(&name);
app.slug = slugify(&name);
}
if let Some(description) = req.description {
app.description = Some(description);
}
if let Some(status) = req.status {
app.status = status;
}
if let Some(config) = req.config {
app.config = config;
}
app.updated_at = chrono::Utc::now();
Ok(Json(app.clone()))
}
async fn delete_app(
State(state): State<Arc<WebaState>>,
Path(id): Path<Uuid>,
) -> axum::http::StatusCode {
let mut apps = state.apps.write().await;
let mut pages = state.pages.write().await;
pages.retain(|_, page| page.app_id != id);
if apps.remove(&id).is_some() {
axum::http::StatusCode::NO_CONTENT
} else {
axum::http::StatusCode::NOT_FOUND
}
}
async fn list_pages(
State(state): State<Arc<WebaState>>,
Path(app_id): Path<Uuid>,
) -> Json<Vec<WebAppPage>> {
let pages = state.pages.read().await;
let result: Vec<WebAppPage> = pages
.values()
.filter(|p| p.app_id == app_id)
.cloned()
.collect();
Json(result)
}
async fn create_page(
State(state): State<Arc<WebaState>>,
Path(app_id): Path<Uuid>,
Json(req): Json<CreatePageRequest>,
) -> Result<Json<WebAppPage>, axum::http::StatusCode> {
let apps = state.apps.read().await;
if !apps.contains_key(&app_id) {
return Err(axum::http::StatusCode::NOT_FOUND);
}
drop(apps);
let now = chrono::Utc::now();
let id = Uuid::new_v4();
let page = WebAppPage {
id,
app_id,
path: req.path,
title: req.title,
content: req.content,
layout: req.layout,
is_index: req.is_index,
created_at: now,
updated_at: now,
};
let mut pages = state.pages.write().await;
pages.insert(id, page.clone());
Ok(Json(page))
}
async fn get_page(
State(state): State<Arc<WebaState>>,
Path((app_id, page_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<WebAppPage>, axum::http::StatusCode> {
let pages = state.pages.read().await;
pages
.get(&page_id)
.filter(|p| p.app_id == app_id)
.cloned()
.map(Json)
.ok_or(axum::http::StatusCode::NOT_FOUND)
}
async fn update_page(
State(state): State<Arc<WebaState>>,
Path((app_id, page_id)): Path<(Uuid, Uuid)>,
Json(req): Json<CreatePageRequest>,
) -> Result<Json<WebAppPage>, axum::http::StatusCode> {
let mut pages = state.pages.write().await;
let page = pages
.get_mut(&page_id)
.filter(|p| p.app_id == app_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
page.path = req.path;
page.title = req.title;
page.content = req.content;
page.layout = req.layout;
page.is_index = req.is_index;
page.updated_at = chrono::Utc::now();
Ok(Json(page.clone()))
}
async fn delete_page(
State(state): State<Arc<WebaState>>,
Path((app_id, page_id)): Path<(Uuid, Uuid)>,
) -> axum::http::StatusCode {
let mut pages = state.pages.write().await;
let exists = pages
.get(&page_id)
.map(|p| p.app_id == app_id)
.unwrap_or(false);
if exists {
pages.remove(&page_id);
axum::http::StatusCode::NO_CONTENT
} else {
axum::http::StatusCode::NOT_FOUND
}
}
async fn publish_app(
State(state): State<Arc<WebaState>>,
Path(id): Path<Uuid>,
) -> Result<Json<WebApp>, axum::http::StatusCode> {
let mut apps = state.apps.write().await;
let app = apps.get_mut(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?;
app.status = WebAppStatus::Published;
app.updated_at = chrono::Utc::now();
Ok(Json(app.clone()))
}
async fn preview_app(
State(state): State<Arc<WebaState>>,
Path(id): Path<Uuid>,
) -> Result<Html<String>, axum::http::StatusCode> {
let apps = state.apps.read().await;
let app = apps.get(&id).ok_or(axum::http::StatusCode::NOT_FOUND)?;
let pages = state.pages.read().await;
let index_page = pages.values().find(|p| p.app_id == id && p.is_index);
let content = index_page
.map(|p| p.content.clone())
.unwrap_or_else(|| "<p>No content yet</p>".to_string());
let html = render_html(app, &content);
Ok(Html(html))
}
async fn render_app(
State(state): State<Arc<WebaState>>,
Path(slug): Path<String>,
) -> Result<impl IntoResponse, axum::http::StatusCode> {
let apps = state.apps.read().await;
let app = apps
.values()
.find(|a| a.slug == slug && matches!(a.status, WebAppStatus::Published))
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone();
drop(apps);
let pages = state.pages.read().await;
let index_page = pages.values().find(|p| p.app_id == app.id && p.is_index);
let content = index_page
.map(|p| p.content.clone())
.unwrap_or_else(|| "<p>Page not found</p>".to_string());
let html = render_html(&app, &content);
Ok(Html(html))
}
async fn render_page(
State(state): State<Arc<WebaState>>,
Path((slug, path)): Path<(String, String)>,
) -> Result<impl IntoResponse, axum::http::StatusCode> {
let apps = state.apps.read().await;
let app = apps
.values()
.find(|a| a.slug == slug && matches!(a.status, WebAppStatus::Published))
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone();
drop(apps);
let normalized_path = format!("/{}", path.trim_start_matches('/'));
let pages = state.pages.read().await;
let page = pages
.values()
.find(|p| p.app_id == app.id && p.path == normalized_path);
let content = page
.map(|p| p.content.clone())
.unwrap_or_else(|| "<p>Page not found</p>".to_string());
let html = render_html(&app, &content);
Ok(Html(html))
}
fn render_html(app: &WebApp, content: &str) -> String {
let meta_tags: String = app
.config
.meta_tags
.iter()
.map(|(k, v)| format!("<meta name=\"{k}\" content=\"{v}\">"))
.collect::<Vec<_>>()
.join("\n ");
let scripts: String = app
.config
.scripts
.iter()
.map(|s| format!("<script src=\"{s}\"></script>"))
.collect::<Vec<_>>()
.join("\n ");
let styles: String = app
.config
.styles
.iter()
.map(|s| format!("<link rel=\"stylesheet\" href=\"{s}\">"))
.collect::<Vec<_>>()
.join("\n ");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{}</title>
{}
{}
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }}
</style>
</head>
<body>
{}
{}
</body>
</html>"#,
app.name, meta_tags, styles, content, scripts
)
}
pub fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub fn init() {
log::info!("WEBA module initialized");
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BrowserType {
#[default]
Chrome,
Firefox,
Safari,
Edge,
}
impl BrowserType {
pub const fn browser_name(self) -> &'static str {
match self {
Self::Chrome => "chrome",
Self::Firefox => "firefox",
Self::Safari => "safari",
Self::Edge => "MicrosoftEdge",
}
}
}
#[derive(Debug, Clone)]
pub struct BrowserConfig {
pub browser_type: BrowserType,
pub debug_port: u16,
pub headless: bool,
pub window_width: u32,
pub window_height: u32,
pub timeout: Duration,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
browser_type: BrowserType::Chrome,
debug_port: 9222,
headless: true,
window_width: 1920,
window_height: 1080,
timeout: Duration::from_secs(30),
}
}
}
impl BrowserConfig {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub const fn with_browser(mut self, browser: BrowserType) -> Self {
self.browser_type = browser;
self
}
pub const fn with_debug_port(mut self, port: u16) -> Self {
self.debug_port = port;
self
}
pub const fn headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub const fn with_window_size(mut self, width: u32, height: u32) -> Self {
self.window_width = width;
self.window_height = height;
self
}
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
#[derive(Debug, Clone)]
pub struct E2EConfig {
browser: BrowserType,
headless: bool,
timeout: Duration,
pub window_width: u32,
pub window_height: u32,
pub screenshot_on_failure: bool,
screenshot_dir: String,
}
impl E2EConfig {
pub fn browser(&self) -> BrowserType {
self.browser
}
pub fn headless(&self) -> bool {
self.headless
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn screenshot_dir(&self) -> &str {
&self.screenshot_dir
}
}
impl Default for E2EConfig {
fn default() -> Self {
Self {
browser: BrowserType::Chrome,
headless: true,
timeout: Duration::from_secs(30),
window_width: 1920,
window_height: 1080,
screenshot_on_failure: true,
screenshot_dir: "./test-screenshots".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub enum Locator {
Css(String),
XPath(String),
Id(String),
}
impl Locator {
pub fn css(selector: &str) -> Self {
Self::Css(selector.to_string())
}
pub fn xpath(expr: &str) -> Self {
Self::XPath(expr.to_string())
}
pub fn id(id: &str) -> Self {
Self::Id(id.to_string())
}
pub fn as_selector(&self) -> &str {
match self {
Self::Css(s) | Self::XPath(s) | Self::Id(s) => s,
}
}
}
#[derive(Debug, Clone)]
pub enum Action {
Click(Locator),
SendKeys(String),
Pause(Duration),
}
impl Action {
pub fn description(&self) -> String {
match self {
Self::Click(loc) => format!("click on {}", loc.as_selector()),
Self::SendKeys(text) => format!("send keys: {text}"),
Self::Pause(dur) => format!("pause for {dur:?}"),
}
}
}
pub struct ActionChain {
actions: Vec<Action>,
}
impl ActionChain {
pub const fn new() -> Self {
Self {
actions: Vec::new(),
}
}
pub fn click(mut self, locator: Locator) -> Self {
self.actions.push(Action::Click(locator));
self
}
pub fn send_keys(mut self, text: &str) -> Self {
self.actions.push(Action::SendKeys(text.to_string()));
self
}
pub fn pause(mut self, duration: Duration) -> Self {
self.actions.push(Action::Pause(duration));
self
}
pub fn actions(&self) -> &[Action] {
&self.actions
}
}
impl Default for ActionChain {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: Option<String>,
pub path: Option<String>,
pub secure: Option<bool>,
pub http_only: Option<bool>,
}
impl Cookie {
pub fn new(name: &str, value: &str) -> Self {
Self {
name: name.to_string(),
value: value.to_string(),
domain: None,
path: None,
secure: None,
http_only: None,
}
}
pub fn with_domain(mut self, domain: &str) -> Self {
self.domain = Some(domain.to_string());
self
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
pub const fn secure(mut self) -> Self {
self.secure = Some(true);
self
}
pub const fn http_only(mut self) -> Self {
self.http_only = Some(true);
self
}
}
pub struct LoginPage {
base_url: String,
}
impl LoginPage {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn url_pattern() -> &'static str {
"/login"
}
pub fn email_input() -> Locator {
Locator::id("email")
}
pub fn password_input() -> Locator {
Locator::id("password")
}
pub fn login_button() -> Locator {
Locator::css("button[type='submit']")
}
pub fn error_message() -> Locator {
Locator::css(".error-message")
}
}
pub struct DashboardPage {
base_url: String,
}
impl DashboardPage {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn url_pattern() -> &'static str {
"/dashboard"
}
}
pub struct ChatPage {
base_url: String,
bot_name: String,
}
impl ChatPage {
pub fn new(base_url: &str, bot_name: &str) -> Self {
Self {
base_url: base_url.to_string(),
bot_name: bot_name.to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn bot_name(&self) -> &str {
&self.bot_name
}
pub fn url_pattern() -> &'static str {
"/chat/"
}
pub fn chat_input() -> Locator {
Locator::id("chat-input")
}
pub fn send_button() -> Locator {
Locator::css("button.send-message")
}
pub fn bot_message() -> Locator {
Locator::css(".message.bot")
}
pub fn typing_indicator() -> Locator {
Locator::css(".typing-indicator")
}
}
pub struct QueuePage {
base_url: String,
}
impl QueuePage {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn url_pattern() -> &'static str {
"/queue"
}
pub fn queue_panel() -> Locator {
Locator::css(".queue-panel")
}
pub fn queue_count() -> Locator {
Locator::css(".queue-count")
}
pub fn take_next_button() -> Locator {
Locator::css("button.take-next")
}
}
pub struct BotManagementPage {
base_url: String,
}
impl BotManagementPage {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn url_pattern() -> &'static str {
"/admin/bots"
}
}
#[test]
fn test_browser_config_builder() {
let config = BrowserConfig::new()
.with_browser(BrowserType::Firefox)
.with_debug_port(9333)
.headless(false)
.with_window_size(1280, 720)
.with_timeout(Duration::from_secs(60));
assert_eq!(config.browser_type, BrowserType::Firefox);
assert_eq!(config.debug_port, 9333);
assert!(!config.headless);
assert_eq!(config.window_width, 1280);
assert_eq!(config.window_height, 720);
assert_eq!(config.timeout, Duration::from_secs(60));
}
#[test]
fn test_locator_constructors() {
let css = Locator::css(".my-class");
assert!(matches!(css, Locator::Css(_)));
assert_eq!(css.as_selector(), ".my-class");
let xpath = Locator::xpath("//div[@id='test']");
assert!(matches!(xpath, Locator::XPath(_)));
assert_eq!(xpath.as_selector(), "//div[@id='test']");
let id = Locator::id("my-id");
assert!(matches!(id, Locator::Id(_)));
assert_eq!(id.as_selector(), "my-id");
}
#[test]
fn test_action_chain() {
let chain = ActionChain::new()
.click(Locator::id("button"))
.send_keys("Hello")
.pause(Duration::from_millis(500));
assert_eq!(chain.actions().len(), 3);
for action in chain.actions() {
let _ = action.description();
}
}
#[test]
fn test_cookie_builder() {
let cookie = Cookie::new("session", "abc123")
.with_domain("example.com")
.with_path("/")
.secure()
.http_only();
assert_eq!(cookie.name, "session");
assert_eq!(cookie.value, "abc123");
assert_eq!(cookie.domain, Some("example.com".to_string()));
assert!(cookie.secure.unwrap());
assert!(cookie.http_only.unwrap());
}
#[test]
fn test_login_page_locators() {
let _ = LoginPage::email_input();
let _ = LoginPage::password_input();
let _ = LoginPage::login_button();
let _ = LoginPage::error_message();
}
#[test]
fn test_chat_page_locators() {
let _ = ChatPage::chat_input();
let _ = ChatPage::send_button();
let _ = ChatPage::bot_message();
let _ = ChatPage::typing_indicator();
}
#[test]
fn test_queue_page_locators() {
let _ = QueuePage::queue_panel();
let _ = QueuePage::queue_count();
let _ = QueuePage::take_next_button();
}
#[test]
fn test_page_url_patterns() {
let login = LoginPage::new("http://localhost:4242");
assert_eq!(LoginPage::url_pattern(), "/login");
assert_eq!(login.base_url(), "http://localhost:4242");
let dashboard = DashboardPage::new("http://localhost:4242");
assert_eq!(DashboardPage::url_pattern(), "/dashboard");
assert_eq!(dashboard.base_url(), "http://localhost:4242");
let chat = ChatPage::new("http://localhost:4242", "test-bot");
assert_eq!(ChatPage::url_pattern(), "/chat/");
assert_eq!(chat.base_url(), "http://localhost:4242");
assert_eq!(chat.bot_name(), "test-bot");
let queue = QueuePage::new("http://localhost:4242");
assert_eq!(QueuePage::url_pattern(), "/queue");
assert_eq!(queue.base_url(), "http://localhost:4242");
let bots = BotManagementPage::new("http://localhost:4242");
assert_eq!(BotManagementPage::url_pattern(), "/admin/bots");
assert_eq!(bots.base_url(), "http://localhost:4242");
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("My App Name"), "my-app-name");
assert_eq!(slugify("Test123"), "test123");
assert_eq!(slugify(" Spaces "), "spaces");
}
#[test]
fn test_web_app_template_default() {
let template = WebAppTemplate::default();
assert!(matches!(template, WebAppTemplate::Blank));
}
#[test]
fn test_web_app_status_default() {
let status = WebAppStatus::default();
assert!(matches!(status, WebAppStatus::Draft));
}
#[test]
fn test_web_app_config_default() {
let config = WebAppConfig::default();
assert!(!config.auth_required);
assert!(config.custom_domain.is_none());
assert!(config.meta_tags.is_empty());
assert!(config.scripts.is_empty());
assert!(config.styles.is_empty());
}
#[test]
fn test_component_types() {
let container = ComponentType::Container;
let text = ComponentType::Text;
let button = ComponentType::Button;
let custom = ComponentType::Custom("MyWidget".to_string());
assert!(matches!(container, ComponentType::Container));
assert!(matches!(text, ComponentType::Text));
assert!(matches!(button, ComponentType::Button));
assert!(matches!(custom, ComponentType::Custom(_)));
}
#[test]
fn test_create_app_request() {
let request = CreateAppRequest {
name: "My Test App".to_string(),
description: Some("A test application".to_string()),
template: Some(WebAppTemplate::Dashboard),
};
assert_eq!(request.name, "My Test App");
assert!(request.description.is_some());
assert!(matches!(request.template, Some(WebAppTemplate::Dashboard)));
}
#[test]
fn test_create_page_request() {
let request = CreatePageRequest {
path: "/about".to_string(),
title: "About Us".to_string(),
content: "<h1>About</h1>".to_string(),
layout: Some("default".to_string()),
is_index: false,
};
assert_eq!(request.path, "/about");
assert!(!request.is_index);
}
#[test]
fn test_render_html_basic() {
let app = WebApp {
id: Uuid::new_v4(),
name: "Test App".to_string(),
slug: "test-app".to_string(),
description: None,
template: WebAppTemplate::Blank,
status: WebAppStatus::Published,
config: WebAppConfig::default(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let html = render_html(&app, "<p>Hello</p>");
assert!(html.contains("Test App"));
assert!(html.contains("<p>Hello</p>"));
assert!(html.contains("<!DOCTYPE html>"));
}
#[test]
fn test_render_html_with_meta_tags() {
let mut config = WebAppConfig::default();
config
.meta_tags
.insert("description".to_string(), "A test page".to_string());
config
.meta_tags
.insert("author".to_string(), "Test Author".to_string());
let app = WebApp {
id: Uuid::new_v4(),
name: "Meta Test".to_string(),
slug: "meta-test".to_string(),
description: None,
template: WebAppTemplate::Blank,
status: WebAppStatus::Published,
config,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let html = render_html(&app, "");
assert!(html.contains("meta name="));
}
#[test]
fn test_weba_state_creation() {
let state = WebaState::new();
let _ = &state;
}
#[test]
fn test_list_query_defaults() {
let query = ListQuery {
limit: None,
offset: None,
status: None,
};
assert!(query.limit.is_none());
assert!(query.offset.is_none());
assert!(query.status.is_none());
}
#[test]
fn test_list_query_with_values() {
let query = ListQuery {
limit: Some(10),
offset: Some(20),
status: Some("published".to_string()),
};
assert_eq!(query.limit, Some(10));
assert_eq!(query.offset, Some(20));
assert_eq!(query.status, Some("published".to_string()));
}
}