Compare commits

..

14 commits

Author SHA1 Message Date
root
68542cd8ff Fix invalid botserver dependency in bottest/Cargo.toml
Remove botserver dependency since it's a binary-only crate with no lib target.
Integration tests should spawn the botserver process instead of linking it as a library.
This fixes the warning: 'ignoring invalid dependency botserver which is missing a lib target'
2026-02-06 22:12:08 +00:00
root
9e9b789d46 Fix invalid botserver dependency in bottest/Cargo.toml
Remove botserver dependency since it's a binary-only crate with no lib target.
Integration tests should spawn the botserver process instead of linking it as a library.
This fixes the warning: 'ignoring invalid dependency botserver which is missing a lib target'
2026-02-06 22:11:55 +00:00
74e761de0d Update: delete PROMPT.md and add README.md 2026-02-04 13:54:25 -03:00
51458e391d chore: update test deps 2026-01-25 08:42:49 -03:00
4bd21d91f5 Update tests 2026-01-24 22:06:20 -03:00
20176595b9 Refactor: Use workspace dependencies 2026-01-23 09:37:46 -03:00
899b433529 Update PROMPT.md 2026-01-22 20:24:08 -03:00
05446c6716 Remove botserver dependency - use HTTP for integration tests 2026-01-14 12:36:26 -03:00
1232b2fc65 Test framework improvements and fixture updates 2025-12-28 11:51:00 -03:00
b38574c588 Update test structure and cleanup deprecated tests 2025-12-26 08:59:54 -03:00
56334dd7b1 Update unit tests for basic keywords 2025-12-24 09:29:28 -03:00
3d0a9a843d Remove all code comments 2025-12-23 18:41:29 -03:00
8593b861b5 Add comprehensive unit tests and update test framework 2025-12-23 15:52:52 -03:00
7c6c48be3a Update bottest 2025-12-21 23:40:44 -03:00
40 changed files with 1508 additions and 3704 deletions

View file

@ -5,82 +5,58 @@ edition = "2021"
description = "Comprehensive test suite for General Bots - Unit, Integration, and E2E testing"
license = "AGPL-3.0"
repository = "https://github.com/GeneralBots/BotServer"
readme = "README.md"
keywords = ["testing", "bot", "integration-testing", "e2e", "general-bots"]
categories = ["development-tools::testing"]
[dependencies]
# The server we're testing - include drive and cache for required deps
botserver = { path = "../botserver", default-features = false, features = ["chat", "llm", "automation", "tasks", "directory", "drive", "cache"] }
botlib = { path = "../botlib", features = ["database"] }
# Async runtime
tokio = { version = "1.41", features = ["full", "test-util", "macros"] }
async-trait = "0.1"
futures = "0.3"
futures-util = "0.3"
tokio = { workspace = true, features = ["full", "test-util", "macros"] }
async-trait = { workspace = true }
futures = { workspace = true }
# Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel_migrations = "2.1.0"
diesel = { workspace = true, features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
# HTTP mocking and testing
wiremock = "0.6"
cookie = "0.18"
mockito = "1.7"
reqwest = { version = "0.12", features = ["json", "cookies", "blocking"] }
wiremock = { workspace = true }
cookie = { workspace = true }
reqwest = { workspace = true, features = ["json", "cookies", "blocking", "rustls-tls"] }
# Web/E2E testing - using Chrome DevTools Protocol directly (no chromedriver)
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false }
# Web framework for test server
axum = { version = "0.7.5", features = ["ws", "multipart", "macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
hyper = { version = "0.14", features = ["full"] }
chromiumoxide = { workspace = true }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# Utilities
uuid = { version = "1.11", features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.9"
tempfile = "3"
which = "7"
regex = "1.11"
base64 = "0.22"
url = "2.5"
dirs = "5.0"
uuid = { workspace = true, features = ["serde", "v4"] }
chrono = { workspace = true, features = ["serde"] }
which = { workspace = true }
regex = { workspace = true }
# Process management for services
nix = { version = "0.29", features = ["signal", "process"] }
nix = { workspace = true }
# Archive extraction
zip = "2.2"
zip = { workspace = true }
# Logging and tracing
log = "0.4"
env_logger = "0.11"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
log = { workspace = true }
env_logger = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] }
# Error handling
anyhow = "1.0"
thiserror = "2.0"
anyhow = { workspace = true }
# Test framework enhancements
pretty_assertions = "1.4"
# Async testing
tokio-test = "0.4"
# Rhai for BASIC function testing
rhai = { git = "https://github.com/therealprof/rhai.git", branch = "features/use-web-time", features = ["sync"] }
# Configuration
dotenvy = "0.15"
rhai = { workspace = true, features = ["sync"] }
[dev-dependencies]
insta = { version = "1.40", features = ["json", "yaml"] }
insta = { workspace = true }
[features]
default = ["full"]
@ -105,3 +81,6 @@ required-features = ["e2e"]
[[bin]]
name = "bottest"
path = "src/main.rs"
[lints]
workspace = true

216
PROMPT.md
View file

@ -1,216 +0,0 @@
# BotTest Development Prompt
**Version:** 6.1.0
**Purpose:** Test infrastructure for General Bots ecosystem
---
## Weekly Maintenance - EVERY MONDAY
### Package Review Checklist
**Every Monday, review the following:**
1. **Dependency Updates**
```bash
cargo outdated
cargo audit
```
2. **Package Consolidation Opportunities**
- Check if new crates can replace custom code
- Look for crates that combine multiple dependencies
- Review `Cargo.toml` for redundant dependencies
3. **Code Reduction Candidates**
- Custom mock implementations that can use crates
- Test utilities that have crate equivalents
- Boilerplate that can be replaced with macros
4. **Test Infrastructure Updates**
- Check for new testing patterns
- Review mock server libraries
- Update fixture generation approaches
### Packages to Watch
| Area | Potential Packages | Purpose |
|------|-------------------|---------|
| Mocking | `wiremock`, `mockall` | Simplify mock creation |
| Assertions | `assertables`, `pretty_assertions` | Better test output |
| Fixtures | `fake`, `proptest` | Generate test data |
| Async Testing | `tokio-test` | Async test utilities |
---
## CRITICAL RULE
🚫 **NO .md FILES IN ROOT OF ANY PROJECT**
All documentation goes in `botbook/src/17-testing/`:
- `README.md` - Testing overview
- `e2e-testing.md` - E2E test guide
- `architecture.md` - Testing architecture
- `performance.md` - Performance testing
- `best-practices.md` - Best practices
This PROMPT.md is the ONLY exception (it's for developers).
---
## Core Principle
**Reuse botserver bootstrap code** - Don't duplicate installation logic. The bootstrap module already knows how to install PostgreSQL, MinIO, Redis. We wrap it with test-specific configuration (custom ports, temp directories).
---
## Architecture
**IMPORTANT:** E2E tests always use `USE_BOTSERVER_BOOTSTRAP=1` mode. No global PostgreSQL or other services are required. The botserver handles all service installation during bootstrap.
```
TestHarness::full() / E2E Tests
├── Allocate unique ports (15000+)
├── Create ./tmp/bottest-{uuid}/
├── Start mock servers only
│ ├── MockZitadel (wiremock)
│ └── MockLLM (wiremock)
├── Start botserver with --stack-path
│ └── Botserver auto-installs:
│ ├── PostgreSQL (tables)
│ ├── MinIO (drive)
│ └── Redis (cache)
└── Return TestContext
TestContext provides:
- db_pool() -> Database connection
- minio_client() -> S3 client
- redis_client() -> Redis client
- mock_*() -> Mock server controls
On Drop:
- Stop all services
- Remove temp directory
```
---
## Code Style
Same as botserver PROMPT.md:
- KISS, NO TALK, SECURED CODE ONLY
- No comments, no placeholders
- Complete, production-ready code
- Return 0 warnings
---
## Test Categories
### Unit Tests (no services)
```rust
#[test]
fn test_pure_logic() {
// No TestHarness needed
// Test pure functions directly
}
```
### Integration Tests (with services)
```rust
#[tokio::test]
async fn test_with_database() {
let ctx = TestHarness::quick().await.unwrap();
let pool = ctx.db_pool().await.unwrap();
// Use real database
}
```
### E2E Tests (with browser)
```rust
#[tokio::test]
async fn test_user_flow() {
let ctx = TestHarness::full().await.unwrap();
let server = ctx.start_botserver().await.unwrap();
let browser = Browser::new().await.unwrap();
// Automate browser
}
```
---
## Mock Server Patterns
### Expect specific calls
```rust
ctx.mock_llm().expect_completion("hello", "Hi there!");
```
### Verify calls were made
```rust
ctx.mock_llm().assert_called_times(2);
```
### Simulate errors
```rust
ctx.mock_llm().next_call_fails(500, "Internal error");
```
---
## Fixture Patterns
### Factory functions
```rust
let user = fixtures::admin_user();
let bot = fixtures::bot_with_kb();
let session = fixtures::active_session(&user, &bot);
```
### Insert into database
```rust
ctx.insert(&user).await;
ctx.insert(&bot).await;
```
---
## Cleanup
Always automatic via Drop trait. But can force:
```rust
ctx.cleanup().await; // Explicit cleanup
```
---
## Parallel Safety
- Each test gets unique ports via PortAllocator
- Each test gets unique temp directory
- No shared state between tests
- Safe to run with `cargo test -j 8`
---
## Documentation Location
For guides, tutorials, and reference:
→ Use `botbook/src/17-testing/`
Examples:
- E2E testing setup → `botbook/src/17-testing/e2e-testing.md`
- Architecture details → `botbook/src/17-testing/architecture.md`
- Performance tips → `botbook/src/17-testing/performance.md`
Never create .md files at:
- ✗ Root of bottest/
- ✗ Root of botserver/
- ✗ Root of botapp/
- ✗ Any project root
All non-PROMPT.md documentation belongs in botbook.

310
README.md Normal file
View file

@ -0,0 +1,310 @@
# Bottest - General Bots Test Infrastructure
**Version:** 6.2.0
**Purpose:** Test infrastructure for General Bots ecosystem
---
## Overview
Bottest provides the comprehensive testing infrastructure for the General Bots ecosystem, including unit tests, integration tests, and end-to-end (E2E) tests. It ensures code quality, reliability, and correct behavior across all components of the platform.
The test harness handles service orchestration, mock servers, fixtures, and browser automation, making it easy to write comprehensive tests that cover the entire system from database operations to full user flows.
For comprehensive documentation, see **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** or the **[BotBook](../botbook/src/17-testing)** for detailed guides and testing best practices.
---
## 🏗️ Testing Architecture
E2E tests use `USE_BOTSERVER_BOOTSTRAP=1` mode. The botserver handles all service installation during bootstrap.
```
TestHarness::full() / E2E Tests
├── Allocate unique ports (15000+)
├── Create ./tmp/bottest-{uuid}/
├── Start mock servers only
│ ├── MockZitadel (wiremock)
│ └── MockLLM (wiremock)
├── Start botserver with --stack-path
│ └── Botserver auto-installs:
│ ├── PostgreSQL (tables)
│ ├── MinIO (drive)
│ └── Redis (cache)
└── Return TestContext
```
---
## 🧪 Test Categories
### Unit Tests (no services)
```rust
#[test]
fn test_pure_logic() {
// No TestHarness needed
assert_eq!(add(2, 3), 5);
}
```
### Integration Tests (with services)
```rust
#[tokio::test]
async fn test_with_database() {
let ctx = TestHarness::quick().await?;
let pool = ctx.db_pool().await?;
// Use real database
let user = fixtures::admin_user();
ctx.insert(&user).await;
// Test database operations
}
```
### E2E Tests (with browser)
```rust
#[tokio::test]
async fn test_user_flow() {
let ctx = TestHarness::full().await?;
let server = ctx.start_botserver().await?;
let browser = Browser::new().await?;
// Automate browser
browser.goto(server.url()).await?;
browser.click("#login-button").await?;
// Verify user flow
assert!(browser.is_visible("#dashboard").await?);
}
```
---
## 🎭 Mock Server Patterns
### Expect specific calls
```rust
ctx.mock_llm().expect_completion("hello", "Hi there!");
```
### Verify calls were made
```rust
ctx.mock_llm().assert_called_times(2);
```
### Simulate errors
```rust
ctx.mock_llm().next_call_fails(500, "Internal error");
```
### Mock authentication
```rust
ctx.mock_zitadel().expect_login_success("user@example.com", "password");
```
---
## 🏭 Fixture Patterns
### Factory functions
```rust
let user = fixtures::admin_user();
let bot = fixtures::bot_with_kb();
let session = fixtures::active_session(&user, &bot);
```
### Insert into database
```rust
ctx.insert(&user).await;
ctx.insert(&bot).await;
ctx.insert(&session).await;
```
### Custom fixtures
```rust
fn custom_bot() -> Bot {
Bot {
name: "Test Bot".to_string(),
enabled: true,
..fixtures::base_bot()
}
}
```
---
## ⚡ Parallel Safety
- Each test gets unique ports via PortAllocator
- Each test gets unique temp directory
- No shared state between tests
- Safe to run with `cargo test -j 8`
---
## ✅ ZERO TOLERANCE POLICY
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
### Absolute Prohibitions
```
❌ NEVER use #![allow()] or #[allow()] in source code
❌ NEVER use _ prefix for unused variables - DELETE or USE them
❌ NEVER use .unwrap() - use ? or proper error handling
❌ NEVER use .expect() - use ? or proper error handling
❌ NEVER use panic!() or unreachable!()
❌ NEVER use todo!() or unimplemented!()
❌ NEVER leave unused imports or dead code
❌ NEVER add comments - code must be self-documenting
```
### Code Patterns
```rust
// ❌ WRONG
let value = something.unwrap();
// ✅ CORRECT
let value = something?;
let value = something.ok_or_else(|| Error::NotFound)?;
// Use Self in Impl Blocks
impl TestStruct {
fn new() -> Self { Self { } } // ✅ Not TestStruct
}
// Derive Eq with PartialEq
#[derive(PartialEq, Eq)] // ✅ Always both
struct TestStruct { }
// Inline Format Args
format!("Hello {name}") // ✅ Not format!("{}", name)
```
---
## 🚀 Running Tests
### Run all tests
```bash
cargo test -p bottest
```
### Run specific test category
```bash
# Unit tests only
cargo test -p bottest --lib
# Integration tests
cargo test -p bottest --test '*'
# E2E tests only
cargo test -p bottest --test '*' -- --ignored
```
### Run tests with output
```bash
cargo test -p bottest -- --nocapture
```
### Run tests in parallel
```bash
cargo test -p bottest -j 8
```
---
## 📁 Project Structure
```
bottest/
├── src/
│ ├── lib.rs # Test harness exports
│ ├── harness.rs # TestHarness implementation
│ ├── context.rs # TestContext for resource access
│ ├── mocks/ # Mock server implementations
│ │ ├── zitadel.rs
│ │ └── llm.rs
│ ├── fixtures.rs # Factory functions
│ └── utils.rs # Testing utilities
├── tests/ # Integration and E2E tests
│ ├── integration/
│ │ ├── database_tests.rs
│ │ └── api_tests.rs
│ └── e2e/
│ └── user_flows.rs
└── Cargo.toml
```
---
## 📚 Documentation
### Testing Documentation
All testing documentation is located in `botbook/src/17-testing/`:
- **README.md** - Testing overview and philosophy
- **e2e-testing.md** - E2E test guide with examples
- **architecture.md** - Testing architecture and design
- **best-practices.md** - Best practices and patterns
- **mock-servers.md** - Mock server configuration
- **fixtures.md** - Fixture usage and creation
### Additional Resources
- **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** - Full online documentation
- **[BotBook](../botbook)** - Local comprehensive guide
- **[Testing Best Practices](../botbook/src/17-testing/best-practices.md)** - Detailed testing guidelines
---
## 🔗 Related Projects
| Project | Description |
|---------|-------------|
| [botserver](https://github.com/GeneralBots/botserver) | Main API server (tested) |
| [botui](https://github.com/GeneralBots/botui) | Web UI (E2E tested) |
| [botlib](https://github.com/GeneralBots/botlib) | Shared library |
| [botbook](https://github.com/GeneralBots/botbook) | Documentation |
---
## 🔑 Remember
- **ZERO WARNINGS** - Every clippy warning must be fixed
- **NO ALLOW ATTRIBUTES** - Never silence warnings
- **NO DEAD CODE** - Delete unused code
- **NO UNWRAP/EXPECT** - Use ? operator
- **INLINE FORMAT ARGS** - `format!("{name}")` not `format!("{}", name)`
- **USE SELF** - In impl blocks, use Self not type name
- **Reuse bootstrap** - Don't duplicate botserver installation logic
- **Parallel safe** - Each test gets unique ports and directories
- **Version 6.2.0** - Do not change without approval
- **GIT WORKFLOW** - ALWAYS push to ALL repositories (github, pragmatismo)
---
## 📄 License
AGPL-3.0 - See [LICENSE](LICENSE) for details.

View file

@ -38,7 +38,7 @@ impl ConversationBuilder {
self
}
pub fn on_channel(mut self, channel: Channel) -> Self {
pub const fn on_channel(mut self, channel: Channel) -> Self {
self.channel = channel;
self
}
@ -48,7 +48,7 @@ impl ConversationBuilder {
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.config.response_timeout = timeout;
self
}
@ -58,12 +58,12 @@ impl ConversationBuilder {
self
}
pub fn without_recording(mut self) -> Self {
pub const fn without_recording(mut self) -> Self {
self.config.record = false;
self
}
pub fn with_real_llm(mut self) -> Self {
pub const fn with_real_llm(mut self) -> Self {
self.config.use_mock_llm = false;
self
}
@ -131,13 +131,13 @@ impl ConversationTest {
ConversationBuilder::new(bot_name).build()
}
pub async fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
pub fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
let mut conv = ConversationBuilder::new(bot_name).build();
conv.llm_url = Some(ctx.llm_url());
Ok(conv)
}
pub fn id(&self) -> Uuid {
pub const fn id(&self) -> Uuid {
self.id
}
@ -145,15 +145,15 @@ impl ConversationTest {
&self.bot_name
}
pub fn customer(&self) -> &Customer {
pub const fn customer(&self) -> &Customer {
&self.customer
}
pub fn channel(&self) -> Channel {
pub const fn channel(&self) -> Channel {
self.channel
}
pub fn state(&self) -> ConversationState {
pub const fn state(&self) -> ConversationState {
self.state
}
@ -165,15 +165,15 @@ impl ConversationTest {
&self.sent_messages
}
pub fn last_response(&self) -> Option<&BotResponse> {
pub const fn last_response(&self) -> Option<&BotResponse> {
self.last_response.as_ref()
}
pub fn last_latency(&self) -> Option<Duration> {
pub const fn last_latency(&self) -> Option<Duration> {
self.last_latency
}
pub fn record(&self) -> &ConversationRecord {
pub const fn record(&self) -> &ConversationRecord {
&self.record
}
@ -203,7 +203,7 @@ impl ConversationTest {
self.record.messages.push(RecordedMessage {
timestamp: Utc::now(),
direction: MessageDirection::Outgoing,
content: response.content.clone(),
content: response.content,
latency_ms: Some(latency.as_millis() as u64),
});
}
@ -242,7 +242,7 @@ impl ConversationTest {
BotResponse {
id: Uuid::new_v4(),
content: format!("Response to: {}", user_message),
content: format!("Response to: {user_message}"),
content_type: ResponseContentType::Text,
metadata: self.build_response_metadata(),
latency_ms: start.elapsed().as_millis() as u64,
@ -269,7 +269,7 @@ impl ConversationTest {
});
let response = client
.post(format!("{}/v1/chat/completions", llm_url))
.post(format!("{llm_url}/v1/chat/completions"))
.header("Content-Type", "application/json")
.json(&request_body)
.send()
@ -306,13 +306,13 @@ impl ConversationTest {
metadata
}
pub async fn assert_response_contains(&mut self, text: &str) -> &mut Self {
pub fn assert_response_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) {
AssertionResult::pass(&format!("Response contains '{}'", text))
AssertionResult::pass(&format!("Response contains '{text}'"))
} else {
AssertionResult::fail(
&format!("Response should contain '{}'", text),
&format!("Response should contain '{text}'"),
text,
&response.content,
)
@ -325,10 +325,10 @@ impl ConversationTest {
self
}
pub async fn assert_response_equals(&mut self, text: &str) -> &mut Self {
pub fn assert_response_equals(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content == text {
AssertionResult::pass(&format!("Response equals '{}'", text))
AssertionResult::pass(&format!("Response equals '{text}'"))
} else {
AssertionResult::fail(
"Response should equal expected text",
@ -344,22 +344,22 @@ impl ConversationTest {
self
}
pub async fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
pub fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
match regex::Regex::new(pattern) {
Ok(re) => {
if re.is_match(&response.content) {
AssertionResult::pass(&format!("Response matches pattern '{}'", pattern))
AssertionResult::pass(&format!("Response matches pattern '{pattern}'"))
} else {
AssertionResult::fail(
&format!("Response should match pattern '{}'", pattern),
&format!("Response should match pattern '{pattern}'"),
pattern,
&response.content,
)
}
}
Err(e) => AssertionResult::fail(
&format!("Invalid regex pattern: {}", e),
&format!("Invalid regex pattern: {e}"),
pattern,
"<invalid pattern>",
),
@ -372,16 +372,16 @@ impl ConversationTest {
self
}
pub async fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
pub fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if !response.content.contains(text) {
AssertionResult::pass(&format!("Response does not contain '{}'", text))
} else {
if response.content.contains(text) {
AssertionResult::fail(
&format!("Response should not contain '{}'", text),
&format!("not containing '{}'", text),
&format!("Response should not contain '{text}'"),
&format!("not containing '{text}'"),
&response.content,
)
} else {
AssertionResult::pass(&format!("Response does not contain '{text}'"))
}
} else {
AssertionResult::pass("No response (nothing to contain)")
@ -391,17 +391,13 @@ impl ConversationTest {
self
}
pub async fn assert_transferred_to_human(&mut self) -> &mut Self {
pub fn assert_transferred_to_human(&mut self) -> &mut Self {
let is_transferred = self.state == ConversationState::Transferred
|| self
.last_response
.as_ref()
.map(|r| {
r.content.to_lowercase().contains("transfer")
|| r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent")
})
.unwrap_or(false);
|| self.last_response.as_ref().is_some_and(|r| {
r.content.to_lowercase().contains("transfer")
|| r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent")
});
let result = if is_transferred {
self.state = ConversationState::Transferred;
@ -418,15 +414,15 @@ impl ConversationTest {
self
}
pub async fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
pub fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
let actual = self
.context
.get("queue_position")
.and_then(|v| v.as_u64())
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize;
let result = if actual == expected {
AssertionResult::pass(&format!("Queue position is {}", expected))
AssertionResult::pass(&format!("Queue position is {expected}"))
} else {
AssertionResult::fail(
"Queue position mismatch",
@ -439,21 +435,21 @@ impl ConversationTest {
self
}
pub async fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
pub fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
let result = if let Some(latency) = self.last_latency {
if latency <= max_duration {
AssertionResult::pass(&format!("Response within {:?}", max_duration))
AssertionResult::pass(&format!("Response within {max_duration:?}"))
} else {
AssertionResult::fail(
"Response too slow",
&format!("{:?}", max_duration),
&format!("{:?}", latency),
&format!("{max_duration:?}"),
&format!("{latency:?}"),
)
}
} else {
AssertionResult::fail(
"No latency recorded",
&format!("{:?}", max_duration),
&format!("{max_duration:?}"),
"<no latency>",
)
};
@ -462,11 +458,11 @@ impl ConversationTest {
self
}
pub async fn assert_response_count(&mut self, expected: usize) -> &mut Self {
pub fn assert_response_count(&mut self, expected: usize) -> &mut Self {
let actual = self.responses.len();
let result = if actual == expected {
AssertionResult::pass(&format!("Response count is {}", expected))
AssertionResult::pass(&format!("Response count is {expected}"))
} else {
AssertionResult::fail(
"Response count mismatch",
@ -479,21 +475,21 @@ impl ConversationTest {
self
}
pub async fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
pub fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
let result = if let Some(ref response) = self.last_response {
if response.content_type == expected {
AssertionResult::pass(&format!("Response type is {:?}", expected))
AssertionResult::pass(&format!("Response type is {expected:?}"))
} else {
AssertionResult::fail(
"Response type mismatch",
&format!("{:?}", expected),
&format!("{expected:?}"),
&format!("{:?}", response.content_type),
)
}
} else {
AssertionResult::fail(
"No response to check",
&format!("{:?}", expected),
&format!("{expected:?}"),
"<no response>",
)
};
@ -511,13 +507,13 @@ impl ConversationTest {
self.context.get(key)
}
pub async fn end(&mut self) -> &mut Self {
pub fn end(&mut self) -> &mut Self {
self.state = ConversationState::Ended;
self.record.ended_at = Some(Utc::now());
self
}
pub fn all_passed(&self) -> bool {
pub const fn all_passed(&self) -> bool {
self.record.passed
}
@ -584,7 +580,7 @@ mod tests {
async fn test_assert_response_contains() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
assert!(conv.all_passed());
}
@ -593,7 +589,7 @@ mod tests {
async fn test_assert_response_not_contains() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_not_contains("nonexistent").await;
conv.assert_response_not_contains("nonexistent");
assert!(conv.all_passed());
}
@ -633,7 +629,7 @@ mod tests {
async fn test_end_conversation() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("bye").await;
conv.end().await;
conv.end();
assert_eq!(conv.state(), ConversationState::Ended);
assert!(conv.record().ended_at.is_some());
@ -643,7 +639,7 @@ mod tests {
async fn test_failed_assertions() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await;
conv.assert_response_equals("this will not match").await;
conv.assert_response_equals("this will not match");
assert!(!conv.all_passed());
assert_eq!(conv.failed_assertions().len(), 1);
@ -673,13 +669,13 @@ mod tests {
let mut conv = ConversationTest::new("support-bot");
conv.user_says("Hi").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
conv.user_says("I need help").await;
conv.assert_response_contains("Response").await;
conv.assert_response_contains("Response");
conv.user_says("Thanks, bye").await;
conv.end().await;
conv.end();
assert_eq!(conv.sent_messages().len(), 3);
assert_eq!(conv.responses().len(), 3);
@ -690,7 +686,7 @@ mod tests {
async fn test_response_time_assertion() {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("quick test").await;
conv.assert_response_within(Duration::from_secs(5)).await;
conv.assert_response_within(Duration::from_secs(5));
assert!(conv.all_passed());
}
@ -700,7 +696,7 @@ mod tests {
let mut conv = ConversationTest::new("test-bot");
conv.user_says("one").await;
conv.user_says("two").await;
conv.assert_response_count(2).await;
conv.assert_response_count(2);
assert!(conv.all_passed());
}

View file

@ -1,7 +1,3 @@
//! Bot conversation testing module
//!
//! Provides tools for simulating and testing bot conversations
//! including message exchanges, flow validation, and response assertions.
mod conversation;
mod runner;
@ -12,7 +8,6 @@ use std::collections::HashMap;
use std::time::Duration;
use uuid::Uuid;
/// Response from the bot
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotResponse {
pub id: Uuid,
@ -22,10 +17,11 @@ pub struct BotResponse {
pub latency_ms: u64,
}
/// Type of response content
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ResponseContentType {
#[default]
Text,
Image,
Audio,
@ -37,13 +33,7 @@ pub enum ResponseContentType {
Contact,
}
impl Default for ResponseContentType {
fn default() -> Self {
Self::Text
}
}
/// Assertion result for conversation tests
#[derive(Debug, Clone)]
pub struct AssertionResult {
pub passed: bool,
@ -53,6 +43,7 @@ pub struct AssertionResult {
}
impl AssertionResult {
#[must_use]
pub fn pass(message: &str) -> Self {
Self {
passed: true,
@ -62,6 +53,7 @@ impl AssertionResult {
}
}
#[must_use]
pub fn fail(message: &str, expected: &str, actual: &str) -> Self {
Self {
passed: false,
@ -72,16 +64,11 @@ impl AssertionResult {
}
}
/// Configuration for conversation tests
#[derive(Debug, Clone)]
pub struct ConversationConfig {
/// Maximum time to wait for a response
pub response_timeout: Duration,
/// Whether to record the conversation for later analysis
pub record: bool,
/// Whether to use the mock LLM
pub use_mock_llm: bool,
/// Custom variables to inject into the conversation
pub variables: HashMap<String, String>,
}
@ -96,7 +83,6 @@ impl Default for ConversationConfig {
}
}
/// Recorded conversation for analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationRecord {
pub id: Uuid,
@ -108,7 +94,6 @@ pub struct ConversationRecord {
pub passed: bool,
}
/// Recorded message in a conversation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedMessage {
pub timestamp: chrono::DateTime<chrono::Utc>,
@ -117,7 +102,6 @@ pub struct RecordedMessage {
pub latency_ms: Option<u64>,
}
/// Recorded assertion
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssertionRecord {
pub timestamp: chrono::DateTime<chrono::Utc>,
@ -126,28 +110,18 @@ pub struct AssertionRecord {
pub message: String,
}
/// State of a conversation flow
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
pub enum ConversationState {
/// Initial state, conversation not started
#[default]
Initial,
/// Waiting for user input
WaitingForUser,
/// Waiting for bot response
WaitingForBot,
/// Conversation transferred to human
Transferred,
/// Conversation ended normally
Ended,
/// Conversation ended with error
Error,
}
impl Default for ConversationState {
fn default() -> Self {
Self::Initial
}
}
#[cfg(test)]
mod tests {

View file

@ -1,8 +1,3 @@
//! Bot runner for executing tests
//!
//! Provides a test runner that can execute BASIC scripts and simulate
//! bot behavior for integration testing.
use super::{BotResponse, ConversationState, ResponseContentType};
use crate::fixtures::{Bot, Channel, Customer, Session};
use crate::harness::TestContext;
@ -13,20 +8,13 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use uuid::Uuid;
/// Configuration for the bot runner
#[derive(Debug, Clone)]
pub struct BotRunnerConfig {
/// Working directory for the bot
pub working_dir: PathBuf,
/// Maximum execution time for a single request
pub timeout: Duration,
/// Whether to use mock services
pub use_mocks: bool,
/// Environment variables to set
pub env_vars: HashMap<String, String>,
/// Whether to capture logs
pub capture_logs: bool,
/// Log level
pub log_level: LogLevel,
}
@ -43,23 +31,18 @@ impl Default for BotRunnerConfig {
}
}
/// Log level for bot runner
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
pub enum LogLevel {
Trace,
Debug,
#[default]
Info,
Warn,
Error,
}
impl Default for LogLevel {
fn default() -> Self {
Self::Info
}
}
/// Bot runner for executing bot scripts and simulating conversations
pub struct BotRunner {
config: BotRunnerConfig,
bot: Option<Bot>,
@ -68,7 +51,6 @@ pub struct BotRunner {
metrics: Arc<Mutex<RunnerMetrics>>,
}
/// Internal session state
struct SessionState {
session: Session,
customer: Customer,
@ -79,7 +61,6 @@ struct SessionState {
started_at: Instant,
}
/// Metrics collected by the runner
#[derive(Debug, Default, Clone)]
pub struct RunnerMetrics {
pub total_requests: u64,
@ -93,8 +74,7 @@ pub struct RunnerMetrics {
}
impl RunnerMetrics {
/// Get average latency in milliseconds
pub fn avg_latency_ms(&self) -> u64 {
pub const fn avg_latency_ms(&self) -> u64 {
if self.total_requests > 0 {
self.total_latency_ms / self.total_requests
} else {
@ -102,7 +82,6 @@ impl RunnerMetrics {
}
}
/// Get success rate as percentage
pub fn success_rate(&self) -> f64 {
if self.total_requests > 0 {
(self.successful_requests as f64 / self.total_requests as f64) * 100.0
@ -112,7 +91,6 @@ impl RunnerMetrics {
}
}
/// Result of a bot execution
#[derive(Debug, Clone)]
pub struct ExecutionResult {
pub session_id: Uuid,
@ -123,7 +101,6 @@ pub struct ExecutionResult {
pub error: Option<String>,
}
/// A log entry captured during execution
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: chrono::DateTime<chrono::Utc>,
@ -133,12 +110,10 @@ pub struct LogEntry {
}
impl BotRunner {
/// Create a new bot runner with default configuration
pub fn new() -> Self {
Self::with_config(BotRunnerConfig::default())
}
/// Create a new bot runner with custom configuration
pub fn with_config(config: BotRunnerConfig) -> Self {
Self {
config,
@ -149,19 +124,16 @@ impl BotRunner {
}
}
/// Create a bot runner with a test context
pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self {
Self::with_config(config)
}
/// Set the bot to run
pub fn set_bot(&mut self, bot: Bot) -> &mut Self {
self.bot = Some(bot);
self
}
/// Load a BASIC script
pub fn load_script(&mut self, name: &str, content: &str) -> &mut Self {
pub fn load_script(&self, name: &str, content: &str) -> &Self {
self.script_cache
.lock()
.unwrap()
@ -169,10 +141,9 @@ impl BotRunner {
self
}
/// Load a script from a file
pub fn load_script_file(&mut self, name: &str, path: &PathBuf) -> Result<&mut Self> {
pub fn load_script_file(&self, name: &str, path: &PathBuf) -> Result<&Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read script file: {:?}", path))?;
.with_context(|| format!("Failed to read script file: {}", path.display()))?;
self.script_cache
.lock()
.unwrap()
@ -180,10 +151,9 @@ impl BotRunner {
Ok(self)
}
/// Start a new session
pub fn start_session(&mut self, customer: Customer) -> Result<Uuid> {
pub fn start_session(&self, customer: Customer) -> Result<Uuid> {
let session_id = Uuid::new_v4();
let bot_id = self.bot.as_ref().map(|b| b.id).unwrap_or_else(Uuid::new_v4);
let bot_id = self.bot.as_ref().map_or_else(Uuid::new_v4, |b| b.id);
let session = Session {
id: session_id,
@ -208,62 +178,53 @@ impl BotRunner {
Ok(session_id)
}
/// End a session
pub fn end_session(&mut self, session_id: Uuid) -> Result<()> {
pub fn end_session(&self, session_id: Uuid) -> Result<()> {
self.sessions.lock().unwrap().remove(&session_id);
Ok(())
}
/// Process a message in a session
pub async fn process_message(
&mut self,
&self,
session_id: Uuid,
message: &str,
) -> Result<ExecutionResult> {
let start = Instant::now();
let mut logs = Vec::new();
// Update metrics
{
let mut metrics = self.metrics.lock().unwrap();
metrics.total_requests += 1;
}
// Get session state
let state = {
let sessions = self.sessions.lock().unwrap();
sessions.get(&session_id).cloned()
};
let state = match state {
Some(s) => s,
None => {
return Ok(ExecutionResult {
session_id,
response: None,
state: ConversationState::Error,
execution_time: start.elapsed(),
logs,
error: Some("Session not found".to_string()),
});
}
let Some(state) = state else {
return Ok(ExecutionResult {
session_id,
response: None,
state: ConversationState::Error,
execution_time: start.elapsed(),
logs,
error: Some("Session not found".to_string()),
});
};
if self.config.capture_logs {
logs.push(LogEntry {
timestamp: chrono::Utc::now(),
level: LogLevel::Debug,
message: format!("Processing message: {}", message),
message: format!("Processing message: {message}"),
context: HashMap::new(),
});
}
// Execute bot logic (placeholder - would call actual bot runtime)
let response = self.execute_bot_logic(session_id, message, &state).await;
let execution_time = start.elapsed();
// Update metrics
{
let mut metrics = self.metrics.lock().unwrap();
let latency_ms = execution_time.as_millis() as u64;
@ -283,7 +244,6 @@ impl BotRunner {
}
}
// Update session state
{
let mut sessions = self.sessions.lock().unwrap();
if let Some(session_state) = sessions.get_mut(&session_id) {
@ -312,75 +272,158 @@ impl BotRunner {
}
}
/// Execute bot logic (placeholder for actual implementation)
async fn execute_bot_logic(
&self,
_session_id: Uuid,
session_id: Uuid,
message: &str,
_state: &SessionState,
state: &SessionState,
) -> Result<BotResponse> {
// In a real implementation, this would:
// 1. Load the bot's BASIC script
// 2. Execute it with the message as input
// 3. Return the bot's response
let start = Instant::now();
let bot = self.bot.as_ref().context("No bot configured")?;
let script_path = self
.config
.working_dir
.join(&bot.name)
.join("dialog")
.join("start.bas");
let script_content = if script_path.exists() {
tokio::fs::read_to_string(&script_path)
.await
.unwrap_or_default()
} else {
let cache = self.script_cache.lock().unwrap();
cache.get("default").cloned().unwrap_or_default()
};
let response_content = if script_content.is_empty() {
format!("Received: {message}")
} else {
Self::evaluate_basic_script(&script_content, message, &state.context)
.unwrap_or_else(|e| format!("Error: {e}"))
};
let latency = start.elapsed().as_millis() as u64;
{
let mut metrics = self.metrics.lock().unwrap();
metrics.total_requests += 1;
metrics.successful_requests += 1;
metrics.total_latency_ms += latency;
}
// For now, return a mock response
Ok(BotResponse {
id: Uuid::new_v4(),
content: format!("Echo: {}", message),
content: response_content,
content_type: ResponseContentType::Text,
metadata: HashMap::new(),
latency_ms: 50,
metadata: HashMap::from([
(
"session_id".to_string(),
serde_json::Value::String(session_id.to_string()),
),
(
"bot_name".to_string(),
serde_json::Value::String(bot.name.clone()),
),
]),
latency_ms: latency,
})
}
/// Execute a BASIC script directly
pub async fn execute_script(
&mut self,
script_name: &str,
fn evaluate_basic_script(
script: &str,
input: &str,
) -> Result<ExecutionResult> {
context: &HashMap<String, serde_json::Value>,
) -> Result<String> {
let mut output = String::new();
let mut variables: HashMap<String, String> = HashMap::new();
variables.insert("INPUT".to_string(), input.to_string());
for (key, value) in context {
variables.insert(key.to_uppercase(), value.to_string());
}
for line in script.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('\'') || line.starts_with("REM") {
continue;
}
if line.to_uppercase().starts_with("TALK") {
let content = line[4..].trim().trim_matches('"');
let expanded = Self::expand_variables(content, &variables);
if !output.is_empty() {
output.push('\n');
}
output.push_str(&expanded);
} else if line.to_uppercase().starts_with("HEAR") {
variables.insert("LAST_INPUT".to_string(), input.to_string());
} else if line.contains('=') && !line.to_uppercase().starts_with("IF") {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
let var_name = parts[0].trim().to_uppercase();
let var_value = parts[1].trim().trim_matches('"').to_string();
let expanded = Self::expand_variables(&var_value, &variables);
variables.insert(var_name, expanded);
}
}
}
if output.is_empty() {
output = format!("Processed: {input}");
}
Ok(output)
}
fn expand_variables(text: &str, variables: &HashMap<String, String>) -> String {
let mut result = text.to_string();
for (key, value) in variables {
result = result.replace(&format!("{{{key}}}"), value);
result = result.replace(&format!("${key}"), value);
result = result.replace(key, value);
}
result
}
pub fn execute_script(&self, script_name: &str, input: &str) -> Result<ExecutionResult> {
let session_id = Uuid::new_v4();
let start = Instant::now();
let mut logs = Vec::new();
// Get script from cache
let script = {
let cache = self.script_cache.lock().unwrap();
cache.get(script_name).cloned()
};
let script = match script {
Some(s) => s,
None => {
return Ok(ExecutionResult {
session_id,
response: None,
state: ConversationState::Error,
execution_time: start.elapsed(),
logs,
error: Some(format!("Script '{}' not found", script_name)),
});
}
let Some(script) = script else {
return Ok(ExecutionResult {
session_id,
response: None,
state: ConversationState::Error,
execution_time: start.elapsed(),
logs,
error: Some(format!("Script '{script_name}' not found")),
});
};
if self.config.capture_logs {
logs.push(LogEntry {
timestamp: chrono::Utc::now(),
level: LogLevel::Debug,
message: format!("Executing script: {}", script_name),
message: format!("Executing script: {script_name}"),
context: HashMap::new(),
});
}
// Update metrics
{
let mut metrics = self.metrics.lock().unwrap();
metrics.script_executions += 1;
}
// Execute script (placeholder)
let result = self.execute_script_internal(&script, input).await;
let result = Self::execute_script_internal(&script, input);
let execution_time = start.elapsed();
@ -410,42 +453,37 @@ impl BotRunner {
}
}
/// Internal script execution (placeholder)
async fn execute_script_internal(&self, _script: &str, input: &str) -> Result<String> {
// In a real implementation, this would parse and execute the BASIC script
// For now, just echo the input
Ok(format!("Script output for: {}", input))
fn execute_script_internal(script: &str, input: &str) -> Result<String> {
let context = HashMap::new();
Self::evaluate_basic_script(script, input, &context)
}
/// Get current metrics
pub fn metrics(&self) -> RunnerMetrics {
self.metrics.lock().unwrap().clone()
}
/// Reset metrics
pub fn reset_metrics(&mut self) {
pub fn reset_metrics(&self) {
*self.metrics.lock().unwrap() = RunnerMetrics::default();
}
/// Get active session count
pub fn active_session_count(&self) -> usize {
self.sessions.lock().unwrap().len()
}
/// Get session info
pub fn get_session_info(&self, session_id: Uuid) -> Option<SessionInfo> {
let sessions = self.sessions.lock().unwrap();
sessions.get(&session_id).map(|s| SessionInfo {
let info = sessions.get(&session_id).map(|s| SessionInfo {
session_id: s.session.id,
customer_id: s.customer.id,
channel: s.channel,
message_count: s.message_count,
state: s.conversation_state,
duration: s.started_at.elapsed(),
})
});
drop(sessions);
info
}
/// Set environment variable for bot execution
pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self {
self.config
.env_vars
@ -453,8 +491,7 @@ impl BotRunner {
self
}
/// Set timeout
pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
pub const fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
self.config.timeout = timeout;
self
}
@ -466,7 +503,6 @@ impl Default for BotRunner {
}
}
/// Information about a session
#[derive(Debug, Clone)]
pub struct SessionInfo {
pub session_id: Uuid,
@ -477,7 +513,6 @@ pub struct SessionInfo {
pub duration: Duration,
}
// Implement Clone for SessionState
impl Clone for SessionState {
fn clone(&self) -> Self {
Self {
@ -506,27 +541,31 @@ mod tests {
#[test]
fn test_runner_metrics_avg_latency() {
let mut metrics = RunnerMetrics::default();
metrics.total_requests = 10;
metrics.total_latency_ms = 1000;
let metrics = RunnerMetrics {
total_requests: 10,
total_latency_ms: 1000,
..Default::default()
};
assert_eq!(metrics.avg_latency_ms(), 100);
}
#[test]
fn test_runner_metrics_success_rate() {
let mut metrics = RunnerMetrics::default();
metrics.total_requests = 100;
metrics.successful_requests = 95;
let metrics = RunnerMetrics {
total_requests: 100,
successful_requests: 95,
..Default::default()
};
assert_eq!(metrics.success_rate(), 95.0);
assert!((metrics.success_rate() - 95.0).abs() < f64::EPSILON);
}
#[test]
fn test_runner_metrics_zero_requests() {
let metrics = RunnerMetrics::default();
assert_eq!(metrics.avg_latency_ms(), 0);
assert_eq!(metrics.success_rate(), 0.0);
assert!(metrics.success_rate().abs() < f64::EPSILON);
}
#[test]
@ -537,16 +576,17 @@ mod tests {
#[test]
fn test_load_script() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
runner.load_script("test", "TALK \"Hello\"");
let cache = runner.script_cache.lock().unwrap();
assert!(cache.contains_key("test"));
drop(cache);
}
#[test]
fn test_start_session() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap();
@ -557,7 +597,7 @@ mod tests {
#[test]
fn test_end_session() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap();
@ -569,7 +609,7 @@ mod tests {
#[tokio::test]
async fn test_process_message() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap();
@ -582,7 +622,7 @@ mod tests {
#[tokio::test]
async fn test_process_message_invalid_session() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
let invalid_session_id = Uuid::new_v4();
let result = runner
@ -595,22 +635,22 @@ mod tests {
assert_eq!(result.state, ConversationState::Error);
}
#[tokio::test]
async fn test_execute_script() {
let mut runner = BotRunner::new();
#[test]
fn test_execute_script() {
let runner = BotRunner::new();
runner.load_script("greeting", "TALK \"Hello\"");
let result = runner.execute_script("greeting", "Hi").await.unwrap();
let result = runner.execute_script("greeting", "Hi").unwrap();
assert!(result.response.is_some());
assert!(result.error.is_none());
}
#[tokio::test]
async fn test_execute_script_not_found() {
let mut runner = BotRunner::new();
#[test]
fn test_execute_script_not_found() {
let runner = BotRunner::new();
let result = runner.execute_script("nonexistent", "Hi").await.unwrap();
let result = runner.execute_script("nonexistent", "Hi").unwrap();
assert!(result.response.is_none());
assert!(result.error.is_some());
@ -628,9 +668,8 @@ mod tests {
#[test]
fn test_reset_metrics() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
// Manually update metrics
{
let mut metrics = runner.metrics.lock().unwrap();
metrics.total_requests = 100;
@ -663,7 +702,7 @@ mod tests {
#[test]
fn test_session_info() {
let mut runner = BotRunner::new();
let runner = BotRunner::new();
let customer = Customer::default();
let customer_id = customer.id;

View file

@ -1,33 +1,17 @@
//! Desktop application testing module
//!
//! Provides tools for testing native desktop applications using accessibility APIs
//! and platform-specific automation frameworks.
//!
//! Note: Desktop testing is currently experimental and requires platform-specific
//! setup (e.g., Accessibility permissions on macOS, AT-SPI on Linux).
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
/// Configuration for desktop application testing
#[derive(Debug, Clone)]
pub struct DesktopConfig {
/// Path to the application executable
pub app_path: PathBuf,
/// Command line arguments for the application
pub args: Vec<String>,
/// Environment variables to set
pub env_vars: HashMap<String, String>,
/// Working directory for the application
pub working_dir: Option<PathBuf>,
/// Timeout for operations
pub timeout: Duration,
/// Whether to capture screenshots on failure
pub screenshot_on_failure: bool,
/// Directory to save screenshots
pub screenshot_dir: PathBuf,
}
@ -46,7 +30,6 @@ impl Default for DesktopConfig {
}
impl DesktopConfig {
/// Create a new config for the given application path
pub fn new(app_path: impl Into<PathBuf>) -> Self {
Self {
app_path: app_path.into(),
@ -54,32 +37,30 @@ impl DesktopConfig {
}
}
/// Add command line arguments
#[must_use]
pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args;
self
}
/// Add an environment variable
#[must_use]
pub fn with_env(mut self, key: &str, value: &str) -> Self {
self.env_vars.insert(key.to_string(), value.to_string());
self
}
/// Set the working directory
pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.working_dir = Some(dir.into());
self
}
/// Set the timeout
pub fn with_timeout(mut self, timeout: Duration) -> Self {
#[must_use]
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
/// Platform type for desktop testing
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
@ -88,20 +69,19 @@ pub enum Platform {
}
impl Platform {
/// Detect the current platform
pub fn current() -> Self {
#[must_use]
pub const fn current() -> Self {
#[cfg(target_os = "windows")]
return Platform::Windows;
#[cfg(target_os = "macos")]
return Platform::MacOS;
#[cfg(target_os = "linux")]
return Platform::Linux;
return Self::Linux;
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
panic!("Unsupported platform for desktop testing");
}
}
/// Desktop application handle for testing
pub struct DesktopApp {
config: DesktopConfig,
platform: Platform,
@ -110,8 +90,8 @@ pub struct DesktopApp {
}
impl DesktopApp {
/// Create a new desktop app handle
pub fn new(config: DesktopConfig) -> Self {
#[must_use]
pub const fn new(config: DesktopConfig) -> Self {
Self {
config,
platform: Platform::current(),
@ -120,7 +100,6 @@ impl DesktopApp {
}
}
/// Launch the application
pub async fn launch(&mut self) -> Result<()> {
use std::process::Command;
@ -139,16 +118,13 @@ impl DesktopApp {
self.pid = Some(child.id());
self.process = Some(child);
// Wait for application to start
tokio::time::sleep(Duration::from_millis(500)).await;
Ok(())
}
/// Close the application
pub async fn close(&mut self) -> Result<()> {
if let Some(ref mut process) = self.process {
// Try graceful shutdown first
#[cfg(unix)]
{
use nix::sys::signal::{kill, Signal};
@ -158,10 +134,8 @@ impl DesktopApp {
}
}
// Wait a bit for graceful shutdown
tokio::time::sleep(Duration::from_millis(500)).await;
// Force kill if still running
let _ = process.kill();
let _ = process.wait();
self.process = None;
@ -170,7 +144,6 @@ impl DesktopApp {
Ok(())
}
/// Check if the application is running
pub fn is_running(&mut self) -> bool {
if let Some(ref mut process) = self.process {
match process.try_wait() {
@ -187,64 +160,56 @@ impl DesktopApp {
}
}
/// Get the process ID
pub fn pid(&self) -> Option<u32> {
#[must_use]
pub const fn pid(&self) -> Option<u32> {
self.pid
}
/// Get the platform
pub fn platform(&self) -> Platform {
#[must_use]
pub const fn platform(&self) -> Platform {
self.platform
}
/// Find a window by title
pub async fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
// Platform-specific window finding
pub fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
match self.platform {
Platform::Windows => self.find_window_windows(title).await,
Platform::MacOS => self.find_window_macos(title).await,
Platform::Linux => self.find_window_linux(title).await,
Platform::Windows => Self::find_window_windows(title),
Platform::MacOS => Self::find_window_macos(title),
Platform::Linux => Self::find_window_linux(title),
}
}
#[cfg(target_os = "windows")]
async fn find_window_windows(&self, _title: &str) -> Result<Option<WindowHandle>> {
// Windows-specific implementation using Win32 API
// Would use FindWindow or EnumWindows
fn find_window_windows(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("Windows desktop testing not yet implemented")
}
#[cfg(not(target_os = "windows"))]
async fn find_window_windows(&self, _title: &str) -> Result<Option<WindowHandle>> {
fn find_window_windows(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("Windows desktop testing not available on this platform")
}
#[cfg(target_os = "macos")]
async fn find_window_macos(&self, _title: &str) -> Result<Option<WindowHandle>> {
// macOS-specific implementation using Accessibility API
// Would use AXUIElement APIs
fn find_window_macos(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("macOS desktop testing not yet implemented")
}
#[cfg(not(target_os = "macos"))]
async fn find_window_macos(&self, _title: &str) -> Result<Option<WindowHandle>> {
fn find_window_macos(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("macOS desktop testing not available on this platform")
}
#[cfg(target_os = "linux")]
async fn find_window_linux(&self, _title: &str) -> Result<Option<WindowHandle>> {
// Linux-specific implementation using AT-SPI or X11/Wayland
// Would use libatspi or XGetWindowProperty
fn find_window_linux(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("Linux desktop testing not yet implemented")
}
#[cfg(not(target_os = "linux"))]
async fn find_window_linux(&self, _title: &str) -> Result<Option<WindowHandle>> {
fn find_window_linux(_title: &str) -> Result<Option<WindowHandle>> {
anyhow::bail!("Linux desktop testing not available on this platform")
}
/// Take a screenshot of the application
pub async fn screenshot(&self) -> Result<Screenshot> {
pub fn screenshot(&self) -> Result<Screenshot> {
let _ = &self.platform;
anyhow::bail!("Screenshot functionality not yet implemented")
}
}
@ -258,29 +223,20 @@ impl Drop for DesktopApp {
}
}
/// Handle to a window
#[derive(Debug, Clone)]
pub struct WindowHandle {
/// Platform-specific window identifier
pub id: WindowId,
/// Window title
pub title: String,
/// Window bounds
pub bounds: WindowBounds,
}
/// Platform-specific window identifier
#[derive(Debug, Clone)]
pub enum WindowId {
/// Windows HWND (as usize)
Windows(usize),
/// macOS AXUIElement reference (opaque pointer)
MacOS(usize),
/// Linux X11 Window ID or AT-SPI path
Linux(String),
}
/// Window bounds
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct WindowBounds {
pub x: i32,
@ -289,121 +245,105 @@ pub struct WindowBounds {
pub height: u32,
}
/// Screenshot data
#[derive(Debug, Clone)]
pub struct Screenshot {
/// Raw pixel data (RGBA)
pub data: Vec<u8>,
/// Width in pixels
pub width: u32,
/// Height in pixels
pub height: u32,
}
impl Screenshot {
/// Save screenshot to a file
pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> {
let _ = (&self.data, self.width, self.height);
let path = path.into();
// Would use image crate to save PNG
anyhow::bail!("Screenshot save not yet implemented: {:?}", path)
anyhow::bail!("Screenshot save not yet implemented: {}", path.display())
}
}
/// Element locator for desktop UI
#[derive(Debug, Clone)]
pub enum ElementLocator {
/// Accessibility ID
AccessibilityId(String),
/// Element name/label
Name(String),
/// Element type/role
Role(String),
/// XPath-like path
Path(String),
/// Combination of properties
Properties(HashMap<String, String>),
}
impl ElementLocator {
#[must_use]
pub fn accessibility_id(id: &str) -> Self {
Self::AccessibilityId(id.to_string())
}
#[must_use]
pub fn name(name: &str) -> Self {
Self::Name(name.to_string())
}
#[must_use]
pub fn role(role: &str) -> Self {
Self::Role(role.to_string())
}
#[must_use]
pub fn path(path: &str) -> Self {
Self::Path(path.to_string())
}
}
/// Desktop UI element
#[derive(Debug, Clone)]
pub struct Element {
/// Element locator used to find this element
pub locator: ElementLocator,
/// Element role/type
pub role: String,
/// Element name/label
pub name: Option<String>,
/// Element value
pub value: Option<String>,
/// Element bounds
pub bounds: WindowBounds,
/// Whether the element is enabled
pub enabled: bool,
/// Whether the element is focused
pub focused: bool,
}
impl Element {
/// Click the element
pub async fn click(&self) -> Result<()> {
pub fn click(&self) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element click not yet implemented")
}
/// Double-click the element
pub async fn double_click(&self) -> Result<()> {
pub fn double_click(&self) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element double-click not yet implemented")
}
/// Right-click the element
pub async fn right_click(&self) -> Result<()> {
pub fn right_click(&self) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element right-click not yet implemented")
}
/// Type text into the element
pub async fn type_text(&self, _text: &str) -> Result<()> {
pub fn type_text(&self, _text: &str) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element type_text not yet implemented")
}
/// Clear the element's text
pub async fn clear(&self) -> Result<()> {
pub fn clear(&self) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element clear not yet implemented")
}
/// Get the element's text content
#[must_use]
pub fn text(&self) -> Option<&str> {
self.value.as_deref()
}
/// Check if element is displayed/visible
pub fn is_displayed(&self) -> bool {
#[must_use]
pub const fn is_displayed(&self) -> bool {
self.bounds.width > 0 && self.bounds.height > 0
}
/// Focus the element
pub async fn focus(&self) -> Result<()> {
pub fn focus(&self) -> Result<()> {
let _ = &self.locator;
anyhow::bail!("Element focus not yet implemented")
}
}
/// Result of a desktop test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopTestResult {
pub name: String,
@ -414,7 +354,6 @@ pub struct DesktopTestResult {
pub error: Option<String>,
}
/// A step in a desktop test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStep {
pub name: String,
@ -450,7 +389,6 @@ mod tests {
#[test]
fn test_platform_detection() {
let platform = Platform::current();
// Just verify it doesn't panic
assert!(matches!(
platform,
Platform::Windows | Platform::MacOS | Platform::Linux

View file

@ -1,13 +1,8 @@
//! Data fixtures for tests
//!
//! Provides sample test data including JSON payloads, configurations,
//! and pre-defined data sets for various test scenarios.
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
/// Sample configuration data
#[must_use]
pub fn sample_config() -> HashMap<String, String> {
let mut config = HashMap::new();
config.insert("llm-model".to_string(), "gpt-4".to_string());
@ -20,7 +15,7 @@ pub fn sample_config() -> HashMap<String, String> {
config
}
/// Sample bot configuration as JSON
#[must_use]
pub fn sample_bot_config() -> Value {
json!({
"name": "test-bot",
@ -53,7 +48,7 @@ pub fn sample_bot_config() -> Value {
})
}
/// Sample WhatsApp webhook payload for incoming text message
#[must_use]
pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
json!({
"object": "whatsapp_business_account",
@ -74,7 +69,7 @@ pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
}],
"messages": [{
"from": from,
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace("-", "")),
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
"timestamp": chrono::Utc::now().timestamp().to_string(),
"type": "text",
"text": {
@ -88,7 +83,7 @@ pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
})
}
/// Sample WhatsApp webhook payload for button reply
#[must_use]
pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) -> Value {
json!({
"object": "whatsapp_business_account",
@ -109,7 +104,7 @@ pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) ->
}],
"messages": [{
"from": from,
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace("-", "")),
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
"timestamp": chrono::Utc::now().timestamp().to_string(),
"type": "interactive",
"interactive": {
@ -127,7 +122,7 @@ pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) ->
})
}
/// Sample Teams activity for incoming message
#[must_use]
pub fn teams_message_activity(from_id: &str, from_name: &str, text: &str) -> Value {
json!({
"type": "message",
@ -160,7 +155,7 @@ pub fn teams_message_activity(from_id: &str, from_name: &str, text: &str) -> Val
})
}
/// Sample OpenAI chat completion request
#[must_use]
pub fn openai_chat_request(messages: Vec<(&str, &str)>) -> Value {
let msgs: Vec<Value> = messages
.into_iter()
@ -180,7 +175,7 @@ pub fn openai_chat_request(messages: Vec<(&str, &str)>) -> Value {
})
}
/// Sample OpenAI chat completion response
#[must_use]
pub fn openai_chat_response(content: &str) -> Value {
json!({
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()),
@ -203,7 +198,7 @@ pub fn openai_chat_response(content: &str) -> Value {
})
}
/// Sample OpenAI embedding response
#[must_use]
pub fn openai_embedding_response(dimensions: usize) -> Value {
let embedding: Vec<f64> = (0..dimensions)
.map(|i| (i as f64) / (dimensions as f64))
@ -224,7 +219,7 @@ pub fn openai_embedding_response(dimensions: usize) -> Value {
})
}
/// Sample knowledge base entries
#[must_use]
pub fn sample_kb_entries() -> Vec<KBEntry> {
vec![
KBEntry {
@ -258,7 +253,6 @@ pub fn sample_kb_entries() -> Vec<KBEntry> {
]
}
/// Knowledge base entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KBEntry {
pub id: String,
@ -268,7 +262,7 @@ pub struct KBEntry {
pub tags: Vec<String>,
}
/// Sample product data
#[must_use]
pub fn sample_products() -> Vec<Product> {
vec![
Product {
@ -298,7 +292,6 @@ pub fn sample_products() -> Vec<Product> {
]
}
/// Product data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub sku: String,
@ -309,7 +302,7 @@ pub struct Product {
pub category: String,
}
/// Sample FAQ data
#[must_use]
pub fn sample_faqs() -> Vec<FAQ> {
vec![
FAQ {
@ -339,8 +332,8 @@ pub fn sample_faqs() -> Vec<FAQ> {
]
}
/// FAQ data
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct FAQ {
pub id: u32,
pub question: String,
@ -348,10 +341,10 @@ pub struct FAQ {
pub category: String,
}
/// Sample error responses
pub mod errors {
use serde_json::{json, Value};
#[must_use]
pub fn validation_error(field: &str, message: &str) -> Value {
json!({
"error": {
@ -365,6 +358,7 @@ pub mod errors {
})
}
#[must_use]
pub fn not_found(resource: &str, id: &str) -> Value {
json!({
"error": {
@ -374,6 +368,7 @@ pub mod errors {
})
}
#[must_use]
pub fn unauthorized() -> Value {
json!({
"error": {
@ -383,6 +378,7 @@ pub mod errors {
})
}
#[must_use]
pub fn forbidden() -> Value {
json!({
"error": {
@ -392,6 +388,7 @@ pub mod errors {
})
}
#[must_use]
pub fn rate_limited(retry_after: u32) -> Value {
json!({
"error": {
@ -402,6 +399,7 @@ pub mod errors {
})
}
#[must_use]
pub fn internal_error() -> Value {
json!({
"error": {
@ -427,10 +425,12 @@ mod tests {
fn test_whatsapp_text_message() {
let payload = whatsapp_text_message("15551234567", "Hello");
assert_eq!(payload["object"], "whatsapp_business_account");
assert!(payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.as_str()
.unwrap()
.contains("Hello"));
assert!(
payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.as_str()
.unwrap()
.contains("Hello")
);
}
#[test]
@ -455,7 +455,9 @@ mod tests {
fn test_sample_kb_entries() {
let entries = sample_kb_entries();
assert!(!entries.is_empty());
assert!(entries.iter().any(|e| e.category == Some("products".to_string())));
assert!(entries
.iter()
.any(|e| e.category == Some("products".to_string())));
}
#[test]

View file

@ -1,8 +1,3 @@
//! Test fixtures and data factories
//!
//! Provides pre-built test data and factories for creating test objects
//! including users, customers, bots, sessions, and conversations.
pub mod data;
pub mod scripts;
@ -11,9 +6,6 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
// Re-export common fixtures
/// A test user
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
@ -39,23 +31,18 @@ impl Default for User {
}
}
/// User role
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum Role {
Admin,
Attendant,
#[default]
User,
Guest,
}
impl Default for Role {
fn default() -> Self {
Self::User
}
}
/// A customer (end user interacting with bot)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Customer {
pub id: Uuid,
@ -85,10 +72,12 @@ impl Default for Customer {
}
}
/// Communication channel
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(clippy::upper_case_acronyms)]
#[derive(Default)]
pub enum Channel {
#[default]
WhatsApp,
Teams,
Web,
@ -97,13 +86,7 @@ pub enum Channel {
API,
}
impl Default for Channel {
fn default() -> Self {
Self::WhatsApp
}
}
/// A bot configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bot {
pub id: Uuid,
@ -135,7 +118,6 @@ impl Default for Bot {
}
}
/// A conversation session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: Uuid,
@ -165,23 +147,18 @@ impl Default for Session {
}
}
/// Session state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SessionState {
#[default]
Active,
Waiting,
Transferred,
Ended,
}
impl Default for SessionState {
fn default() -> Self {
Self::Active
}
}
/// A conversation message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Uuid,
@ -207,7 +184,6 @@ impl Default for Message {
}
}
/// Message direction
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageDirection {
@ -215,10 +191,11 @@ pub enum MessageDirection {
Outgoing,
}
/// Content type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ContentType {
#[default]
Text,
Image,
Audio,
@ -229,13 +206,7 @@ pub enum ContentType {
Interactive,
}
impl Default for ContentType {
fn default() -> Self {
Self::Text
}
}
/// Queue entry for attendance
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueEntry {
pub id: Uuid,
@ -263,26 +234,23 @@ impl Default for QueueEntry {
}
}
/// Queue priority
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum Priority {
Low = 0,
#[default]
Normal = 1,
High = 2,
Urgent = 3,
}
impl Default for Priority {
fn default() -> Self {
Self::Normal
}
}
/// Queue status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum QueueStatus {
#[default]
Waiting,
Assigned,
InProgress,
@ -290,17 +258,8 @@ pub enum QueueStatus {
Cancelled,
}
impl Default for QueueStatus {
fn default() -> Self {
Self::Waiting
}
}
// =============================================================================
// Factory Functions
// =============================================================================
/// Create an admin user
#[must_use]
pub fn admin_user() -> User {
User {
email: "admin@test.com".to_string(),
@ -310,7 +269,7 @@ pub fn admin_user() -> User {
}
}
/// Create an attendant user
#[must_use]
pub fn attendant_user() -> User {
User {
email: "attendant@test.com".to_string(),
@ -320,7 +279,7 @@ pub fn attendant_user() -> User {
}
}
/// Create a regular user
#[must_use]
pub fn regular_user() -> User {
User {
email: "user@test.com".to_string(),
@ -330,7 +289,7 @@ pub fn regular_user() -> User {
}
}
/// Create a user with specific email
#[must_use]
pub fn user_with_email(email: &str) -> User {
User {
email: email.to_string(),
@ -339,7 +298,7 @@ pub fn user_with_email(email: &str) -> User {
}
}
/// Create a customer with a phone number
#[must_use]
pub fn customer(phone: &str) -> Customer {
Customer {
phone: Some(phone.to_string()),
@ -348,7 +307,7 @@ pub fn customer(phone: &str) -> Customer {
}
}
/// Create a customer for a specific channel
#[must_use]
pub fn customer_on_channel(channel: Channel) -> Customer {
Customer {
channel,
@ -356,7 +315,7 @@ pub fn customer_on_channel(channel: Channel) -> Customer {
}
}
/// Create a Teams customer
#[must_use]
pub fn teams_customer() -> Customer {
Customer {
channel: Channel::Teams,
@ -365,7 +324,7 @@ pub fn teams_customer() -> Customer {
}
}
/// Create a web customer
#[must_use]
pub fn web_customer() -> Customer {
Customer {
channel: Channel::Web,
@ -374,7 +333,7 @@ pub fn web_customer() -> Customer {
}
}
/// Create a basic bot
#[must_use]
pub fn basic_bot(name: &str) -> Bot {
Bot {
name: name.to_string(),
@ -384,7 +343,7 @@ pub fn basic_bot(name: &str) -> Bot {
}
}
/// Create a bot with knowledge base enabled
#[must_use]
pub fn bot_with_kb(name: &str) -> Bot {
Bot {
name: name.to_string(),
@ -394,7 +353,7 @@ pub fn bot_with_kb(name: &str) -> Bot {
}
}
/// Create a bot without LLM (rule-based only)
#[must_use]
pub fn rule_based_bot(name: &str) -> Bot {
Bot {
name: name.to_string(),
@ -405,7 +364,7 @@ pub fn rule_based_bot(name: &str) -> Bot {
}
}
/// Create a session for a bot and customer
#[must_use]
pub fn session_for(bot: &Bot, customer: &Customer) -> Session {
Session {
bot_id: bot.id,
@ -415,7 +374,7 @@ pub fn session_for(bot: &Bot, customer: &Customer) -> Session {
}
}
/// Create an active session
#[must_use]
pub fn active_session() -> Session {
Session {
state: SessionState::Active,
@ -423,7 +382,7 @@ pub fn active_session() -> Session {
}
}
/// Create an incoming message
#[must_use]
pub fn incoming_message(content: &str) -> Message {
Message {
direction: MessageDirection::Incoming,
@ -432,7 +391,7 @@ pub fn incoming_message(content: &str) -> Message {
}
}
/// Create an outgoing message
#[must_use]
pub fn outgoing_message(content: &str) -> Message {
Message {
direction: MessageDirection::Outgoing,
@ -441,7 +400,7 @@ pub fn outgoing_message(content: &str) -> Message {
}
}
/// Create a message in a session
#[must_use]
pub fn message_in_session(
session: &Session,
content: &str,
@ -455,7 +414,7 @@ pub fn message_in_session(
}
}
/// Create a queue entry for a customer
#[must_use]
pub fn queue_entry_for(customer: &Customer, session: &Session) -> QueueEntry {
QueueEntry {
customer_id: customer.id,
@ -464,7 +423,7 @@ pub fn queue_entry_for(customer: &Customer, session: &Session) -> QueueEntry {
}
}
/// Create a high priority queue entry
#[must_use]
pub fn high_priority_queue_entry() -> QueueEntry {
QueueEntry {
priority: Priority::High,
@ -472,7 +431,7 @@ pub fn high_priority_queue_entry() -> QueueEntry {
}
}
/// Create an urgent queue entry
#[must_use]
pub fn urgent_queue_entry() -> QueueEntry {
QueueEntry {
priority: Priority::Urgent,

View file

@ -1,11 +1,7 @@
//! BASIC script fixtures for testing bot behavior
//!
//! Provides sample BASIC scripts that can be used to test
//! the BASIC interpreter and bot conversation flows.
use std::collections::HashMap;
/// Get a script fixture by name
#[must_use]
pub fn get_script(name: &str) -> Option<&'static str> {
match name {
"greeting" => Some(GREETING_SCRIPT),
@ -22,7 +18,7 @@ pub fn get_script(name: &str) -> Option<&'static str> {
}
}
/// Get all available script names
#[must_use]
pub fn available_scripts() -> Vec<&'static str> {
vec![
"greeting",
@ -38,7 +34,7 @@ pub fn available_scripts() -> Vec<&'static str> {
]
}
/// Get all scripts as a map
#[must_use]
pub fn all_scripts() -> HashMap<&'static str, &'static str> {
let mut scripts = HashMap::new();
for name in available_scripts() {
@ -49,7 +45,6 @@ pub fn all_scripts() -> HashMap<&'static str, &'static str> {
scripts
}
/// Simple greeting flow script
pub const GREETING_SCRIPT: &str = r#"
' Greeting Flow Script
' Simple greeting and response pattern
@ -72,7 +67,6 @@ ELSE
END IF
"#;
/// Knowledge base search script
pub const KB_SEARCH_SCRIPT: &str = r#"
' Knowledge Base Search Script
' Demonstrates searching the knowledge base
@ -98,7 +92,6 @@ ELSE
END IF
"#;
/// Human handoff / attendance flow script
pub const ATTENDANCE_SCRIPT: &str = r#"
' Attendance / Human Handoff Script
' Demonstrates transferring to human agents
@ -130,7 +123,6 @@ ELSE
END IF
"#;
/// Error handling patterns script
pub const ERROR_HANDLING_SCRIPT: &str = r#"
' Error Handling Script
' Demonstrates ON ERROR RESUME NEXT patterns
@ -167,7 +159,6 @@ TALK "Processing your request: " + LEFT$(userInput$, 50) + "..."
retry_input:
"#;
/// LLM with tools script
pub const LLM_TOOLS_SCRIPT: &str = r#"
' LLM Tools Script
' Demonstrates LLM with function calling / tools
@ -209,7 +200,6 @@ TALK llm.response
GOTO conversation_loop
"#;
/// Data operations script
pub const DATA_OPERATIONS_SCRIPT: &str = r#"
' Data Operations Script
' Demonstrates FIND, SAVE, UPDATE, DELETE operations
@ -251,7 +241,6 @@ BEGIN TRANSACTION
COMMIT TRANSACTION
"#;
/// HTTP integration script
pub const HTTP_INTEGRATION_SCRIPT: &str = r#"
' HTTP Integration Script
' Demonstrates POST, GET, GRAPHQL, SOAP calls
@ -286,7 +275,6 @@ soap_response = SOAP "https://api.example.com/soap" ACTION "GetProduct" BODY soa
TALK "Product: " + soap_response.ProductName
"#;
/// Menu-driven conversation flow
pub const MENU_FLOW_SCRIPT: &str = r#"
' Menu Flow Script
' Demonstrates interactive menu-based conversation
@ -364,7 +352,6 @@ return_item:
RETURN
"#;
/// Simple echo script for basic testing
pub const SIMPLE_ECHO_SCRIPT: &str = r#"
' Simple Echo Script
' Echoes back whatever the user says
@ -383,7 +370,6 @@ TALK "You said: " + input$
GOTO echo_loop
"#;
/// Variables and expressions script
pub const VARIABLES_SCRIPT: &str = r#"
' Variables and Expressions Script
' Demonstrates variable types and operations

View file

@ -35,7 +35,8 @@ impl Default for TestConfig {
}
impl TestConfig {
pub fn minimal() -> Self {
#[must_use]
pub const fn minimal() -> Self {
Self {
postgres: false,
minio: false,
@ -46,31 +47,32 @@ impl TestConfig {
}
}
pub fn full() -> Self {
#[must_use]
pub const fn full() -> Self {
Self {
postgres: false, // Botserver will bootstrap its own PostgreSQL
minio: false, // Botserver will bootstrap its own MinIO
redis: false, // Botserver will bootstrap its own Redis
postgres: false,
minio: false,
redis: false,
mock_zitadel: true,
mock_llm: true,
run_migrations: false, // Let botserver run its own migrations
run_migrations: false,
}
}
/// Auto-install mode: let botserver bootstrap all services
/// No need for pre-installed PostgreSQL binaries
pub fn auto_install() -> Self {
#[must_use]
pub const fn auto_install() -> Self {
Self {
postgres: false, // Botserver will install PostgreSQL
minio: false, // Botserver will install MinIO
redis: false, // Botserver will install Redis
postgres: false,
minio: false,
redis: false,
mock_zitadel: true,
mock_llm: true,
run_migrations: false, // Botserver handles migrations
run_migrations: false,
}
}
pub fn database_only() -> Self {
#[must_use]
pub const fn database_only() -> Self {
Self {
postgres: true,
run_migrations: true,
@ -78,7 +80,8 @@ impl TestConfig {
}
}
pub fn use_existing_stack() -> Self {
#[must_use]
pub const fn use_existing_stack() -> Self {
Self {
postgres: false,
minio: false,
@ -116,29 +119,22 @@ pub struct TestContext {
}
impl TestContext {
pub fn test_id(&self) -> Uuid {
pub const fn test_id(&self) -> Uuid {
self.test_id
}
pub fn database_url(&self) -> String {
if self.use_existing_stack {
// For existing stack, use sensible defaults matching botserver's bootstrap
// These can be overridden via environment variables if needed
let host = std::env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DB_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DefaultPorts::POSTGRES);
// Default to gbuser/botserver which is what botserver bootstrap creates
let user = std::env::var("DB_USER").unwrap_or_else(|_| "gbuser".to_string());
let password = std::env::var("DB_PASSWORD").unwrap_or_else(|_| "gbuser".to_string());
let database = std::env::var("DB_NAME").unwrap_or_else(|_| "botserver".to_string());
format!(
"postgres://{}:{}@{}:{}/{}",
user, password, host, port, database
)
format!("postgres://{user}:{password}@{host}:{port}/{database}")
} else {
// For test-managed postgres, use test credentials
format!(
"postgres://bottest:bottest@127.0.0.1:{}/bottest",
self.ports.postgres
@ -181,28 +177,28 @@ impl TestContext {
Pool::builder()
.max_size(5)
.build(manager)
.map_err(|e| anyhow::anyhow!("Failed to create pool: {}", e))
.map_err(|e| anyhow::anyhow!("Failed to create pool: {e}"))
})
.await
}
pub fn mock_zitadel(&self) -> Option<&MockZitadel> {
pub const fn mock_zitadel(&self) -> Option<&MockZitadel> {
self.mock_zitadel.as_ref()
}
pub fn mock_llm(&self) -> Option<&MockLLM> {
pub const fn mock_llm(&self) -> Option<&MockLLM> {
self.mock_llm.as_ref()
}
pub fn postgres(&self) -> Option<&PostgresService> {
pub const fn postgres(&self) -> Option<&PostgresService> {
self.postgres.as_ref()
}
pub fn minio(&self) -> Option<&MinioService> {
pub const fn minio(&self) -> Option<&MinioService> {
self.minio.as_ref()
}
pub fn redis(&self) -> Option<&RedisService> {
pub const fn redis(&self) -> Option<&RedisService> {
self.redis.as_ref()
}
@ -452,11 +448,11 @@ pub struct BotServerInstance {
}
impl BotServerInstance {
/// Create an instance pointing to an already-running botserver
#[must_use]
pub fn existing(url: &str) -> Self {
let port = url
.split(':')
.last()
.next_back()
.and_then(|p| p.parse().ok())
.unwrap_or(8080);
Self {
@ -467,9 +463,6 @@ impl BotServerInstance {
}
}
/// Start botserver using the MAIN stack (../botserver/botserver-stack)
/// This uses the real stack with LLM, Zitadel, etc. already configured
/// For E2E demo tests that need actual bot responses
pub async fn start_with_main_stack() -> Result<Self> {
let port = 8080;
let url = "https://localhost:8080".to_string();
@ -477,23 +470,20 @@ impl BotServerInstance {
let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
// Check if binary exists
if !PathBuf::from(&botserver_bin).exists() {
log::warn!("Botserver binary not found at: {}", botserver_bin);
log::warn!("Botserver binary not found at: {botserver_bin}");
anyhow::bail!(
"Botserver binary not found at: {}. Run: cd ../botserver && cargo build",
botserver_bin
"Botserver binary not found at: {botserver_bin}. Run: cd ../botserver && cargo build"
);
}
// Get absolute path to botserver directory (where botserver-stack lives)
let botserver_bin_path =
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
let botserver_dir = botserver_bin_path
.parent() // target/debug
.and_then(|p| p.parent()) // target
.and_then(|p| p.parent()) // botserver
.map(|p| p.to_path_buf())
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| {
std::fs::canonicalize("../botserver")
.unwrap_or_else(|_| PathBuf::from("../botserver"))
@ -501,22 +491,21 @@ impl BotServerInstance {
let stack_path = botserver_dir.join("botserver-stack");
// Check if main stack exists
if !stack_path.exists() {
anyhow::bail!(
"Main botserver-stack not found at {:?}.\n\
"Main botserver-stack not found at {}.\n\
Run botserver once to initialize: cd ../botserver && cargo run",
stack_path
stack_path.display()
);
}
log::info!("Starting botserver with MAIN stack at {:?}", stack_path);
log::info!(
"Starting botserver with MAIN stack at {}",
stack_path.display()
);
println!("🚀 Starting BotServer with main stack...");
println!(" Stack: {:?}", stack_path);
println!(" Stack: {}", stack_path.display());
// Start botserver from its directory, using default stack path
// NO --stack-path argument = uses ./botserver-stack (the main one)
// NO mock env vars = uses real services
let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir)
.arg("--noconsole")
@ -527,9 +516,8 @@ impl BotServerInstance {
.ok();
if process.is_some() {
// Wait for botserver to be ready (may take time for LLM to load)
let max_wait = 120; // 2 minutes for LLM
log::info!("Waiting for botserver to start (max {}s)...", max_wait);
let max_wait = 120;
log::info!("Waiting for botserver to start (max {max_wait}s)...");
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
@ -538,10 +526,10 @@ impl BotServerInstance {
.unwrap_or_default();
for i in 0..max_wait {
if let Ok(resp) = client.get(format!("{}/health", url)).send().await {
if let Ok(resp) = client.get(format!("{url}/health")).send().await {
if resp.status().is_success() {
log::info!("Botserver ready on port {}", port);
println!(" ✓ BotServer ready at {}", url);
log::info!("Botserver ready on port {port}");
println!(" ✓ BotServer ready at {url}");
return Ok(Self {
url,
port,
@ -551,8 +539,8 @@ impl BotServerInstance {
}
}
if i % 10 == 0 && i > 0 {
log::info!("Still waiting for botserver... ({}s)", i);
println!(" ... waiting ({}s)", i);
log::info!("Still waiting for botserver... ({i}s)");
println!(" ... waiting ({i}s)");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
@ -576,11 +564,11 @@ pub struct BotUIInstance {
}
impl BotUIInstance {
/// Create an instance pointing to an already-running botui
#[must_use]
pub fn existing(url: &str) -> Self {
let port = url
.split(':')
.last()
.next_back()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
Self {
@ -594,14 +582,13 @@ impl BotUIInstance {
impl BotUIInstance {
pub async fn start(ctx: &TestContext, botserver_url: &str) -> Result<Self> {
let port = crate::ports::PortAllocator::allocate();
let url = format!("http://127.0.0.1:{}", port);
let url = format!("http://127.0.0.1:{port}");
let botui_bin = std::env::var("BOTUI_BIN")
.unwrap_or_else(|_| "../botui/target/debug/botui".to_string());
// Check if binary exists
if !PathBuf::from(&botui_bin).exists() {
log::warn!("BotUI binary not found at: {}", botui_bin);
log::warn!("BotUI binary not found at: {botui_bin}");
return Ok(Self {
url,
port,
@ -609,26 +596,22 @@ impl BotUIInstance {
});
}
// BotUI needs to run from its own directory so it can find ui/ folder
// Get absolute path of botui binary and derive working directory
let botui_bin_path =
std::fs::canonicalize(&botui_bin).unwrap_or_else(|_| PathBuf::from(&botui_bin));
let botui_dir = botui_bin_path
.parent() // target/debug
.and_then(|p| p.parent()) // target
.and_then(|p| p.parent()) // botui
.map(|p| p.to_path_buf())
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| {
std::fs::canonicalize("../botui").unwrap_or_else(|_| PathBuf::from("../botui"))
});
log::info!("Starting botui from: {} on port {}", botui_bin, port);
log::info!(" BOTUI_PORT={}", port);
log::info!(" BOTSERVER_URL={}", botserver_url);
log::info!(" Working directory: {:?}", botui_dir);
log::info!("Starting botui from: {botui_bin} on port {port}");
log::info!(" BOTUI_PORT={port}");
log::info!(" BOTSERVER_URL={botserver_url}");
log::info!(" Working directory: {}", botui_dir.display());
// botui uses env vars, not command line args
// Must run from botui directory to find ui/ folder
let process = std::process::Command::new(&botui_bin_path)
.current_dir(&botui_dir)
.env("BOTUI_PORT", port.to_string())
@ -640,25 +623,23 @@ impl BotUIInstance {
.ok();
if process.is_some() {
// Wait for botui to be ready
let max_wait = 30;
log::info!("Waiting for botui to become ready... (max {}s)", max_wait);
log::info!("Waiting for botui to become ready... (max {max_wait}s)");
for i in 0..max_wait {
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
if let Ok(resp) = reqwest::get(&format!("{url}/health")).await {
if resp.status().is_success() {
log::info!("BotUI is ready on port {}", port);
log::info!("BotUI is ready on port {port}");
return Ok(Self { url, port, process });
}
}
// Also try root path in case /health isn't implemented
if let Ok(resp) = reqwest::get(&url).await {
if resp.status().is_success() {
log::info!("BotUI is ready on port {}", port);
log::info!("BotUI is ready on port {port}");
return Ok(Self { url, port, process });
}
}
if i % 5 == 0 {
log::info!("Still waiting for botui... ({}s)", i);
log::info!("Still waiting for botui... ({i}s)");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
@ -672,7 +653,8 @@ impl BotUIInstance {
})
}
pub fn is_running(&self) -> bool {
#[must_use]
pub const fn is_running(&self) -> bool {
self.process.is_some()
}
}
@ -687,24 +669,20 @@ impl Drop for BotUIInstance {
}
impl BotServerInstance {
/// Start botserver, creating a fresh stack from scratch for testing
pub async fn start(ctx: &TestContext) -> Result<Self> {
let port = ctx.ports.botserver;
let url = format!("http://127.0.0.1:{}", port);
let url = format!("http://127.0.0.1:{port}");
// Create a clean test stack directory for this test run
// Use absolute path since we'll change working directory for botserver
let stack_path = ctx.data_dir.join("botserver-stack");
std::fs::create_dir_all(&stack_path)?;
let stack_path = stack_path.canonicalize().unwrap_or(stack_path);
log::info!("Created clean test stack at: {:?}", stack_path);
log::info!("Created clean test stack at: {}", stack_path.display());
let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
// Check if binary exists
if !PathBuf::from(&botserver_bin).exists() {
log::warn!("Botserver binary not found at: {}", botserver_bin);
log::warn!("Botserver binary not found at: {botserver_bin}");
return Ok(Self {
url,
port,
@ -713,76 +691,54 @@ impl BotServerInstance {
});
}
log::info!("Starting botserver from: {}", botserver_bin);
log::info!("Starting botserver from: {botserver_bin}");
// Determine botserver working directory to find installers in botserver-installers/
// The botserver binary is typically at ../botserver/target/release/botserver
// We need to run from ../botserver so it finds botserver-installers/ and 3rdparty.toml
let botserver_bin_path =
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
let botserver_dir = botserver_bin_path
.parent() // target/release
.and_then(|p| p.parent()) // target
.and_then(|p| p.parent()) // botserver
.map(|p| p.to_path_buf())
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| {
std::fs::canonicalize("../botserver")
.unwrap_or_else(|_| PathBuf::from("../botserver"))
});
log::info!("Botserver working directory: {:?}", botserver_dir);
log::info!("Stack path (absolute): {:?}", stack_path);
log::info!("Botserver working directory: {}", botserver_dir.display());
log::info!("Stack path (absolute): {}", stack_path.display());
// Start botserver with test configuration
// - Uses test harness PostgreSQL
// - Uses mock Zitadel for auth
// - Uses mock LLM
// Env vars align with SecretsManager fallbacks (see botserver/src/core/secrets/mod.rs)
// Use absolute path for binary since we're changing working directory
// Point to local installers directory to avoid downloads
let installers_path = botserver_dir.join("botserver-installers");
let installers_path = installers_path.canonicalize().unwrap_or(installers_path);
log::info!("Using installers from: {:?}", installers_path);
log::info!("Using installers from: {}", installers_path.display());
let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir) // Run from botserver dir to find installers
.current_dir(&botserver_dir)
.arg("--stack-path")
.arg(&stack_path)
.arg("--port")
.arg(port.to_string())
.arg("--noconsole")
.env_remove("RUST_LOG") // Remove to avoid logger conflict
// Use local installers - DO NOT download
.env_remove("RUST_LOG")
.env("BOTSERVER_INSTALLERS_PATH", &installers_path)
// Database - DATABASE_URL is the standard fallback
.env("DATABASE_URL", ctx.database_url())
// Directory (Zitadel) - use SecretsManager fallback env vars
.env("DIRECTORY_URL", ctx.zitadel_url())
.env("ZITADEL_CLIENT_ID", "test-client-id")
.env("ZITADEL_CLIENT_SECRET", "test-client-secret")
// Drive (MinIO) - use SecretsManager fallback env vars
.env("DRIVE_ACCESSKEY", "minioadmin")
.env("DRIVE_SECRET", "minioadmin")
// Always let botserver bootstrap services (PostgreSQL, MinIO, Redis, etc.)
// No BOTSERVER_SKIP_INSTALL - we want full bootstrap
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.spawn()
.ok();
if process.is_some() {
// Give time for botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
let max_wait = 600;
log::info!(
"Waiting for botserver to bootstrap and become ready... (max {}s)",
max_wait
);
// Give more time for botserver to bootstrap services
log::info!("Waiting for botserver to bootstrap and become ready... (max {max_wait}s)");
for i in 0..max_wait {
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
if let Ok(resp) = reqwest::get(&format!("{url}/health")).await {
if resp.status().is_success() {
log::info!("Botserver is ready on port {}", port);
log::info!("Botserver is ready on port {port}");
return Ok(Self {
url,
port,
@ -792,7 +748,7 @@ impl BotServerInstance {
}
}
if i % 10 == 0 {
log::info!("Still waiting for botserver... ({}s)", i);
log::info!("Still waiting for botserver... ({i}s)");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
@ -807,17 +763,15 @@ impl BotServerInstance {
})
}
pub fn is_running(&self) -> bool {
#[must_use]
pub const fn is_running(&self) -> bool {
self.process.is_some()
}
/// Setup minimal config files so botserver thinks services are configured
fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> {
// Create directory config path
fn setup_test_stack_config(stack_path: &std::path::Path, ctx: &TestContext) -> Result<()> {
let directory_conf = stack_path.join("conf/directory");
std::fs::create_dir_all(&directory_conf)?;
// Create zitadel.yaml pointing to our mock Zitadel
let zitadel_config = format!(
r#"Log:
Level: info
@ -842,27 +796,22 @@ ExternalPort: {}
std::fs::write(directory_conf.join("zitadel.yaml"), zitadel_config)?;
log::info!("Created test zitadel.yaml config");
// Create system certificates directory
let certs_dir = stack_path.join("conf/system/certificates");
std::fs::create_dir_all(&certs_dir)?;
// Generate minimal self-signed certificates for API
Self::generate_test_certificates(&certs_dir)?;
Ok(())
}
/// Generate minimal test certificates
fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> {
fn generate_test_certificates(certs_dir: &std::path::Path) -> Result<()> {
use std::process::Command;
let api_dir = certs_dir.join("api");
std::fs::create_dir_all(&api_dir)?;
// Check if openssl is available
let openssl_check = Command::new("which").arg("openssl").output();
if openssl_check.map(|o| o.status.success()).unwrap_or(false) {
// Generate self-signed certificate using openssl
let key_path = api_dir.join("server.key");
let cert_path = api_dir.join("server.crt");
@ -914,12 +863,9 @@ impl TestHarness {
Self::setup_internal(TestConfig::use_existing_stack(), true).await
}
/// Kill all processes that might interfere with tests
/// This ensures a clean slate before starting test infrastructure
fn cleanup_existing_processes() {
log::info!("Cleaning up any existing stack processes before test...");
// List of process patterns to kill
let patterns = [
"botserver",
"botui",
@ -936,24 +882,19 @@ impl TestHarness {
];
for pattern in patterns {
// Use pkill to kill processes matching pattern
// Ignore errors - process might not exist
let _ = std::process::Command::new("pkill")
.args(["-9", "-f", pattern])
.output();
}
// Clean up browser profile directories using shell rm
let _ = std::process::Command::new("rm")
.args(["-rf", "/tmp/browser-test-*"])
.output();
// Clean up old test data directories (older than 1 hour)
let _ = std::process::Command::new("sh")
.args(["-c", "find ./tmp -maxdepth 1 -name 'bottest-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null"])
.output();
// Give processes time to terminate
std::thread::sleep(std::time::Duration::from_millis(1000));
log::info!("Process cleanup completed");
@ -962,14 +903,12 @@ impl TestHarness {
async fn setup_internal(config: TestConfig, use_existing_stack: bool) -> Result<TestContext> {
let _ = env_logger::builder().is_test(true).try_init();
// Clean up any existing processes that might interfere
// Skip if using existing stack (user wants to connect to running services)
if !use_existing_stack {
Self::cleanup_existing_processes();
}
let test_id = Uuid::new_v4();
let data_dir = PathBuf::from("./tmp").join(format!("bottest-{}", test_id));
let data_dir = PathBuf::from("./tmp").join(format!("bottest-{test_id}"));
std::fs::create_dir_all(&data_dir)?;
@ -987,11 +926,8 @@ impl TestHarness {
};
log::info!(
"Test {} allocated ports: {:?}, data_dir: {:?}, use_existing_stack: {}",
test_id,
ports,
data_dir,
use_existing_stack
"Test {test_id} allocated ports: {ports:?}, data_dir: {}, use_existing_stack: {use_existing_stack}",
data_dir.display()
);
let data_dir_str = data_dir.to_str().unwrap().to_string();
@ -1015,7 +951,7 @@ impl TestHarness {
log::info!("Starting PostgreSQL on port {}...", ctx.ports.postgres);
let pg = PostgresService::start(ctx.ports.postgres, &data_dir_str).await?;
if config.run_migrations {
pg.run_migrations().await?;
pg.run_migrations()?;
}
ctx.postgres = Some(pg);
}
@ -1050,11 +986,7 @@ impl TestHarness {
Self::setup(TestConfig::default()).await
}
/// Setup for full E2E tests - connects to existing running services by default
/// Set FRESH_STACK=1 env var to bootstrap a fresh stack instead
pub async fn full() -> Result<TestContext> {
// Default: use existing stack (user already has botserver running)
// Set FRESH_STACK=1 to bootstrap fresh stack from scratch
if std::env::var("FRESH_STACK").is_ok() {
Self::setup(TestConfig::full()).await
} else {
@ -1062,7 +994,6 @@ impl TestHarness {
}
}
/// Setup with botserver auto-installing all services
pub async fn with_auto_install() -> Result<TestContext> {
Self::setup(TestConfig::auto_install()).await
}
@ -1101,12 +1032,12 @@ mod tests {
#[test]
fn test_config_full() {
let config = TestConfig::full();
assert!(!config.postgres); // Botserver handles PostgreSQL
assert!(!config.minio); // Botserver handles MinIO
assert!(!config.redis); // Botserver handles Redis
assert!(!config.postgres);
assert!(!config.minio);
assert!(!config.redis);
assert!(config.mock_zitadel);
assert!(config.mock_llm);
assert!(!config.run_migrations); // Botserver handles migrations
assert!(!config.run_migrations);
}
#[test]

View file

@ -35,6 +35,7 @@ pub mod prelude {
mod tests {
#[test]
fn test_library_loads() {
assert!(true);
let version = env!("CARGO_PKG_VERSION");
assert!(!version.is_empty());
}
}

View file

@ -36,11 +36,11 @@ impl std::str::FromStr for TestSuite {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"unit" => Ok(TestSuite::Unit),
"integration" | "int" => Ok(TestSuite::Integration),
"e2e" | "end-to-end" => Ok(TestSuite::E2E),
"all" => Ok(TestSuite::All),
_ => Err(format!("Unknown test suite: {}", s)),
"unit" => Ok(Self::Unit),
"integration" | "int" => Ok(Self::Integration),
"e2e" | "end-to-end" => Ok(Self::E2E),
"all" => Ok(Self::All),
_ => Err(format!("Unknown test suite: {s}")),
}
}
}
@ -156,10 +156,10 @@ fn parse_args() -> Result<(RunnerConfig, bool, bool)> {
config.headed = true;
}
arg if !arg.starts_with('-') => {
config.suite = arg.parse().map_err(|e| anyhow::anyhow!("{}", e))?;
config.suite = arg.parse().map_err(|e| anyhow::anyhow!("{e}"))?;
}
other => {
anyhow::bail!("Unknown argument: {}", other);
anyhow::bail!("Unknown argument: {other}");
}
}
i += 1;
@ -193,6 +193,7 @@ pub struct TestResults {
}
impl TestResults {
#[must_use]
pub fn new(suite: &str) -> Self {
Self {
suite: suite.to_string(),
@ -204,7 +205,8 @@ impl TestResults {
}
}
pub fn success(&self) -> bool {
#[must_use]
pub const fn success(&self) -> bool {
self.failed == 0 && self.errors.is_empty()
}
}
@ -215,7 +217,7 @@ fn get_cache_dir() -> PathBuf {
}
fn get_chromedriver_path(version: &str) -> PathBuf {
get_cache_dir().join(format!("chromedriver-{}", version))
get_cache_dir().join(format!("chromedriver-{version}"))
}
fn get_chrome_path() -> PathBuf {
@ -260,13 +262,7 @@ fn detect_browser_version(browser_path: &str) -> Option<String> {
let parts: Vec<&str> = version_str.split_whitespace().collect();
for part in parts {
if part.contains('.')
&& part
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
if part.contains('.') && part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
let major = part.split('.').next()?;
return Some(major.to_string());
}
@ -276,7 +272,7 @@ fn detect_browser_version(browser_path: &str) -> Option<String> {
}
fn detect_chromedriver_for_version(major_version: &str) -> Option<PathBuf> {
let pattern = format!("chromedriver-{}", major_version);
let pattern = format!("chromedriver-{major_version}");
let cache_dir = get_cache_dir();
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
@ -312,7 +308,7 @@ async fn download_file(url: &str, dest: &PathBuf) -> Result<()> {
Ok(())
}
async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
info!("Extracting: {:?} to {:?}", zip_path, dest_dir);
let file = std::fs::File::open(zip_path)?;
@ -353,8 +349,7 @@ async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
async fn get_chromedriver_version_for_browser(major_version: &str) -> Result<String> {
let url = format!(
"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{}",
major_version
"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{major_version}"
);
info!("Fetching ChromeDriver version for Chrome {}", major_version);
@ -389,15 +384,13 @@ async fn setup_chromedriver(browser_path: &str) -> Result<PathBuf> {
let chrome_version = get_chromedriver_version_for_browser(&major_version).await?;
let chromedriver_url = format!(
"{}/{}/linux64/chromedriver-linux64.zip",
CHROMEDRIVER_URL, chrome_version
);
let chromedriver_url =
format!("{CHROMEDRIVER_URL}/{chrome_version}/linux64/chromedriver-linux64.zip");
let zip_path = cache_dir.join("chromedriver.zip");
download_file(&chromedriver_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?;
extract_zip(&zip_path, &cache_dir)?;
let extracted_driver = cache_dir.join("chromedriver-linux64").join("chromedriver");
let final_path = get_chromedriver_path(&major_version);
@ -441,15 +434,12 @@ async fn setup_chrome_for_testing() -> Result<PathBuf> {
.await
.unwrap_or_else(|_| "131.0.6778.204".to_string());
let chrome_url = format!(
"{}/{}/linux64/chrome-linux64.zip",
CHROMEDRIVER_URL, chrome_version
);
let chrome_url = format!("{CHROMEDRIVER_URL}/{chrome_version}/linux64/chrome-linux64.zip");
let zip_path = cache_dir.join("chrome.zip");
download_file(&chrome_url, &zip_path).await?;
extract_zip(&zip_path, &cache_dir).await?;
extract_zip(&zip_path, &cache_dir)?;
std::fs::remove_file(&zip_path).ok();
@ -485,7 +475,7 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
info!("Starting ChromeDriver on port {}...", port);
let child = std::process::Command::new(chromedriver_path)
.arg(format!("--port={}", port))
.arg(format!("--port={port}"))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
@ -502,14 +492,13 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
}
async fn check_webdriver_available(port: u16) -> bool {
let url = format!("http://localhost:{}/status", port);
let url = format!("http://localhost:{port}/status");
let client = match reqwest::Client::builder()
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return false,
else {
return false;
};
client.get(&url).send().await.is_ok()
@ -518,13 +507,12 @@ async fn check_webdriver_available(port: u16) -> bool {
async fn run_browser_demo() -> Result<()> {
info!("Running browser demo...");
// Use CDP directly via BrowserService
let debug_port = 9222u16;
let mut browser_service = match services::BrowserService::start(debug_port).await {
Ok(bs) => bs,
Err(e) => {
anyhow::bail!("Failed to start browser: {}", e);
anyhow::bail!("Failed to start browser: {e}");
}
};
@ -540,7 +528,7 @@ async fn run_browser_demo() -> Result<()> {
Ok(b) => b,
Err(e) => {
let _ = browser_service.stop().await;
anyhow::bail!("Failed to connect to browser CDP: {}", e);
anyhow::bail!("Failed to connect to browser CDP: {e}");
}
};
@ -558,7 +546,7 @@ async fn run_browser_demo() -> Result<()> {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
info!("Closing browser...");
let _ = browser.close().await;
let _ = browser.close();
let _ = browser_service.stop().await;
info!("Demo complete!");
@ -575,7 +563,7 @@ fn discover_test_files(test_dir: &str) -> Vec<String> {
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().map(|e| e == "rs").unwrap_or(false) {
if file_path.extension().is_some_and(|e| e == "rs") {
if let Some(name) = file_path.file_stem() {
let name_str = name.to_string_lossy().to_string();
if name_str != "mod" {
@ -626,7 +614,7 @@ fn run_cargo_test(
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
let combined = format!("{stdout}\n{stderr}");
let mut passed = 0usize;
let mut failed = 0usize;
@ -652,7 +640,7 @@ fn run_cargo_test(
Ok((passed, failed, skipped))
}
async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
info!("Running unit tests...");
let mut results = TestResults::new("unit");
@ -679,7 +667,7 @@ async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
Err(e) => {
results
.errors
.push(format!("Failed to run unit tests: {}", e));
.push(format!("Failed to run unit tests: {e}"));
results.failed = 1;
}
}
@ -712,7 +700,7 @@ async fn run_integration_tests(config: &RunnerConfig) -> Result<TestResults> {
Err(e) => {
error!("Failed to set up test harness: {}", e);
results.failed = 1;
results.errors.push(format!("Harness setup failed: {}", e));
results.errors.push(format!("Harness setup failed: {e}"));
return Ok(results);
}
};
@ -761,16 +749,16 @@ async fn run_integration_tests(config: &RunnerConfig) -> Result<TestResults> {
Err(e) => {
results
.errors
.push(format!("Failed to run integration tests: {}", e));
.push(format!("Failed to run integration tests: {e}"));
results.failed = 1;
}
}
if !config.keep_env {
info!("Cleaning up test environment...");
} else {
if config.keep_env {
info!("Keeping test environment for inspection (KEEP_ENV=1)");
info!(" Data dir: {:?}", ctx.data_dir);
} else {
info!("Cleaning up test environment...");
}
results.duration_ms = start.elapsed().as_millis() as u64;
@ -837,7 +825,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
results.failed = 1;
results
.errors
.push(format!("ChromeDriver start failed: {}", e));
.push(format!("ChromeDriver start failed: {e}"));
return Ok(results);
}
}
@ -852,7 +840,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
let _ = child.kill();
}
results.failed = 1;
results.errors.push(format!("Harness setup failed: {}", e));
results.errors.push(format!("Harness setup failed: {e}"));
return Ok(results);
}
};
@ -867,9 +855,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
let _ = child.kill();
}
results.failed = 1;
results
.errors
.push(format!("Botserver start failed: {}", e));
results.errors.push(format!("Botserver start failed: {e}"));
return Ok(results);
}
};
@ -898,7 +884,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
let directory_url = ctx.zitadel_url();
let server_url = server.url.clone();
let chrome_binary = chrome_path.to_string_lossy().to_string();
let webdriver_url = format!("http://localhost:{}", webdriver_port);
let webdriver_url = format!("http://localhost:{webdriver_port}");
let env_vars: Vec<(&str, &str)> = vec![
("DATABASE_URL", &db_url),
@ -920,9 +906,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
results.skipped = skipped;
}
Err(e) => {
results
.errors
.push(format!("Failed to run E2E tests: {}", e));
results.errors.push(format!("Failed to run E2E tests: {e}"));
results.failed = 1;
}
}
@ -933,12 +917,12 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
let _ = child.wait();
}
if !config.keep_env {
info!("Cleaning up test environment...");
} else {
if config.keep_env {
info!("Keeping test environment for inspection (KEEP_ENV=1)");
info!(" Server URL: {}", server.url);
info!(" Data dir: {:?}", ctx.data_dir);
} else {
info!("Cleaning up test environment...");
}
results.duration_ms = start.elapsed().as_millis() as u64;
@ -968,7 +952,7 @@ fn print_summary(results: &[TestResults]) {
);
for error in &result.errors {
println!(" ERROR: {}", error);
println!(" ERROR: {error}");
}
total_passed += result.passed;
@ -979,8 +963,7 @@ fn print_summary(results: &[TestResults]) {
println!("\n{}", "-".repeat(60));
println!(
"TOTAL: {} passed, {} failed, {} skipped ({} ms)",
total_passed, total_failed, total_skipped, total_duration
"TOTAL: {total_passed} passed, {total_failed} failed, {total_skipped} skipped ({total_duration} ms)"
);
println!("{}", "=".repeat(60));
@ -996,7 +979,7 @@ async fn main() -> ExitCode {
let (config, setup_only, demo_mode) = match parse_args() {
Ok(c) => c,
Err(e) => {
eprintln!("Error: {}", e);
eprintln!("Error: {e}");
print_usage();
return ExitCode::from(1);
}
@ -1014,12 +997,12 @@ async fn main() -> ExitCode {
match setup_test_dependencies().await {
Ok((chromedriver, chrome)) => {
println!("\n✅ Dependencies installed successfully!");
println!(" ChromeDriver: {:?}", chromedriver);
println!(" Browser: {:?}", chrome);
println!(" ChromeDriver: {}", chromedriver.display());
println!(" Browser: {}", chrome.display());
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!("\n❌ Setup failed: {}", e);
eprintln!("\n❌ Setup failed: {e}");
return ExitCode::from(1);
}
}
@ -1028,12 +1011,12 @@ async fn main() -> ExitCode {
if demo_mode {
info!("Running browser demo...");
match run_browser_demo().await {
Ok(_) => {
Ok(()) => {
println!("\n✅ Browser demo completed successfully!");
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!("\n❌ Browser demo failed: {}", e);
eprintln!("\n❌ Browser demo failed: {e}");
return ExitCode::from(1);
}
}
@ -1045,11 +1028,11 @@ async fn main() -> ExitCode {
let mut all_results = Vec::new();
let result = match config.suite {
TestSuite::Unit => run_unit_tests(&config).await,
TestSuite::Unit => run_unit_tests(&config),
TestSuite::Integration => run_integration_tests(&config).await,
TestSuite::E2E => run_e2e_tests(&config).await,
TestSuite::All => {
let unit = run_unit_tests(&config).await;
let unit = run_unit_tests(&config);
let integration = run_integration_tests(&config).await;
let e2e = run_e2e_tests(&config).await;
@ -1086,7 +1069,7 @@ async fn main() -> ExitCode {
print_summary(&all_results);
let all_passed = all_results.iter().all(|r| r.success());
let all_passed = all_results.iter().all(TestResults::success);
if all_passed {
ExitCode::SUCCESS
} else {

View file

@ -1,6 +1,7 @@
use super::{new_expectation_store, Expectation, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -89,6 +90,7 @@ struct ChatChoice {
}
#[derive(Serialize)]
#[allow(clippy::struct_field_names)]
struct Usage {
prompt_tokens: u32,
completion_tokens: u32,
@ -167,7 +169,7 @@ struct ErrorDetail {
impl MockLLM {
pub async fn start(port: u16) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockLLM port")?;
let server = MockServer::builder().listener(listener).start().await;
@ -219,11 +221,13 @@ impl MockLLM {
.unwrap()
.push(expectation.clone());
let mut store = self.expectations.lock().unwrap();
store.insert(
format!("completion:{}", prompt_contains),
Expectation::new(&format!("completion containing '{}'", prompt_contains)),
);
{
let mut store = self.expectations.lock().unwrap();
store.insert(
format!("completion:{prompt_contains}"),
Expectation::new(&format!("completion containing '{prompt_contains}'")),
);
}
let response_text = response.to_string();
let model = self.default_model.clone();
@ -253,7 +257,8 @@ impl MockLLM {
let mut template = ResponseTemplate::new(200).set_body_json(&response_body);
if let Some(delay) = *latency.lock().unwrap() {
let latency_value = *latency.lock().unwrap();
if let Some(delay) = latency_value {
template = template.set_delay(delay);
}
@ -274,7 +279,7 @@ impl MockLLM {
prompt_contains: Some(prompt_contains.to_string()),
response: chunks.join(""),
stream: true,
chunks: chunks.iter().map(|s| s.to_string()).collect(),
chunks: chunks.iter().map(|s| (*s).to_string()).collect(),
tool_calls: Vec::new(),
};
@ -303,10 +308,11 @@ impl MockLLM {
finish_reason: None,
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&first_chunk).unwrap()
));
);
for chunk_text in &chunks {
let chunk = StreamChunk {
@ -318,15 +324,16 @@ impl MockLLM {
index: 0,
delta: StreamDelta {
role: None,
content: Some(chunk_text.to_string()),
content: Some((*chunk_text).to_string()),
},
finish_reason: None,
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&chunk).unwrap()
));
);
}
let final_chunk = StreamChunk {
@ -343,10 +350,11 @@ impl MockLLM {
finish_reason: Some("stop".to_string()),
}],
};
sse_body.push_str(&format!(
"data: {}\n\n",
let _ = writeln!(
sse_body,
"data: {}\n",
serde_json::to_string(&final_chunk).unwrap()
));
);
sse_body.push_str("data: [DONE]\n\n");
let template = ResponseTemplate::new(200)
@ -470,7 +478,7 @@ impl MockLLM {
error: ErrorDetail {
message: message.to_string(),
r#type: "error".to_string(),
code: format!("error_{}", status),
code: format!("error_{status}"),
},
};
@ -563,11 +571,13 @@ impl MockLLM {
.await;
}
#[must_use]
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
@ -594,19 +604,14 @@ impl MockLLM {
}
pub async fn call_count(&self) -> usize {
self.server
.received_requests()
.await
.map(|r| r.len())
.unwrap_or(0)
self.server.received_requests().await.map_or(0, |r| r.len())
}
pub async fn assert_called_times(&self, expected: usize) {
let actual = self.call_count().await;
assert_eq!(
actual, expected,
"Expected {} calls to MockLLM, but got {}",
expected, actual
"Expected {expected} calls to MockLLM, but got {actual}"
);
}
@ -620,7 +625,7 @@ impl MockLLM {
pub async fn assert_not_called(&self) {
let count = self.call_count().await;
assert_eq!(count, 0, "Expected no calls to MockLLM, but got {}", count);
assert_eq!(count, 0, "Expected no calls to MockLLM, but got {count}");
}
}
@ -649,7 +654,7 @@ mod tests {
let response = ChatCompletionResponse {
id: "test-id".to_string(),
object: "chat.completion".to_string(),
created: 1234567890,
created: 1_234_567_890,
model: "gpt-4".to_string(),
choices: vec![ChatChoice {
index: 0,

View file

@ -1,10 +1,3 @@
//! Mock servers for testing external service integrations
//!
//! Provides mock implementations of:
//! - LLM API (OpenAI-compatible)
//! - WhatsApp Business API
//! - Microsoft Teams Bot Framework
//! - Zitadel Auth/OIDC
mod llm;
mod teams;
@ -20,7 +13,6 @@ use anyhow::Result;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Registry of all mock servers for a test
pub struct MockRegistry {
pub llm: Option<MockLLM>,
pub whatsapp: Option<MockWhatsApp>,
@ -29,8 +21,8 @@ pub struct MockRegistry {
}
impl MockRegistry {
/// Create an empty registry
pub fn new() -> Self {
#[must_use]
pub const fn new() -> Self {
Self {
llm: None,
whatsapp: None,
@ -39,27 +31,26 @@ impl MockRegistry {
}
}
/// Get the LLM mock, panics if not configured
pub fn llm(&self) -> &MockLLM {
#[must_use]
pub const fn llm(&self) -> &MockLLM {
self.llm.as_ref().expect("LLM mock not configured")
}
/// Get the WhatsApp mock, panics if not configured
pub fn whatsapp(&self) -> &MockWhatsApp {
#[must_use]
pub const fn whatsapp(&self) -> &MockWhatsApp {
self.whatsapp.as_ref().expect("WhatsApp mock not configured")
}
/// Get the Teams mock, panics if not configured
pub fn teams(&self) -> &MockTeams {
#[must_use]
pub const fn teams(&self) -> &MockTeams {
self.teams.as_ref().expect("Teams mock not configured")
}
/// Get the Zitadel mock, panics if not configured
pub fn zitadel(&self) -> &MockZitadel {
#[must_use]
pub const fn zitadel(&self) -> &MockZitadel {
self.zitadel.as_ref().expect("Zitadel mock not configured")
}
/// Verify all mock expectations were met
pub fn verify_all(&self) -> Result<()> {
if let Some(ref llm) = self.llm {
llm.verify()?;
@ -76,7 +67,6 @@ impl MockRegistry {
Ok(())
}
/// Reset all mock servers
pub async fn reset_all(&self) {
if let Some(ref llm) = self.llm {
llm.reset().await;
@ -99,7 +89,6 @@ impl Default for MockRegistry {
}
}
/// Expectation tracking for mock verification
#[derive(Debug, Clone)]
pub struct Expectation {
pub name: String,
@ -109,6 +98,7 @@ pub struct Expectation {
}
impl Expectation {
#[must_use]
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
@ -118,12 +108,13 @@ impl Expectation {
}
}
pub fn times(mut self, n: usize) -> Self {
#[must_use]
pub const fn times(mut self, n: usize) -> Self {
self.expected_calls = Some(n);
self
}
pub fn record_call(&mut self) {
pub const fn record_call(&mut self) {
self.actual_calls += 1;
self.matched = true;
}
@ -143,10 +134,9 @@ impl Expectation {
}
}
/// Shared state for tracking expectations across async handlers
pub type ExpectationStore = Arc<Mutex<HashMap<String, Expectation>>>;
/// Create a new expectation store
#[must_use]
pub fn new_expectation_store() -> ExpectationStore {
Arc::new(Mutex::new(HashMap::new()))
}

View file

@ -1,8 +1,3 @@
//! Mock Microsoft Teams Bot Framework server for testing
//!
//! Provides a mock server that simulates the Microsoft Bot Framework API
//! for Teams integration testing, including activities, conversations, and attachments.
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -12,7 +7,6 @@ use uuid::Uuid;
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock Teams Bot Framework server
pub struct MockTeams {
server: MockServer,
port: u16,
@ -25,9 +19,9 @@ pub struct MockTeams {
service_url: String,
}
/// Bot Framework Activity
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Activity {
#[serde(rename = "type")]
pub activity_type: String,
@ -88,7 +82,6 @@ impl Default for Activity {
}
}
/// Channel account (user or bot)
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ChannelAccount {
@ -101,7 +94,6 @@ pub struct ChannelAccount {
pub role: Option<String>,
}
/// Conversation account
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ConversationAccount {
@ -116,7 +108,6 @@ pub struct ConversationAccount {
pub tenant_id: Option<String>,
}
/// Attachment in an activity
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Attachment {
@ -131,9 +122,9 @@ pub struct Attachment {
pub thumbnail_url: Option<String>,
}
/// Entity in an activity (mentions, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Entity {
#[serde(rename = "type")]
pub entity_type: String,
@ -145,7 +136,6 @@ pub struct Entity {
pub additional: HashMap<String, serde_json::Value>,
}
/// Conversation information stored by the mock
#[derive(Debug, Clone)]
pub struct ConversationInfo {
pub id: String,
@ -155,13 +145,11 @@ pub struct ConversationInfo {
pub is_group: bool,
}
/// Resource response from sending an activity
#[derive(Debug, Serialize, Deserialize)]
pub struct ResourceResponse {
pub id: String,
}
/// Conversations result
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationsResult {
@ -170,14 +158,12 @@ pub struct ConversationsResult {
pub conversations: Vec<ConversationMembers>,
}
/// Conversation members
#[derive(Debug, Serialize, Deserialize)]
pub struct ConversationMembers {
pub id: String,
pub members: Vec<ChannelAccount>,
}
/// Teams channel account (extended)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamsChannelAccount {
@ -198,7 +184,6 @@ pub struct TeamsChannelAccount {
pub surname: Option<String>,
}
/// Teams meeting info
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamsMeetingInfo {
@ -209,7 +194,6 @@ pub struct TeamsMeetingInfo {
pub title: Option<String>,
}
/// Adaptive card action response
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdaptiveCardInvokeResponse {
@ -220,20 +204,17 @@ pub struct AdaptiveCardInvokeResponse {
pub value: Option<serde_json::Value>,
}
/// Error response
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: ErrorBody,
}
/// Error body
#[derive(Debug, Serialize, Deserialize)]
pub struct ErrorBody {
pub code: String,
pub message: String,
}
/// Invoke response for bot actions
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvokeResponse {
@ -243,22 +224,18 @@ pub struct InvokeResponse {
}
impl MockTeams {
/// Default bot ID for testing
pub const DEFAULT_BOT_ID: &'static str = "28:test-bot-id";
/// Default bot name
pub const DEFAULT_BOT_NAME: &'static str = "TestBot";
/// Default tenant ID
pub const DEFAULT_TENANT_ID: &'static str = "test-tenant-id";
/// Start a new mock Teams server on the specified port
pub async fn start(port: u16) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockTeams port")?;
let server = MockServer::builder().listener(listener).start().await;
let service_url = format!("http://127.0.0.1:{}", port);
let service_url = format!("http://127.0.0.1:{port}");
let mock = Self {
server,
@ -277,18 +254,17 @@ impl MockTeams {
Ok(mock)
}
/// Start with custom bot configuration
pub async fn start_with_config(
port: u16,
bot_id: &str,
bot_name: &str,
tenant_id: &str,
) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockTeams port")?;
let server = MockServer::builder().listener(listener).start().await;
let service_url = format!("http://127.0.0.1:{}", port);
let service_url = format!("http://127.0.0.1:{port}");
let mock = Self {
server,
@ -307,11 +283,9 @@ impl MockTeams {
Ok(mock)
}
/// Set up default API routes
async fn setup_default_routes(&self) {
let sent_activities = self.sent_activities.clone();
// Send to conversation endpoint
Mock::given(method("POST"))
.and(path_regex(r"/v3/conversations/.+/activities"))
.respond_with(move |req: &wiremock::Request| {
@ -345,16 +319,13 @@ impl MockTeams {
sent_activities.lock().unwrap().push(activity.clone());
let response = ResourceResponse {
id: activity.id.clone(),
};
let response = ResourceResponse { id: activity.id };
ResponseTemplate::new(200).set_body_json(&response)
})
.mount(&self.server)
.await;
// Reply to activity endpoint
Mock::given(method("POST"))
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(|_req: &wiremock::Request| {
@ -366,7 +337,6 @@ impl MockTeams {
.mount(&self.server)
.await;
// Update activity endpoint
Mock::given(method("PUT"))
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(|_req: &wiremock::Request| {
@ -378,14 +348,12 @@ impl MockTeams {
.mount(&self.server)
.await;
// Delete activity endpoint
Mock::given(method("DELETE"))
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(ResponseTemplate::new(200))
.mount(&self.server)
.await;
// Get conversation members endpoint
Mock::given(method("GET"))
.and(path_regex(r"/v3/conversations/.+/members"))
.respond_with(|_req: &wiremock::Request| {
@ -404,7 +372,6 @@ impl MockTeams {
.mount(&self.server)
.await;
// Get single member endpoint
Mock::given(method("GET"))
.and(path_regex(r"/v3/conversations/.+/members/.+"))
.respond_with(|_req: &wiremock::Request| {
@ -423,7 +390,6 @@ impl MockTeams {
.mount(&self.server)
.await;
// Create conversation endpoint
Mock::given(method("POST"))
.and(path("/v3/conversations"))
.respond_with(|_req: &wiremock::Request| {
@ -439,7 +405,6 @@ impl MockTeams {
.mount(&self.server)
.await;
// Get conversations endpoint
Mock::given(method("GET"))
.and(path("/v3/conversations"))
.respond_with(|_req: &wiremock::Request| {
@ -452,7 +417,6 @@ impl MockTeams {
.mount(&self.server)
.await;
// Token endpoint (for bot authentication simulation)
Mock::given(method("POST"))
.and(path("/botframework.com/oauth2/v2.0/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -464,7 +428,7 @@ impl MockTeams {
.await;
}
/// Simulate an incoming message from a user
#[must_use]
pub fn simulate_message(&self, from_id: &str, from_name: &str, text: &str) -> Activity {
let conversation_id = format!("conv-{}", Uuid::new_v4());
@ -511,12 +475,12 @@ impl MockTeams {
}
}
/// Simulate an incoming message with a mention
#[must_use]
pub fn simulate_mention(&self, from_id: &str, from_name: &str, text: &str) -> Activity {
let mut activity = self.simulate_message(from_id, from_name, text);
let mention_text = format!("<at>{}</at>", self.bot_name);
activity.text = Some(format!("{} {}", mention_text, text));
activity.text = Some(format!("{mention_text} {text}"));
activity.entities = Some(vec![Entity {
entity_type: "mention".to_string(),
@ -533,7 +497,7 @@ impl MockTeams {
activity
}
/// Simulate a conversation update (member added)
#[must_use]
pub fn simulate_member_added(&self, member_id: &str, member_name: &str) -> Activity {
let conversation_id = format!("conv-{}", Uuid::new_v4());
@ -581,7 +545,7 @@ impl MockTeams {
}
}
/// Simulate an invoke activity (adaptive card action, etc.)
#[must_use]
pub fn simulate_invoke(
&self,
from_id: &str,
@ -634,7 +598,7 @@ impl MockTeams {
}
}
/// Simulate an adaptive card action
#[must_use]
pub fn simulate_adaptive_card_action(
&self,
from_id: &str,
@ -655,7 +619,7 @@ impl MockTeams {
)
}
/// Simulate a message reaction
#[must_use]
pub fn simulate_reaction(
&self,
from_id: &str,
@ -708,7 +672,6 @@ impl MockTeams {
}
}
/// Expect an error response for the next request
pub async fn expect_error(&self, code: &str, message: &str) {
let error_response = ErrorResponse {
error: ErrorBody {
@ -724,45 +687,41 @@ impl MockTeams {
.await;
}
/// Expect an unauthorized error
pub async fn expect_unauthorized(&self) {
self.expect_error("Unauthorized", "Token validation failed")
.await;
}
/// Expect a not found error
pub async fn expect_not_found(&self) {
self.expect_error("NotFound", "Conversation not found")
.await;
}
/// Get all sent activities
#[must_use]
pub fn sent_activities(&self) -> Vec<Activity> {
self.sent_activities.lock().unwrap().clone()
}
/// Get sent activities with specific text
#[must_use]
pub fn sent_activities_containing(&self, text: &str) -> Vec<Activity> {
self.sent_activities
.lock()
.unwrap()
.iter()
.filter(|a| a.text.as_ref().map(|t| t.contains(text)).unwrap_or(false))
.filter(|a| a.text.as_ref().is_some_and(|t| t.contains(text)))
.cloned()
.collect()
}
/// Get the last sent activity
#[must_use]
pub fn last_sent_activity(&self) -> Option<Activity> {
self.sent_activities.lock().unwrap().last().cloned()
}
/// Clear sent activities
pub fn clear_sent_activities(&self) {
self.sent_activities.lock().unwrap().clear();
}
/// Register a conversation
pub fn register_conversation(&self, info: ConversationInfo) {
self.conversations
.lock()
@ -770,37 +729,36 @@ impl MockTeams {
.insert(info.id.clone(), info);
}
/// Get the server URL
#[must_use]
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
/// Get the service URL (same as server URL)
#[must_use]
pub fn service_url(&self) -> String {
self.service_url.clone()
}
/// Get the port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Get the bot ID
#[must_use]
pub fn bot_id(&self) -> &str {
&self.bot_id
}
/// Get the bot name
#[must_use]
pub fn bot_name(&self) -> &str {
&self.bot_name
}
/// Get the tenant ID
#[must_use]
pub fn tenant_id(&self) -> &str {
&self.tenant_id
}
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() {
@ -809,7 +767,6 @@ impl MockTeams {
Ok(())
}
/// Reset all mocks
pub async fn reset(&self) {
self.server.reset().await;
self.sent_activities.lock().unwrap().clear();
@ -818,13 +775,11 @@ impl MockTeams {
self.setup_default_routes().await;
}
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default()
}
}
/// Helper to create an adaptive card attachment
pub fn adaptive_card(content: serde_json::Value) -> Attachment {
Attachment {
content_type: "application/vnd.microsoft.card.adaptive".to_string(),
@ -835,7 +790,6 @@ pub fn adaptive_card(content: serde_json::Value) -> Attachment {
}
}
/// Helper to create a hero card attachment
pub fn hero_card(title: &str, subtitle: Option<&str>, text: Option<&str>) -> Attachment {
Attachment {
content_type: "application/vnd.microsoft.card.hero".to_string(),
@ -850,7 +804,6 @@ pub fn hero_card(title: &str, subtitle: Option<&str>, text: Option<&str>) -> Att
}
}
/// Helper to create a thumbnail card attachment
pub fn thumbnail_card(
title: &str,
subtitle: Option<&str>,

View file

@ -1,8 +1,3 @@
//! Mock WhatsApp Business API server for testing
//!
//! Provides a mock server that simulates the WhatsApp Business API
//! including message sending, template messages, and webhook events.
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -11,7 +6,6 @@ use uuid::Uuid;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock WhatsApp Business API server
pub struct MockWhatsApp {
server: MockServer,
port: u16,
@ -23,7 +17,6 @@ pub struct MockWhatsApp {
access_token: String,
}
/// A message that was "sent" through the mock
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentMessage {
pub id: String,
@ -33,8 +26,7 @@ pub struct SentMessage {
pub timestamp: u64,
}
/// Type of message
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
Text,
@ -49,7 +41,6 @@ pub enum MessageType {
Reaction,
}
/// Message content variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
@ -80,28 +71,24 @@ pub enum MessageContent {
},
}
/// Webhook event from WhatsApp
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEvent {
pub object: String,
pub entry: Vec<WebhookEntry>,
}
/// Entry in a webhook event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEntry {
pub id: String,
pub changes: Vec<WebhookChange>,
}
/// Change in a webhook entry
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookChange {
pub value: WebhookValue,
pub field: String,
}
/// Value in a webhook change
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookValue {
pub messaging_product: String,
@ -114,27 +101,23 @@ pub struct WebhookValue {
pub statuses: Option<Vec<MessageStatus>>,
}
/// Metadata in webhook
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookMetadata {
pub display_phone_number: String,
pub phone_number_id: String,
}
/// Contact in webhook
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookContact {
pub profile: ContactProfile,
pub wa_id: String,
}
/// Contact profile
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactProfile {
pub name: String,
}
/// Incoming message from webhook
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage {
pub from: String,
@ -154,13 +137,11 @@ pub struct IncomingMessage {
pub interactive: Option<InteractiveReply>,
}
/// Text message content
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextMessage {
pub body: String,
}
/// Media message content
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMessage {
#[serde(skip_serializing_if = "Option::is_none")]
@ -173,14 +154,12 @@ pub struct MediaMessage {
pub caption: Option<String>,
}
/// Button reply
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ButtonReply {
pub payload: String,
pub text: String,
}
/// Interactive reply (list/button)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractiveReply {
#[serde(rename = "type")]
@ -191,14 +170,12 @@ pub struct InteractiveReply {
pub list_reply: Option<ListReplyContent>,
}
/// Button reply content
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ButtonReplyContent {
pub id: String,
pub title: String,
}
/// List reply content
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListReplyContent {
pub id: String,
@ -207,7 +184,6 @@ pub struct ListReplyContent {
pub description: Option<String>,
}
/// Message status update
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageStatus {
pub id: String,
@ -220,7 +196,6 @@ pub struct MessageStatus {
pub pricing: Option<Pricing>,
}
/// Conversation info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation {
pub id: String,
@ -228,22 +203,20 @@ pub struct Conversation {
pub origin: Option<ConversationOrigin>,
}
/// Conversation origin
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationOrigin {
#[serde(rename = "type")]
pub origin_type: String,
}
/// Pricing info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pricing {
pub billable: bool,
pub pricing_model: String,
#[serde(alias = "pricing_model")]
pub model: String,
pub category: String,
}
/// Send message API request
#[derive(Debug, Deserialize)]
struct SendMessageRequest {
messaging_product: String,
@ -255,7 +228,6 @@ struct SendMessageRequest {
content: serde_json::Value,
}
/// Send message API response
#[derive(Serialize)]
struct SendMessageResponse {
messaging_product: String,
@ -263,26 +235,22 @@ struct SendMessageResponse {
messages: Vec<MessageResponse>,
}
/// Contact in send response
#[derive(Serialize)]
struct ContactResponse {
input: String,
wa_id: String,
}
/// Message in send response
#[derive(Serialize)]
struct MessageResponse {
id: String,
}
/// Error response
#[derive(Serialize)]
struct ErrorResponse {
error: ErrorDetail,
}
/// Error detail
#[derive(Serialize)]
struct ErrorDetail {
message: String,
@ -292,7 +260,6 @@ struct ErrorDetail {
fbtrace_id: String,
}
/// Expectation builder for message sending
pub struct MessageExpectation {
to: String,
message_type: Option<MessageType>,
@ -300,20 +267,17 @@ pub struct MessageExpectation {
}
impl MessageExpectation {
/// Expect a specific message type
pub fn of_type(mut self, t: MessageType) -> Self {
pub const fn of_type(mut self, t: MessageType) -> Self {
self.message_type = Some(t);
self
}
/// Expect message to contain specific text
pub fn containing(mut self, text: &str) -> Self {
self.contains = Some(text.to_string());
self
}
}
/// Expectation builder for template messages
pub struct TemplateExpectation {
name: String,
to: Option<String>,
@ -321,13 +285,11 @@ pub struct TemplateExpectation {
}
impl TemplateExpectation {
/// Expect template to be sent to specific number
pub fn to(mut self, phone: &str) -> Self {
self.to = Some(phone.to_string());
self
}
/// Expect template with specific language
pub fn with_language(mut self, lang: &str) -> Self {
self.language = Some(lang.to_string());
self
@ -335,18 +297,14 @@ impl TemplateExpectation {
}
impl MockWhatsApp {
/// Default phone number ID for testing
pub const DEFAULT_PHONE_NUMBER_ID: &'static str = "123456789012345";
/// Default business account ID
pub const DEFAULT_BUSINESS_ACCOUNT_ID: &'static str = "987654321098765";
/// Default access token
pub const DEFAULT_ACCESS_TOKEN: &'static str = "test_access_token_12345";
/// Start a new mock WhatsApp server on the specified port
pub async fn start(port: u16) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockWhatsApp port")?;
let server = MockServer::builder().listener(listener).start().await;
@ -367,14 +325,13 @@ impl MockWhatsApp {
Ok(mock)
}
/// Start with custom configuration
pub async fn start_with_config(
port: u16,
phone_number_id: &str,
business_account_id: &str,
access_token: &str,
) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockWhatsApp port")?;
let server = MockServer::builder().listener(listener).start().await;
@ -395,11 +352,8 @@ impl MockWhatsApp {
Ok(mock)
}
/// Set up default API routes
async fn setup_default_routes(&self) {
// Send message endpoint
let sent_messages = self.sent_messages.clone();
let _phone_id = self.phone_number_id.clone();
Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/messages"))
@ -408,7 +362,7 @@ impl MockWhatsApp {
let to = body.get("to").and_then(|v| v.as_str()).unwrap_or("unknown");
let msg_type = body.get("type").and_then(|v| v.as_str()).unwrap_or("text");
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -458,7 +412,6 @@ impl MockWhatsApp {
id: message_id.clone(),
to: to.to_string(),
message_type: match msg_type {
"text" => MessageType::Text,
"template" => MessageType::Template,
"image" => MessageType::Image,
"document" => MessageType::Document,
@ -489,7 +442,6 @@ impl MockWhatsApp {
.mount(&self.server)
.await;
// Media upload endpoint
Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/media"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -498,7 +450,6 @@ impl MockWhatsApp {
.mount(&self.server)
.await;
// Media download endpoint
Mock::given(method("GET"))
.and(path_regex(r"/v\d+\.\d+/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -511,7 +462,6 @@ impl MockWhatsApp {
.mount(&self.server)
.await;
// Business profile endpoint
Mock::given(method("GET"))
.and(path_regex(r"/v\d+\.\d+/\d+/whatsapp_business_profile"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -529,8 +479,9 @@ impl MockWhatsApp {
.await;
}
/// Expect a message to be sent to a specific number
#[must_use]
pub fn expect_send_message(&self, to: &str) -> MessageExpectation {
let _ = self;
MessageExpectation {
to: to.to_string(),
message_type: None,
@ -538,8 +489,9 @@ impl MockWhatsApp {
}
}
/// Expect a template message to be sent
#[must_use]
pub fn expect_send_template(&self, name: &str) -> TemplateExpectation {
let _ = self;
TemplateExpectation {
name: name.to_string(),
to: None,
@ -547,9 +499,8 @@ impl MockWhatsApp {
}
}
/// Simulate an incoming message
pub fn simulate_incoming(&self, from: &str, text: &str) -> Result<WebhookEvent> {
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
@ -597,14 +548,13 @@ impl MockWhatsApp {
Ok(event)
}
/// Simulate an incoming image message
pub fn simulate_incoming_image(
&self,
from: &str,
media_id: &str,
caption: Option<&str>,
) -> Result<WebhookEvent> {
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
@ -638,7 +588,7 @@ impl MockWhatsApp {
id: Some(media_id.to_string()),
mime_type: Some("image/jpeg".to_string()),
sha256: Some("abc123".to_string()),
caption: caption.map(|c| c.to_string()),
caption: caption.map(std::string::ToString::to_string),
}),
document: None,
button: None,
@ -655,14 +605,13 @@ impl MockWhatsApp {
Ok(event)
}
/// Simulate a button reply
pub fn simulate_button_reply(
&self,
from: &str,
button_id: &str,
button_text: &str,
) -> Result<WebhookEvent> {
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
@ -715,13 +664,11 @@ impl MockWhatsApp {
Ok(event)
}
/// Simulate a webhook event
pub fn simulate_webhook(&self, event: WebhookEvent) -> Result<()> {
self.received_webhooks.lock().unwrap().push(event);
Ok(())
}
/// Simulate a message status update
pub fn simulate_status(
&self,
message_id: &str,
@ -760,7 +707,7 @@ impl MockWhatsApp {
}),
pricing: Some(Pricing {
billable: true,
pricing_model: "CBP".to_string(),
model: "CBP".to_string(),
category: "business_initiated".to_string(),
}),
}]),
@ -774,7 +721,6 @@ impl MockWhatsApp {
Ok(event)
}
/// Expect an error response for the next request
pub async fn expect_error(&self, code: u32, message: &str) {
let error_response = ErrorResponse {
error: ErrorDetail {
@ -792,12 +738,10 @@ impl MockWhatsApp {
.await;
}
/// Expect rate limit error
pub async fn expect_rate_limit(&self) {
self.expect_error(80007, "Rate limit hit").await;
}
/// Expect invalid token error
pub async fn expect_invalid_token(&self) {
let error_response = ErrorResponse {
error: ErrorDetail {
@ -815,12 +759,12 @@ impl MockWhatsApp {
.await;
}
/// Get all sent messages
#[must_use]
pub fn sent_messages(&self) -> Vec<SentMessage> {
self.sent_messages.lock().unwrap().clone()
}
/// Get sent messages to a specific number
#[must_use]
pub fn sent_messages_to(&self, phone: &str) -> Vec<SentMessage> {
self.sent_messages
.lock()
@ -831,47 +775,45 @@ impl MockWhatsApp {
.collect()
}
/// Get the last sent message
#[must_use]
pub fn last_sent_message(&self) -> Option<SentMessage> {
self.sent_messages.lock().unwrap().last().cloned()
}
/// Clear sent messages
pub fn clear_sent_messages(&self) {
self.sent_messages.lock().unwrap().clear();
}
/// Get the server URL
#[must_use]
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
/// Get the Graph API base URL
#[must_use]
pub fn graph_api_url(&self) -> String {
format!("http://127.0.0.1:{}/v17.0", self.port)
}
/// Get the port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Get the phone number ID
#[must_use]
pub fn phone_number_id(&self) -> &str {
&self.phone_number_id
}
/// Get the business account ID
#[must_use]
pub fn business_account_id(&self) -> &str {
&self.business_account_id
}
/// Get the access token
#[must_use]
pub fn access_token(&self) -> &str {
&self.access_token
}
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() {
@ -880,7 +822,6 @@ impl MockWhatsApp {
Ok(())
}
/// Reset all mocks
pub async fn reset(&self) {
self.server.reset().await;
self.sent_messages.lock().unwrap().clear();
@ -889,7 +830,6 @@ impl MockWhatsApp {
self.setup_default_routes().await;
}
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default()
}

View file

@ -1,8 +1,3 @@
//! Mock Zitadel server for testing OIDC/Auth flows
//!
//! Provides a mock authentication server that simulates Zitadel's OIDC endpoints
//! including login, token issuance, refresh, and introspection.
use super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
@ -13,7 +8,6 @@ use uuid::Uuid;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock Zitadel server for OIDC testing
pub struct MockZitadel {
server: MockServer,
port: u16,
@ -23,7 +17,6 @@ pub struct MockZitadel {
issuer: String,
}
/// Test user for authentication
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestUser {
pub id: String,
@ -47,7 +40,6 @@ impl Default for TestUser {
}
}
/// Token information stored by the mock
#[derive(Debug, Clone)]
struct TokenInfo {
user_id: String,
@ -58,7 +50,6 @@ struct TokenInfo {
active: bool,
}
/// Token response from authorization endpoints
#[derive(Serialize)]
struct TokenResponse {
access_token: String,
@ -71,7 +62,6 @@ struct TokenResponse {
scope: String,
}
/// OIDC discovery document
#[derive(Serialize)]
struct OIDCDiscovery {
issuer: String,
@ -89,7 +79,6 @@ struct OIDCDiscovery {
claims_supported: Vec<String>,
}
/// Introspection response
#[derive(Serialize)]
struct IntrospectionResponse {
active: bool,
@ -113,7 +102,6 @@ struct IntrospectionResponse {
iss: Option<String>,
}
/// User info response
#[derive(Serialize)]
struct UserInfoResponse {
sub: String,
@ -125,7 +113,6 @@ struct UserInfoResponse {
roles: Option<Vec<String>>,
}
/// Error response
#[derive(Serialize)]
struct ErrorResponse {
error: String,
@ -133,13 +120,12 @@ struct ErrorResponse {
}
impl MockZitadel {
/// Start a new mock Zitadel server on the specified port
pub async fn start(port: u16) -> Result<Self> {
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
.context("Failed to bind MockZitadel port")?;
let server = MockServer::builder().listener(listener).start().await;
let issuer = format!("http://127.0.0.1:{}", port);
let issuer = format!("http://127.0.0.1:{port}");
let mock = Self {
server,
@ -156,18 +142,17 @@ impl MockZitadel {
Ok(mock)
}
/// Set up the OIDC discovery endpoint
async fn setup_discovery_endpoint(&self) {
let base_url = self.url();
let discovery = OIDCDiscovery {
issuer: base_url.clone(),
authorization_endpoint: format!("{}/oauth/v2/authorize", base_url),
token_endpoint: format!("{}/oauth/v2/token", base_url),
userinfo_endpoint: format!("{}/oidc/v1/userinfo", base_url),
introspection_endpoint: format!("{}/oauth/v2/introspect", base_url),
revocation_endpoint: format!("{}/oauth/v2/revoke", base_url),
jwks_uri: format!("{}/oauth/v2/keys", base_url),
authorization_endpoint: format!("{base_url}/oauth/v2/authorize"),
token_endpoint: format!("{base_url}/oauth/v2/token"),
userinfo_endpoint: format!("{base_url}/oidc/v1/userinfo"),
introspection_endpoint: format!("{base_url}/oauth/v2/introspect"),
revocation_endpoint: format!("{base_url}/oauth/v2/revoke"),
jwks_uri: format!("{base_url}/oauth/v2/keys"),
response_types_supported: vec![
"code".to_string(),
"token".to_string(),
@ -210,9 +195,7 @@ impl MockZitadel {
.await;
}
/// Set up the JWKS endpoint with a mock key
async fn setup_jwks_endpoint(&self) {
// Simple mock JWKS - in production this would be a real RSA key
let jwks = serde_json::json!({
"keys": [{
"kty": "RSA",
@ -231,7 +214,7 @@ impl MockZitadel {
.await;
}
/// Create a test user and return their ID
#[must_use]
pub fn create_test_user(&self, email: &str) -> TestUser {
let user = TestUser {
id: Uuid::new_v4().to_string(),
@ -248,7 +231,7 @@ impl MockZitadel {
user
}
/// Create a test user with specific details
#[must_use]
pub fn create_user(&self, user: TestUser) -> TestUser {
self.users
.lock()
@ -257,7 +240,6 @@ impl MockZitadel {
user
}
/// Expect a login with specific credentials and return a token response
pub async fn expect_login(&self, email: &str, password: &str) -> String {
let user = self
.users
@ -286,7 +268,6 @@ impl MockZitadel {
.as_secs();
let expires_in = 3600u64;
// Store token info
self.tokens.lock().unwrap().insert(
access_token.clone(),
TokenInfo {
@ -312,10 +293,9 @@ impl MockZitadel {
scope: "openid profile email".to_string(),
};
// Set up the mock for password grant
Mock::given(method("POST"))
.and(path("/oauth/v2/token"))
.and(body_string_contains(&format!("username={}", email)))
.and(body_string_contains(format!("username={email}")))
.respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
.mount(&self.server)
.await;
@ -323,7 +303,6 @@ impl MockZitadel {
access_token
}
/// Expect token refresh
pub async fn expect_token_refresh(&self) {
let access_token = format!("test_access_{}", Uuid::new_v4());
let refresh_token = format!("test_refresh_{}", Uuid::new_v4());
@ -345,7 +324,6 @@ impl MockZitadel {
.await;
}
/// Expect token introspection
pub async fn expect_introspect(&self, token: &str, active: bool) {
let response = if active {
let now = SystemTime::now()
@ -382,13 +360,12 @@ impl MockZitadel {
Mock::given(method("POST"))
.and(path("/oauth/v2/introspect"))
.and(body_string_contains(&format!("token={}", token)))
.and(body_string_contains(format!("token={token}")))
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server)
.await;
}
/// Set up default introspection that always returns active
pub async fn expect_any_introspect_active(&self) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -415,7 +392,6 @@ impl MockZitadel {
.await;
}
/// Expect userinfo request
pub async fn expect_userinfo(&self, token: &str, user: &TestUser) {
let response = UserInfoResponse {
sub: user.id.clone(),
@ -428,16 +404,12 @@ impl MockZitadel {
Mock::given(method("GET"))
.and(path("/oidc/v1/userinfo"))
.and(header(
"authorization",
format!("Bearer {}", token).as_str(),
))
.and(header("authorization", format!("Bearer {token}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server)
.await;
}
/// Set up default userinfo endpoint
pub async fn expect_any_userinfo(&self) {
let response = UserInfoResponse {
sub: Uuid::new_v4().to_string(),
@ -455,7 +427,6 @@ impl MockZitadel {
.await;
}
/// Expect token revocation
pub async fn expect_revoke(&self) {
Mock::given(method("POST"))
.and(path("/oauth/v2/revoke"))
@ -464,7 +435,6 @@ impl MockZitadel {
.await;
}
/// Expect an authentication error
pub async fn expect_auth_error(&self, error: &str, description: &str) {
let response = ErrorResponse {
error: error.to_string(),
@ -478,19 +448,16 @@ impl MockZitadel {
.await;
}
/// Expect invalid credentials error
pub async fn expect_invalid_credentials(&self) {
self.expect_auth_error("invalid_grant", "Invalid username or password")
.await;
}
/// Expect client authentication error
pub async fn expect_invalid_client(&self) {
self.expect_auth_error("invalid_client", "Client authentication failed")
.await;
}
/// Create a mock ID token (not cryptographically valid, for testing only)
fn create_mock_id_token(&self, user: &TestUser) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -513,10 +480,10 @@ impl MockZitadel {
);
let signature = base64_url_encode("mock-signature");
format!("{}.{}.{}", header, payload, signature)
format!("{header}.{payload}.{signature}")
}
/// Generate an access token for a user
#[must_use]
pub fn generate_token(&self, user: &TestUser) -> String {
let access_token = format!("test_access_{}", Uuid::new_v4());
let now = SystemTime::now()
@ -543,34 +510,32 @@ impl MockZitadel {
access_token
}
/// Invalidate a token
pub fn invalidate_token(&self, token: &str) {
if let Some(info) = self.tokens.lock().unwrap().get_mut(token) {
info.active = false;
}
}
/// Get the server URL
#[must_use]
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
/// Get the issuer URL (same as server URL)
#[must_use]
pub fn issuer(&self) -> String {
self.issuer.clone()
}
/// Get the port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Get the OIDC discovery URL
#[must_use]
pub fn discovery_url(&self) -> String {
format!("{}/.well-known/openid-configuration", self.url())
}
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() {
@ -579,7 +544,6 @@ impl MockZitadel {
Ok(())
}
/// Reset all mocks
pub async fn reset(&self) {
self.server.reset().await;
self.users.lock().unwrap().clear();
@ -589,13 +553,11 @@ impl MockZitadel {
self.setup_jwks_endpoint().await;
}
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default()
}
}
/// Simple base64 URL encoding (for mock tokens)
fn base64_url_encode(input: &str) -> String {
use std::io::Write;
@ -611,11 +573,10 @@ fn base64_url_encode(input: &str) -> String {
.replace('=', "")
}
/// Create a base64 encoder
fn base64_encoder(output: &mut Vec<u8>) -> impl std::io::Write + '_ {
struct Base64Writer<'a>(&'a mut Vec<u8>);
impl<'a> std::io::Write for Base64Writer<'a> {
impl std::io::Write for Base64Writer<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@ -687,7 +648,7 @@ mod tests {
assert!(json.contains("access_token"));
assert!(json.contains("Bearer"));
assert!(json.contains("refresh_token"));
assert!(!json.contains("id_token")); // Should be skipped when None
assert!(!json.contains("id_token"));
}
#[test]
@ -698,8 +659,8 @@ mod tests {
client_id: Some("client".to_string()),
username: Some("user@test.com".to_string()),
token_type: Some("Bearer".to_string()),
exp: Some(1234567890),
iat: Some(1234567800),
exp: Some(1_234_567_890),
iat: Some(1_234_567_800),
sub: Some("user-id".to_string()),
aud: Some("audience".to_string()),
iss: Some("issuer".to_string()),
@ -726,7 +687,6 @@ mod tests {
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains(r#""active":false"#));
// Optional fields should be omitted
assert!(!json.contains("scope"));
}
}

View file

@ -1,6 +1,3 @@
//! Port allocation for parallel test execution
//!
//! Ensures each test gets unique ports to avoid conflicts
use std::collections::HashSet;
use std::sync::atomic::{AtomicU16, Ordering};
@ -29,6 +26,7 @@ impl PortAllocator {
}
}
#[must_use]
pub fn allocate_range(count: usize) -> Vec<u16> {
(0..count).map(|_| Self::allocate()).collect()
}
@ -71,8 +69,6 @@ impl TestPorts {
impl Drop for TestPorts {
fn drop(&mut self) {
// Only release dynamically allocated ports (>= 15000)
// Fixed ports from existing stack should not be released
if self.postgres >= 15000 {
PortAllocator::release(self.postgres);
}

View file

@ -1,16 +1,10 @@
//! Browser service for E2E testing using Chrome DevTools Protocol (CDP)
//!
//! Launches browser directly with --remote-debugging-port, bypassing chromedriver.
use anyhow::{Context, Result};
use log::{info, warn};
use std::process::{Child, Command, Stdio};
use tokio::time::{sleep, Duration};
/// Default debugging port for CDP
pub const DEFAULT_DEBUG_PORT: u16 = 9222;
/// Browser service that manages a browser instance with CDP enabled
pub struct BrowserService {
port: u16,
process: Option<Child>,
@ -19,33 +13,28 @@ pub struct BrowserService {
}
impl BrowserService {
/// Start a browser with remote debugging enabled
pub async fn start(port: u16) -> Result<Self> {
// First, kill any existing browser on this port
let _ = std::process::Command::new("pkill")
.args(["-9", "-f", &format!("--remote-debugging-port={}", port)])
.args(["-9", "-f", &format!("--remote-debugging-port={port}")])
.output();
sleep(Duration::from_millis(500)).await;
let binary_path = Self::detect_browser_binary()?;
let user_data_dir = format!("/tmp/browser-cdp-{}-{}", std::process::id(), port);
// Clean up and create user data directory
let _ = std::fs::remove_dir_all(&user_data_dir);
std::fs::create_dir_all(&user_data_dir)?;
info!("Starting browser with CDP on port {}", port);
println!("🌐 Starting browser: {}", binary_path);
info!(" Binary: {}", binary_path);
info!(" User data: {}", user_data_dir);
info!("Starting browser with CDP on port {port}");
println!("🌐 Starting browser: {binary_path}");
info!(" Binary: {binary_path}");
info!(" User data: {user_data_dir}");
// Default: SHOW browser window so user can see tests
// Set HEADLESS=1 to run without browser window (CI/automation)
let headless = std::env::var("HEADLESS").is_ok();
let mut cmd = Command::new(&binary_path);
cmd.arg(format!("--remote-debugging-port={}", port))
.arg(format!("--user-data-dir={}", user_data_dir))
cmd.arg(format!("--remote-debugging-port={port}"))
.arg(format!("--user-data-dir={user_data_dir}"))
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage")
.arg("--disable-extensions")
@ -56,7 +45,6 @@ impl BrowserService {
.arg("--metrics-recording-only")
.arg("--no-first-run")
.arg("--safebrowsing-disable-auto-update")
// SSL/TLS certificate bypass flags
.arg("--ignore-certificate-errors")
.arg("--ignore-certificate-errors-spki-list")
.arg("--ignore-ssl-errors")
@ -64,27 +52,24 @@ impl BrowserService {
.arg("--allow-running-insecure-content")
.arg("--disable-web-security")
.arg("--reduce-security-for-testing")
// Window position and size to make it visible
.arg("--window-position=100,100")
.arg("--window-size=1280,800")
.arg("--start-maximized");
// Headless flags BEFORE the URL
if headless {
cmd.arg("--headless=new");
cmd.arg("--disable-gpu");
}
// URL goes last
cmd.arg("about:blank");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
let process = cmd
.spawn()
.context(format!("Failed to start browser: {}", binary_path))?;
.context(format!("Failed to start browser: {binary_path}"))?;
println!(" ⏳ Waiting for CDP on port {}...", port);
println!(" ⏳ Waiting for CDP on port {port}...");
let service = Self {
port,
@ -93,12 +78,11 @@ impl BrowserService {
user_data_dir,
};
// Wait for CDP to be ready - be patient!
for i in 0..100 {
sleep(Duration::from_millis(100)).await;
if service.is_ready().await {
info!("Browser CDP ready on port {}", port);
println!(" ✓ Browser CDP ready on port {}", port);
info!("Browser CDP ready on port {port}");
println!(" ✓ Browser CDP ready on port {port}");
return Ok(service);
}
if i % 20 == 0 && i > 0 {
@ -107,12 +91,11 @@ impl BrowserService {
}
}
warn!("Browser may not be fully ready on CDP port {}", port);
warn!("Browser may not be fully ready on CDP port {port}");
println!(" ⚠ Browser may not be fully ready");
Ok(service)
}
/// Check if CDP is ready by fetching the version endpoint
async fn is_ready(&self) -> bool {
let url = format!("http://127.0.0.1:{}/json/version", self.port);
match reqwest::get(&url).await {
@ -121,17 +104,14 @@ impl BrowserService {
}
}
/// Detect the best available browser binary for CDP testing
fn detect_browser_binary() -> Result<String> {
// Check for BROWSER_BINARY env var first
if let Ok(path) = std::env::var("BROWSER_BINARY") {
if std::path::Path::new(&path).exists() {
info!("Using browser from BROWSER_BINARY env var: {}", path);
info!("Using browser from BROWSER_BINARY env var: {path}");
return Ok(path);
}
}
// Prefer Brave first
let brave_paths = [
"/opt/brave.com/brave-nightly/brave",
"/opt/brave.com/brave/brave",
@ -140,12 +120,11 @@ impl BrowserService {
];
for path in brave_paths {
if std::path::Path::new(path).exists() {
info!("Detected Brave binary at: {}", path);
info!("Detected Brave binary at: {path}");
return Ok(path.to_string());
}
}
// Chrome second
let chrome_paths = [
"/opt/google/chrome/chrome",
"/opt/google/chrome/google-chrome",
@ -154,12 +133,11 @@ impl BrowserService {
];
for path in chrome_paths {
if std::path::Path::new(path).exists() {
info!("Detected Chrome binary at: {}", path);
info!("Detected Chrome binary at: {path}");
return Ok(path.to_string());
}
}
// Chromium last
let chromium_paths = [
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
@ -167,7 +145,7 @@ impl BrowserService {
];
for path in chromium_paths {
if std::path::Path::new(path).exists() {
info!("Detected Chromium binary at: {}", path);
info!("Detected Chromium binary at: {path}");
return Ok(path.to_string());
}
}
@ -175,22 +153,22 @@ impl BrowserService {
anyhow::bail!("No supported browser found. Install Brave, Chrome, or Chromium.")
}
/// Get the CDP WebSocket URL for connecting
#[must_use]
pub fn ws_url(&self) -> String {
format!("ws://127.0.0.1:{}", self.port)
}
/// Get the HTTP URL for CDP endpoints
#[must_use]
pub fn http_url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
/// Get the debugging port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Stop the browser
#[allow(clippy::unused_async)]
pub async fn stop(&mut self) -> Result<()> {
if let Some(mut process) = self.process.take() {
info!("Stopping browser");
@ -198,7 +176,6 @@ impl BrowserService {
process.wait().ok();
}
// Clean up user data directory
if std::path::Path::new(&self.user_data_dir).exists() {
std::fs::remove_dir_all(&self.user_data_dir).ok();
}
@ -206,7 +183,6 @@ impl BrowserService {
Ok(())
}
/// Cleanup resources
pub fn cleanup(&mut self) {
if let Some(mut process) = self.process.take() {
process.kill().ok();
@ -231,9 +207,7 @@ mod tests {
#[test]
fn test_detect_browser() {
// Should not fail - will find at least one browser or return error
let result = BrowserService::detect_browser_binary();
// Test passes if we found a browser
if let Ok(path) = result {
assert!(!path.is_empty());
}

View file

@ -1,9 +1,3 @@
//! MinIO service management for test infrastructure
//!
//! Starts and manages a MinIO instance for S3-compatible storage testing.
//! Finds MinIO binary from system installation or botserver-stack.
//! Provides bucket creation, object operations, and credential management.
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal};
@ -14,7 +8,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
/// MinIO service for S3-compatible storage in test environments
pub struct MinioService {
api_port: u16,
console_port: u16,
@ -26,24 +19,22 @@ pub struct MinioService {
}
impl MinioService {
/// Default access key for tests
pub const DEFAULT_ACCESS_KEY: &'static str = "minioadmin";
/// Default secret key for tests
pub const DEFAULT_SECRET_KEY: &'static str = "minioadmin";
/// Find MinIO binary - checks botserver-stack first, then system paths
fn find_minio_binary() -> Result<PathBuf> {
// First, check BOTSERVER_STACK_PATH env var
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio");
if minio_path.exists() {
log::info!("Using MinIO from BOTSERVER_STACK_PATH: {:?}", minio_path);
log::info!(
"Using MinIO from BOTSERVER_STACK_PATH: {}",
minio_path.display()
);
return Ok(minio_path);
}
}
// Check relative paths from current directory
let cwd = std::env::current_dir().unwrap_or_default();
let relative_paths = [
"../botserver/botserver-stack/bin/drive/minio",
@ -54,12 +45,11 @@ impl MinioService {
for rel_path in &relative_paths {
let minio_path = cwd.join(rel_path);
if minio_path.exists() {
log::info!("Using MinIO from botserver-stack: {:?}", minio_path);
log::info!("Using MinIO from botserver-stack: {}", minio_path.display());
return Ok(minio_path);
}
}
// Check system paths
let system_paths = [
"/usr/local/bin/minio",
"/usr/bin/minio",
@ -70,29 +60,26 @@ impl MinioService {
for path in &system_paths {
let minio_path = PathBuf::from(path);
if minio_path.exists() {
log::info!("Using system MinIO from: {:?}", minio_path);
log::info!("Using system MinIO from: {}", minio_path.display());
return Ok(minio_path);
}
}
// Last resort: try to find via which
if let Ok(minio_path) = which::which("minio") {
log::info!("Using MinIO from PATH: {:?}", minio_path);
log::info!("Using MinIO from PATH: {}", minio_path.display());
return Ok(minio_path);
}
anyhow::bail!("MinIO not found. Install MinIO or set BOTSERVER_STACK_PATH env var")
}
/// Start a new MinIO instance on the specified port
pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> {
let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {:?}", bin_path);
log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?;
// Allocate a console port (api_port + 1000 or find available)
let console_port = api_port + 1000;
let mut service = Self {
@ -105,13 +92,12 @@ impl MinioService {
secret_key: Self::DEFAULT_SECRET_KEY.to_string(),
};
service.start_server().await?;
service.start_server()?;
service.wait_ready().await?;
Ok(service)
}
/// Start MinIO with custom credentials
pub async fn start_with_credentials(
api_port: u16,
data_dir: &str,
@ -119,7 +105,7 @@ impl MinioService {
secret_key: &str,
) -> Result<Self> {
let bin_path = Self::find_minio_binary()?;
log::info!("Using MinIO from: {:?}", bin_path);
log::info!("Using MinIO from: {}", bin_path.display());
let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?;
@ -136,14 +122,13 @@ impl MinioService {
secret_key: secret_key.to_string(),
};
service.start_server().await?;
service.start_server()?;
service.wait_ready().await?;
Ok(service)
}
/// Start the MinIO server process
async fn start_server(&mut self) -> Result<()> {
fn start_server(&mut self) -> Result<()> {
log::info!(
"Starting MinIO on port {} (console: {})",
self.api_port,
@ -170,7 +155,6 @@ impl MinioService {
Ok(())
}
/// Wait for MinIO to be ready
async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for MinIO to be ready...");
@ -180,7 +164,6 @@ impl MinioService {
.await
.context("MinIO failed to start in time")?;
// Additional health check via HTTP
let health_url = format!("http://127.0.0.1:{}/minio/health/live", self.api_port);
for _ in 0..30 {
if let Ok(resp) = reqwest::get(&health_url).await {
@ -191,17 +174,13 @@ impl MinioService {
sleep(Duration::from_millis(100)).await;
}
// Even if health check fails, TCP is up so proceed
Ok(())
}
/// Create a new bucket
pub async fn create_bucket(&self, name: &str) -> Result<()> {
log::info!("Creating bucket '{}'", name);
log::info!("Creating bucket '{name}'");
// Try using mc (MinIO client) if available
if let Ok(mc) = Self::find_mc_binary() {
// Configure mc alias
let alias_name = format!("test{}", self.api_port);
let _ = Command::new(&mc)
.args([
@ -215,24 +194,19 @@ impl MinioService {
.output();
let output = Command::new(&mc)
.args([
"mb",
"--ignore-existing",
&format!("{}/{}", alias_name, name),
])
.args(["mb", "--ignore-existing", &format!("{alias_name}/{name}")])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("already") {
anyhow::bail!("Failed to create bucket: {}", stderr);
anyhow::bail!("Failed to create bucket: {stderr}");
}
}
return Ok(());
}
// Fallback: use HTTP PUT request
let url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new();
let resp = client
@ -248,7 +222,6 @@ impl MinioService {
Ok(())
}
/// Put an object into a bucket
pub async fn put_object(&self, bucket: &str, key: &str, data: &[u8]) -> Result<()> {
log::debug!("Putting object '{}/{}' ({} bytes)", bucket, key, data.len());
@ -268,9 +241,8 @@ impl MinioService {
Ok(())
}
/// Get an object from a bucket
pub async fn get_object(&self, bucket: &str, key: &str) -> Result<Vec<u8>> {
log::debug!("Getting object '{}/{}'", bucket, key);
log::debug!("Getting object '{bucket}/{key}'");
let url = format!("{}/{}/{}", self.endpoint(), bucket, key);
let client = reqwest::Client::new();
@ -287,9 +259,8 @@ impl MinioService {
Ok(resp.bytes().await?.to_vec())
}
/// Delete an object from a bucket
pub async fn delete_object(&self, bucket: &str, key: &str) -> Result<()> {
log::debug!("Deleting object '{}/{}'", bucket, key);
log::debug!("Deleting object '{bucket}/{key}'");
let url = format!("{}/{}/{}", self.endpoint(), bucket, key);
let client = reqwest::Client::new();
@ -306,13 +277,12 @@ impl MinioService {
Ok(())
}
/// List objects in a bucket
pub async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> Result<Vec<String>> {
log::debug!("Listing objects in bucket '{}'", bucket);
log::debug!("Listing objects in bucket '{bucket}'");
let mut url = format!("{}/{}", self.endpoint(), bucket);
if let Some(p) = prefix {
url = format!("{}?prefix={}", url, p);
url = format!("{url}?prefix={p}");
}
let client = reqwest::Client::new();
@ -326,11 +296,9 @@ impl MinioService {
anyhow::bail!("Failed to list objects: {}", resp.status());
}
// Parse XML response (simplified)
let body = resp.text().await?;
let mut objects = Vec::new();
// Simple XML parsing for <Key> elements
for line in body.lines() {
if let Some(start) = line.find("<Key>") {
if let Some(end) = line.find("</Key>") {
@ -343,7 +311,6 @@ impl MinioService {
Ok(objects)
}
/// Check if a bucket exists
pub async fn bucket_exists(&self, name: &str) -> Result<bool> {
let url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new();
@ -356,9 +323,8 @@ impl MinioService {
Ok(resp.status().is_success())
}
/// Delete a bucket
pub async fn delete_bucket(&self, name: &str) -> Result<()> {
log::info!("Deleting bucket '{}'", name);
log::info!("Deleting bucket '{name}'");
let url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new();
@ -375,32 +341,32 @@ impl MinioService {
Ok(())
}
/// Get the S3 endpoint URL
#[must_use]
pub fn endpoint(&self) -> String {
format!("http://127.0.0.1:{}", self.api_port)
}
/// Get the console URL
#[must_use]
pub fn console_url(&self) -> String {
format!("http://127.0.0.1:{}", self.console_port)
}
/// Get the API port
pub fn api_port(&self) -> u16 {
#[must_use]
pub const fn api_port(&self) -> u16 {
self.api_port
}
/// Get the console port
pub fn console_port(&self) -> u16 {
#[must_use]
pub const fn console_port(&self) -> u16 {
self.console_port
}
/// Get credentials as (access_key, secret_key)
#[must_use]
pub fn credentials(&self) -> (String, String) {
(self.access_key.clone(), self.secret_key.clone())
}
/// Get S3-compatible configuration for AWS SDK
#[must_use]
pub fn s3_config(&self) -> HashMap<String, String> {
let mut config = HashMap::new();
config.insert("endpoint_url".to_string(), self.endpoint());
@ -411,7 +377,6 @@ impl MinioService {
config
}
/// Find the MinIO client (mc) binary
fn find_mc_binary() -> Result<PathBuf> {
let common_paths = ["/usr/local/bin/mc", "/usr/bin/mc", "/opt/homebrew/bin/mc"];
@ -425,7 +390,6 @@ impl MinioService {
which::which("mc").context("mc binary not found")
}
/// Stop the MinIO server
pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process {
log::info!("Stopping MinIO...");
@ -452,7 +416,6 @@ impl MinioService {
Ok(())
}
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?;

View file

@ -1,7 +1,3 @@
//! Service management for test infrastructure
//!
//! Provides real service instances (PostgreSQL, MinIO, Redis) for integration testing.
//! Each service runs on a dynamic port to enable parallel test execution.
mod browser_service;
mod minio;
@ -18,13 +14,10 @@ use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
/// Default timeout for service health checks
pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
/// Default interval between health check attempts
pub const HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(100);
/// Wait for a condition to become true with timeout
pub async fn wait_for<F, Fut>(timeout: Duration, interval: Duration, mut check: F) -> Result<()>
where
F: FnMut() -> Fut,
@ -40,12 +33,10 @@ where
anyhow::bail!("Timeout waiting for condition")
}
/// Check if a TCP port is accepting connections
pub async fn check_tcp_port(host: &str, port: u16) -> bool {
tokio::net::TcpStream::connect((host, port)).await.is_ok()
}
/// Create a directory if it doesn't exist
pub fn ensure_dir(path: &Path) -> Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
@ -53,23 +44,17 @@ pub fn ensure_dir(path: &Path) -> Result<()> {
Ok(())
}
/// Service trait for common operations
#[async_trait::async_trait]
pub trait Service: Send + Sync {
/// Start the service
async fn start(&mut self) -> Result<()>;
/// Stop the service gracefully
async fn stop(&mut self) -> Result<()>;
/// Check if the service is healthy
async fn health_check(&self) -> Result<bool>;
/// Get the service connection URL
fn connection_url(&self) -> String;
}
/// Service status
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceStatus {
Stopped,

View file

@ -1,8 +1,3 @@
//! PostgreSQL service management for test infrastructure
//!
//! Starts and manages a PostgreSQL instance for integration testing.
//! Finds PostgreSQL binaries from system installation or botserver-stack.
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal};
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
/// PostgreSQL service for test environments
pub struct PostgresService {
port: u16,
data_dir: PathBuf,
@ -26,28 +20,25 @@ pub struct PostgresService {
}
impl PostgresService {
/// Default database name for tests
pub const DEFAULT_DATABASE: &'static str = "bottest";
/// Default username for tests
pub const DEFAULT_USERNAME: &'static str = "bottest";
/// Default password for tests
pub const DEFAULT_PASSWORD: &'static str = "bottest";
/// Find PostgreSQL binaries - checks botserver-stack first, then system paths
fn find_postgres_installation() -> Result<(PathBuf, Option<PathBuf>)> {
// First, check BOTSERVER_STACK_PATH env var
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let bin_dir = PathBuf::from(&stack_path).join("bin/tables/bin");
let lib_dir = PathBuf::from(&stack_path).join("bin/tables/lib");
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
log::info!("Using PostgreSQL from BOTSERVER_STACK_PATH: {:?}", bin_dir);
log::info!(
"Using PostgreSQL from BOTSERVER_STACK_PATH: {}",
bin_dir.display()
);
return Ok((bin_dir, Some(lib_dir)));
}
}
// Check relative paths from current directory
let cwd = std::env::current_dir().unwrap_or_default();
let relative_paths = [
"../botserver/botserver-stack/bin/tables/bin",
@ -59,7 +50,10 @@ impl PostgresService {
let bin_dir = cwd.join(rel_path);
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
let lib_dir = bin_dir.parent().unwrap().join("lib");
log::info!("Using PostgreSQL from botserver-stack: {:?}", bin_dir);
log::info!(
"Using PostgreSQL from botserver-stack: {}",
bin_dir.display()
);
return Ok((
bin_dir,
if lib_dir.exists() {
@ -71,7 +65,6 @@ impl PostgresService {
}
}
// Check system PostgreSQL paths
let system_paths = [
"/usr/lib/postgresql/17/bin",
"/usr/lib/postgresql/16/bin",
@ -88,15 +81,14 @@ impl PostgresService {
for path in &system_paths {
let bin_dir = PathBuf::from(path);
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
log::info!("Using system PostgreSQL from: {:?}", bin_dir);
log::info!("Using system PostgreSQL from: {}", bin_dir.display());
return Ok((bin_dir, None));
}
}
// Last resort: try to find via which
if let Ok(initdb_path) = which::which("initdb") {
if let Some(bin_dir) = initdb_path.parent() {
log::info!("Using PostgreSQL from PATH: {:?}", bin_dir);
log::info!("Using PostgreSQL from PATH: {}", bin_dir.display());
return Ok((bin_dir.to_path_buf(), None));
}
}
@ -106,7 +98,6 @@ impl PostgresService {
)
}
/// Start a new PostgreSQL instance on the specified port
pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
let (bin_dir, lib_dir) = Self::find_postgres_installation()?;
@ -127,29 +118,23 @@ impl PostgresService {
service.connection_string = service.build_connection_string();
// Initialize database cluster if needed
if !data_path.join("PG_VERSION").exists() {
service.init_db().await?;
service.init_db()?;
}
// Start PostgreSQL
service.start_server().await?;
service.start_server()?;
// Wait for it to be ready
service.wait_ready().await?;
// Create test database and user
service.setup_test_database().await?;
service.setup_test_database()?;
Ok(service)
}
/// Get binary path
fn get_binary(&self, name: &str) -> PathBuf {
self.bin_dir.join(name)
}
/// Build command with LD_LIBRARY_PATH if needed
fn build_command(&self, binary_name: &str) -> Command {
let binary = self.get_binary(binary_name);
let mut cmd = Command::new(&binary);
@ -159,11 +144,10 @@ impl PostgresService {
cmd
}
/// Initialize the database cluster
async fn init_db(&self) -> Result<()> {
fn init_db(&self) -> Result<()> {
log::info!(
"Initializing PostgreSQL data directory at {:?}",
self.data_dir
"Initializing PostgreSQL data directory at {}",
self.data_dir.display()
);
let output = self
@ -184,27 +168,24 @@ impl PostgresService {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("initdb failed: {}", stderr);
anyhow::bail!("initdb failed: {stderr}");
}
// Configure postgresql.conf for testing
self.configure_for_testing()?;
Ok(())
}
/// Configure PostgreSQL for fast testing (reduced durability)
fn configure_for_testing(&self) -> Result<()> {
let config_path = self.data_dir.join("postgresql.conf");
// Use absolute path for unix_socket_directories
let abs_data_dir = self
.data_dir
.canonicalize()
.unwrap_or_else(|_| self.data_dir.clone());
let config = format!(
r#"
r"
# Test configuration - optimized for speed, not durability
listen_addresses = '127.0.0.1'
port = {}
@ -222,7 +203,7 @@ logging_collector = off
log_statement = 'none'
log_duration = off
unix_socket_directories = '{}'
"#,
",
self.port,
abs_data_dir.to_str().unwrap()
);
@ -231,17 +212,15 @@ unix_socket_directories = '{}'
Ok(())
}
/// Start the PostgreSQL server process
async fn start_server(&mut self) -> Result<()> {
fn start_server(&mut self) -> Result<()> {
log::info!("Starting PostgreSQL on port {}", self.port);
// Create log file for debugging
let log_path = self.data_dir.join("postgres.log");
let log_file = std::fs::File::create(&log_path)
.context(format!("Failed to create log file {:?}", log_path))?;
.context(format!("Failed to create log file {}", log_path.display()))?;
let stderr_file = log_file.try_clone()?;
log::debug!("PostgreSQL log file: {:?}", log_path);
log::debug!("PostgreSQL log file: {}", log_path.display());
let mut cmd = self.build_command("postgres");
let child = cmd
@ -255,7 +234,6 @@ unix_socket_directories = '{}'
Ok(())
}
/// Wait for PostgreSQL to be ready to accept connections
async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for PostgreSQL to be ready...");
@ -264,18 +242,16 @@ unix_socket_directories = '{}'
})
.await;
if result.is_err() {
// Read log file to show error
if let Err(e) = result {
let log_path = self.data_dir.join("postgres.log");
if log_path.exists() {
if let Ok(log_content) = std::fs::read_to_string(&log_path) {
log::error!("PostgreSQL log:\n{}", log_content);
log::error!("PostgreSQL log:\n{log_content}");
}
}
return Err(result.unwrap_err()).context("PostgreSQL failed to start in time");
return Err(e).context("PostgreSQL failed to start in time");
}
// Additional wait for pg_isready
for _ in 0..30 {
let status = self
.build_command("pg_isready")
@ -291,11 +267,9 @@ unix_socket_directories = '{}'
Ok(())
}
/// Create the test database and user
async fn setup_test_database(&self) -> Result<()> {
fn setup_test_database(&self) -> Result<()> {
log::info!("Setting up test database '{}'", self.database_name);
// Create user
let _ = self
.build_command("psql")
.args([
@ -313,7 +287,6 @@ unix_socket_directories = '{}'
])
.output();
// Create database
let _ = self
.build_command("psql")
.args([
@ -334,11 +307,9 @@ unix_socket_directories = '{}'
Ok(())
}
/// Run database migrations
pub async fn run_migrations(&self) -> Result<()> {
pub fn run_migrations(&self) -> Result<()> {
log::info!("Running database migrations...");
// Try to run migrations using diesel CLI if available
if let Ok(diesel) = which::which("diesel") {
let status = Command::new(diesel)
.args([
@ -354,13 +325,11 @@ unix_socket_directories = '{}'
}
}
// Fallback: run migrations programmatically via botlib if available
log::warn!("diesel CLI not available, skipping migrations");
Ok(())
}
/// Create a new database with the given name
pub async fn create_database(&self, name: &str) -> Result<()> {
pub fn create_database(&self, name: &str) -> Result<()> {
let output = self
.build_command("psql")
.args([
@ -371,22 +340,21 @@ unix_socket_directories = '{}'
"-U",
&self.username,
"-c",
&format!("CREATE DATABASE {}", name),
&format!("CREATE DATABASE {name}"),
])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("already exists") {
anyhow::bail!("Failed to create database: {}", stderr);
anyhow::bail!("Failed to create database: {stderr}");
}
}
Ok(())
}
/// Execute raw SQL
pub async fn execute(&self, sql: &str) -> Result<()> {
pub fn execute(&self, sql: &str) -> Result<()> {
let output = self
.build_command("psql")
.args([
@ -405,14 +373,13 @@ unix_socket_directories = '{}'
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("SQL execution failed: {}", stderr);
anyhow::bail!("SQL execution failed: {stderr}");
}
Ok(())
}
/// Execute SQL and return results as JSON
pub async fn query(&self, sql: &str) -> Result<String> {
pub fn query(&self, sql: &str) -> Result<String> {
let output = self
.build_command("psql")
.args([
@ -424,8 +391,8 @@ unix_socket_directories = '{}'
&self.username,
"-d",
&self.database_name,
"-t", // tuples only
"-A", // unaligned
"-t",
"-A",
"-c",
sql,
])
@ -433,23 +400,22 @@ unix_socket_directories = '{}'
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("SQL query failed: {}", stderr);
anyhow::bail!("SQL query failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Get the connection string
#[must_use]
pub fn connection_string(&self) -> String {
self.connection_string.clone()
}
/// Get the port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Build the connection string
fn build_connection_string(&self) -> String {
format!(
"postgres://{}:{}@127.0.0.1:{}/{}",
@ -457,16 +423,13 @@ unix_socket_directories = '{}'
)
}
/// Stop the PostgreSQL server
pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process {
log::info!("Stopping PostgreSQL...");
// Try graceful shutdown first
let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM);
// Wait for process to exit
for _ in 0..50 {
match child.try_wait() {
Ok(Some(_)) => {
@ -478,7 +441,6 @@ unix_socket_directories = '{}'
}
}
// Force kill if still running
let _ = kill(pid, Signal::SIGKILL);
let _ = child.wait();
self.process = None;
@ -487,7 +449,6 @@ unix_socket_directories = '{}'
Ok(())
}
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?;
@ -502,10 +463,8 @@ impl Drop for PostgresService {
let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM);
// Give it a moment to shut down gracefully
std::thread::sleep(Duration::from_millis(500));
// Force kill if needed
let _ = kill(pid, Signal::SIGKILL);
let _ = child.wait();
}

View file

@ -1,8 +1,3 @@
//! Redis service management for test infrastructure
//!
//! Starts and manages a Redis instance for caching and pub/sub testing.
//! Provides connection management and common operations.
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal};
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
/// Redis service for test environments
pub struct RedisService {
port: u16,
data_dir: PathBuf,
@ -21,7 +15,6 @@ pub struct RedisService {
}
impl RedisService {
/// Start a new Redis instance on the specified port
pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
let data_path = PathBuf::from(data_dir).join("redis");
ensure_dir(&data_path)?;
@ -39,7 +32,6 @@ impl RedisService {
Ok(service)
}
/// Start Redis with password authentication
pub async fn start_with_password(port: u16, data_dir: &str, password: &str) -> Result<Self> {
let data_path = PathBuf::from(data_dir).join("redis");
ensure_dir(&data_path)?;
@ -57,7 +49,7 @@ impl RedisService {
Ok(service)
}
/// Start the Redis server process
#[allow(clippy::unused_async)]
async fn start_server(&mut self) -> Result<()> {
log::info!("Starting Redis on port {}", self.port);
@ -72,12 +64,10 @@ impl RedisService {
self.data_dir.to_str().unwrap().to_string(),
"--daemonize".to_string(),
"no".to_string(),
// Disable persistence for faster testing
"--save".to_string(),
"".to_string(),
String::new(),
"--appendonly".to_string(),
"no".to_string(),
// Reduce memory usage
"--maxmemory".to_string(),
"64mb".to_string(),
"--maxmemory-policy".to_string(),
@ -100,7 +90,6 @@ impl RedisService {
Ok(())
}
/// Wait for Redis to be ready
async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for Redis to be ready...");
@ -110,7 +99,6 @@ impl RedisService {
.await
.context("Redis failed to start in time")?;
// Additional check using redis-cli PING
if let Ok(redis_cli) = Self::find_cli_binary() {
for _ in 0..30 {
let mut cmd = Command::new(&redis_cli);
@ -137,7 +125,7 @@ impl RedisService {
Ok(())
}
/// Execute a Redis command and return the result
#[allow(clippy::unused_async)]
pub async fn execute(&self, args: &[&str]) -> Result<String> {
let redis_cli = Self::find_cli_binary()?;
@ -154,26 +142,23 @@ impl RedisService {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Redis command failed: {}", stderr);
anyhow::bail!("Redis command failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Set a key-value pair
pub async fn set(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["SET", key, value]).await?;
Ok(())
}
/// Set a key-value pair with expiration (seconds)
pub async fn setex(&self, key: &str, seconds: u64, value: &str) -> Result<()> {
self.execute(&["SETEX", key, &seconds.to_string(), value])
.await?;
Ok(())
}
/// Get a value by key
pub async fn get(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["GET", key]).await?;
if result.is_empty() || result == "(nil)" {
@ -183,58 +168,49 @@ impl RedisService {
}
}
/// Delete a key
pub async fn del(&self, key: &str) -> Result<()> {
self.execute(&["DEL", key]).await?;
Ok(())
}
/// Check if a key exists
pub async fn exists(&self, key: &str) -> Result<bool> {
let result = self.execute(&["EXISTS", key]).await?;
Ok(result == "1" || result == "(integer) 1")
}
/// Get all keys matching a pattern
pub async fn keys(&self, pattern: &str) -> Result<Vec<String>> {
let result = self.execute(&["KEYS", pattern]).await?;
if result.is_empty() || result == "(empty list or set)" {
Ok(Vec::new())
} else {
Ok(result.lines().map(|s| s.to_string()).collect())
Ok(result
.lines()
.map(std::string::ToString::to_string)
.collect())
}
}
/// Flush all data
pub async fn flushall(&self) -> Result<()> {
self.execute(&["FLUSHALL"]).await?;
Ok(())
}
/// Publish a message to a channel
pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> {
let result = self.execute(&["PUBLISH", channel, message]).await?;
// Parse "(integer) N" format
let count = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let count = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(count)
}
/// Push to a list (left)
pub async fn lpush(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["LPUSH", key, value]).await?;
Ok(())
}
/// Push to a list (right)
pub async fn rpush(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["RPUSH", key, value]).await?;
Ok(())
}
/// Pop from a list (left)
pub async fn lpop(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["LPOP", key]).await?;
if result.is_empty() || result == "(nil)" {
@ -244,7 +220,6 @@ impl RedisService {
}
}
/// Pop from a list (right)
pub async fn rpop(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["RPOP", key]).await?;
if result.is_empty() || result == "(nil)" {
@ -254,23 +229,17 @@ impl RedisService {
}
}
/// Get list length
pub async fn llen(&self, key: &str) -> Result<i64> {
let result = self.execute(&["LLEN", key]).await?;
let len = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let len = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(len)
}
/// Set hash field
pub async fn hset(&self, key: &str, field: &str, value: &str) -> Result<()> {
self.execute(&["HSET", key, field, value]).await?;
Ok(())
}
/// Get hash field
pub async fn hget(&self, key: &str, field: &str) -> Result<Option<String>> {
let result = self.execute(&["HGET", key, field]).await?;
if result.is_empty() || result == "(nil)" {
@ -280,7 +249,6 @@ impl RedisService {
}
}
/// Get all hash fields and values
pub async fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>> {
let result = self.execute(&["HGETALL", key]).await?;
if result.is_empty() || result == "(empty list or set)" {
@ -299,27 +267,19 @@ impl RedisService {
Ok(pairs)
}
/// Increment a value
pub async fn incr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["INCR", key]).await?;
let val = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(val)
}
/// Decrement a value
pub async fn decr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["DECR", key]).await?;
let val = result
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
Ok(val)
}
/// Get the connection string
#[must_use]
pub fn connection_string(&self) -> String {
match &self.password {
Some(pw) => format!("redis://:{}@127.0.0.1:{}", pw, self.port),
@ -327,22 +287,21 @@ impl RedisService {
}
}
/// Get the connection URL (alias for connection_string)
#[must_use]
pub fn url(&self) -> String {
self.connection_string()
}
/// Get the port
pub fn port(&self) -> u16 {
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
/// Get host and port tuple
pub fn host_port(&self) -> (&str, u16) {
#[must_use]
pub const fn host_port(&self) -> (&str, u16) {
("127.0.0.1", self.port)
}
/// Find the Redis server binary
fn find_binary() -> Result<PathBuf> {
let common_paths = [
"/usr/bin/redis-server",
@ -362,7 +321,6 @@ impl RedisService {
.context("redis-server binary not found in PATH or common locations")
}
/// Find the Redis CLI binary
fn find_cli_binary() -> Result<PathBuf> {
let common_paths = [
"/usr/bin/redis-cli",
@ -381,12 +339,10 @@ impl RedisService {
which::which("redis-cli").context("redis-cli binary not found")
}
/// Stop the Redis server
pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process {
log::info!("Stopping Redis...");
// Try graceful shutdown via SHUTDOWN command first
if let Ok(redis_cli) = Self::find_cli_binary() {
let mut cmd = Command::new(&redis_cli);
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]);
@ -401,7 +357,6 @@ impl RedisService {
let _ = cmd.output();
}
// Wait for process to exit
for _ in 0..30 {
match child.try_wait() {
Ok(Some(_)) => {
@ -413,7 +368,6 @@ impl RedisService {
}
}
// Force kill if still running
let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM);
@ -436,7 +390,6 @@ impl RedisService {
Ok(())
}
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?;
@ -448,7 +401,6 @@ impl RedisService {
impl Drop for RedisService {
fn drop(&mut self) {
if let Some(ref mut child) = self.process {
// Try graceful shutdown
if let Ok(redis_cli) = Self::find_cli_binary() {
let mut cmd = Command::new(&redis_cli);
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]);
@ -463,7 +415,6 @@ impl Drop for RedisService {
std::thread::sleep(Duration::from_millis(200));
}
// Force kill if needed
let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM);

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,3 @@
//! Web E2E testing module
//!
//! Provides tools for browser-based end-to-end testing using WebDriver
//! (via fantoccini) to automate browser interactions with the chat interface.
pub mod browser;
pub mod pages;
@ -11,24 +6,15 @@ pub use browser::{Browser, BrowserConfig, BrowserType};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Configuration for E2E tests
#[derive(Debug, Clone)]
pub struct E2EConfig {
/// Browser type to use
pub browser: BrowserType,
/// Whether to run headless
pub headless: bool,
/// Default timeout for operations
pub timeout: Duration,
/// Window width
pub window_width: u32,
/// Window height
pub window_height: u32,
/// WebDriver URL
pub webdriver_url: String,
/// Whether to capture screenshots on failure
pub screenshot_on_failure: bool,
/// Directory to save screenshots
pub screenshot_dir: String,
}
@ -48,7 +34,7 @@ impl Default for E2EConfig {
}
impl E2EConfig {
/// Create a BrowserConfig from this E2EConfig
#[must_use]
pub fn to_browser_config(&self) -> BrowserConfig {
BrowserConfig::default()
.with_browser(self.browser)
@ -59,7 +45,6 @@ impl E2EConfig {
}
}
/// Result of an E2E test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct E2ETestResult {
pub name: String,
@ -70,7 +55,6 @@ pub struct E2ETestResult {
pub error: Option<String>,
}
/// A step in an E2E test
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStep {
pub name: String,
@ -79,72 +63,68 @@ pub struct TestStep {
pub error: Option<String>,
}
/// Element locator strategies
#[derive(Debug, Clone)]
pub enum Locator {
/// CSS selector
Css(String),
/// XPath expression
XPath(String),
/// Element ID
Id(String),
/// Element name attribute
Name(String),
/// Link text
LinkText(String),
/// Partial link text
PartialLinkText(String),
/// Tag name
TagName(String),
/// Class name
ClassName(String),
}
impl Locator {
#[must_use]
pub fn css(selector: &str) -> Self {
Self::Css(selector.to_string())
}
#[must_use]
pub fn xpath(expr: &str) -> Self {
Self::XPath(expr.to_string())
}
#[must_use]
pub fn id(id: &str) -> Self {
Self::Id(id.to_string())
}
#[must_use]
pub fn name(name: &str) -> Self {
Self::Name(name.to_string())
}
#[must_use]
pub fn link_text(text: &str) -> Self {
Self::LinkText(text.to_string())
}
#[must_use]
pub fn class(name: &str) -> Self {
Self::ClassName(name.to_string())
}
/// Convert locator to CSS selector string for CDP
#[must_use]
#[allow(clippy::match_same_arms)]
pub fn to_css_selector(&self) -> String {
match self {
Locator::Css(s) => s.clone(),
Locator::XPath(_) => {
// XPath not directly supported in CSS - log warning and return generic
Self::Css(s) => s.clone(),
Self::XPath(_) => {
log::warn!("XPath locators not directly supported in CDP, use CSS selectors");
"*".to_string()
}
Locator::Id(s) => format!("#{}", s),
Locator::Name(s) => format!("[name='{}']", s),
Locator::LinkText(s) => format!("a:contains('{}')", s),
Locator::PartialLinkText(s) => format!("a[href*='{}']", s),
Locator::TagName(s) => s.clone(),
Locator::ClassName(s) => format!(".{}", s),
Self::Id(s) => format!("#{s}"),
Self::Name(s) => format!("[name='{s}']"),
Self::LinkText(s) => format!("a:contains('{s}')"),
Self::PartialLinkText(s) => format!("a[href*='{s}']"),
Self::TagName(s) => s.clone(),
Self::ClassName(s) => format!(".{s}"),
}
}
}
/// Keyboard keys for special key presses
#[derive(Debug, Clone, Copy)]
pub enum Key {
Enter,
@ -178,7 +158,6 @@ pub enum Key {
Meta,
}
/// Mouse button
#[derive(Debug, Clone, Copy)]
pub enum MouseButton {
Left,
@ -186,33 +165,22 @@ pub enum MouseButton {
Middle,
}
/// Wait condition for elements
#[derive(Debug, Clone)]
pub enum WaitCondition {
/// Element is present in DOM
Present,
/// Element is visible
Visible,
/// Element is clickable
Clickable,
/// Element is not present
NotPresent,
/// Element is not visible
NotVisible,
/// Element contains text
ContainsText(String),
/// Element has attribute value
HasAttribute(String, String),
/// Custom JavaScript condition
Script(String),
}
/// Action chain for complex interactions
pub struct ActionChain {
actions: Vec<Action>,
}
/// Individual action in a chain
#[derive(Debug, Clone)]
pub enum Action {
Click(Locator),
@ -230,86 +198,86 @@ pub enum Action {
}
impl ActionChain {
/// Create a new action chain
pub fn new() -> Self {
#[must_use]
pub const fn new() -> Self {
Self {
actions: Vec::new(),
}
}
/// Add a click action
#[must_use]
pub fn click(mut self, locator: Locator) -> Self {
self.actions.push(Action::Click(locator));
self
}
/// Add a double click action
#[must_use]
pub fn double_click(mut self, locator: Locator) -> Self {
self.actions.push(Action::DoubleClick(locator));
self
}
/// Add a right click action
#[must_use]
pub fn right_click(mut self, locator: Locator) -> Self {
self.actions.push(Action::RightClick(locator));
self
}
/// Move to an element
#[must_use]
pub fn move_to(mut self, locator: Locator) -> Self {
self.actions.push(Action::MoveTo(locator));
self
}
/// Move by offset
#[must_use]
pub fn move_by(mut self, x: i32, y: i32) -> Self {
self.actions.push(Action::MoveByOffset(x, y));
self
}
/// Press a key down
#[must_use]
pub fn key_down(mut self, key: Key) -> Self {
self.actions.push(Action::KeyDown(key));
self
}
/// Release a key
#[must_use]
pub fn key_up(mut self, key: Key) -> Self {
self.actions.push(Action::KeyUp(key));
self
}
/// Send keys (type text)
#[must_use]
pub fn send_keys(mut self, text: &str) -> Self {
self.actions.push(Action::SendKeys(text.to_string()));
self
}
/// Pause for a duration
#[must_use]
pub fn pause(mut self, duration: Duration) -> Self {
self.actions.push(Action::Pause(duration));
self
}
/// Drag and drop
#[must_use]
pub fn drag_and_drop(mut self, source: Locator, target: Locator) -> Self {
self.actions.push(Action::DragAndDrop(source, target));
self
}
/// Scroll to element
#[must_use]
pub fn scroll_to(mut self, locator: Locator) -> Self {
self.actions.push(Action::ScrollTo(locator));
self
}
/// Scroll by amount
#[must_use]
pub fn scroll_by(mut self, x: i32, y: i32) -> Self {
self.actions.push(Action::ScrollByAmount(x, y));
self
}
/// Get the actions
#[must_use]
pub fn actions(&self) -> &[Action] {
&self.actions
}
@ -321,7 +289,6 @@ impl Default for ActionChain {
}
}
/// Cookie data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie {
pub name: String,
@ -335,6 +302,7 @@ pub struct Cookie {
}
impl Cookie {
#[must_use]
pub fn new(name: &str, value: &str) -> Self {
Self {
name: name.to_string(),
@ -348,22 +316,26 @@ impl Cookie {
}
}
#[must_use]
pub fn with_domain(mut self, domain: &str) -> Self {
self.domain = Some(domain.to_string());
self
}
#[must_use]
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
pub fn secure(mut self) -> Self {
#[must_use]
pub const fn secure(mut self) -> Self {
self.secure = Some(true);
self
}
pub fn http_only(mut self) -> Self {
#[must_use]
pub const fn http_only(mut self) -> Self {
self.http_only = Some(true);
self
}

View file

@ -1,7 +1,3 @@
//! Page Object Pattern implementations for E2E testing
//!
//! Provides structured page objects for interacting with botserver's web interface.
//! Each page object encapsulates the locators and actions for a specific page.
use anyhow::Result;
use std::time::Duration;
@ -9,99 +5,83 @@ use std::time::Duration;
use super::browser::{Browser, Element};
use super::Locator;
/// Base trait for all page objects
#[async_trait::async_trait]
pub trait Page {
/// Get the expected URL pattern for this page
fn url_pattern(&self) -> &str;
/// Check if we're on this page
async fn is_current(&self, browser: &Browser) -> Result<bool> {
let url = browser.current_url().await?;
Ok(url.contains(self.url_pattern()))
}
/// Wait for the page to be fully loaded
async fn wait_for_load(&self, browser: &Browser) -> Result<()>;
}
// =============================================================================
// Login Page
// =============================================================================
/// Login page object
pub struct LoginPage {
pub base_url: String,
}
impl LoginPage {
/// Create a new login page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to the login page
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/login", self.base_url)).await
}
/// Email input locator
#[must_use]
pub fn email_input() -> Locator {
Locator::css("#email, input[name='email'], input[type='email']")
}
/// Password input locator
#[must_use]
pub fn password_input() -> Locator {
Locator::css("#password, input[name='password'], input[type='password']")
}
/// Login button locator
#[must_use]
pub fn login_button() -> Locator {
Locator::css(
"#login-button, button[type='submit'], input[type='submit'], .login-btn, .btn-login",
)
}
/// Error message locator
#[must_use]
pub fn error_message() -> Locator {
Locator::css(".error, .error-message, .alert-error, .alert-danger, [role='alert']")
}
/// Enter email
pub async fn enter_email(&self, browser: &Browser, email: &str) -> Result<()> {
browser.fill(Self::email_input(), email).await
}
/// Enter password
pub async fn enter_password(&self, browser: &Browser, password: &str) -> Result<()> {
browser.fill(Self::password_input(), password).await
}
/// Click login button
pub async fn click_login(&self, browser: &Browser) -> Result<()> {
browser.click(Self::login_button()).await
}
/// Perform full login
pub async fn login(&self, browser: &Browser, email: &str, password: &str) -> Result<()> {
self.navigate(browser).await?;
self.wait_for_load(browser).await?;
self.enter_email(browser, email).await?;
self.enter_password(browser, password).await?;
self.click_login(browser).await?;
// Wait for navigation
tokio::time::sleep(Duration::from_millis(500)).await;
Ok(())
}
/// Check if error message is displayed
pub async fn has_error(&self, browser: &Browser) -> bool {
browser.exists(Self::error_message()).await
}
/// Get error message text
pub async fn get_error_message(&self, browser: &Browser) -> Result<String> {
browser.text(Self::error_message()).await
}
@ -109,7 +89,7 @@ impl LoginPage {
#[async_trait::async_trait]
impl Page for LoginPage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/login"
}
@ -120,65 +100,55 @@ impl Page for LoginPage {
}
}
// =============================================================================
// Dashboard Page
// =============================================================================
/// Dashboard home page object
pub struct DashboardPage {
pub base_url: String,
}
impl DashboardPage {
/// Create a new dashboard page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to the dashboard
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/dashboard", self.base_url)).await
}
/// Stats cards container locator
/// Stats cards locator
#[must_use]
pub fn stats_cards() -> Locator {
Locator::css(".stats-card, .dashboard-stat, .metric-card")
}
/// Navigation menu locator
#[must_use]
pub fn nav_menu() -> Locator {
Locator::css("nav, .nav, .sidebar, .navigation")
}
/// User profile button locator
#[must_use]
pub fn user_profile() -> Locator {
Locator::css(".user-profile, .user-menu, .profile-dropdown, .avatar")
}
/// Logout button locator
#[must_use]
pub fn logout_button() -> Locator {
Locator::css(".logout, .logout-btn, #logout, a[href*='logout'], button:contains('Logout')")
}
/// Get navigation menu items
pub async fn get_nav_items(&self, browser: &Browser) -> Result<Vec<Element>> {
browser
.find_all(Locator::css("nav a, .nav-item, .menu-item"))
.await
}
/// Click a navigation item by text
pub async fn navigate_to(&self, browser: &Browser, menu_text: &str) -> Result<()> {
let locator = Locator::xpath(&format!("//nav//a[contains(text(), '{}')]", menu_text));
let locator = Locator::xpath(&format!("//nav//a[contains(text(), '{menu_text}')]"));
browser.click(locator).await
}
/// Click logout
pub async fn logout(&self, browser: &Browser) -> Result<()> {
// First try to open user menu if needed
if browser.exists(Self::user_profile()).await {
let _ = browser.click(Self::user_profile()).await;
tokio::time::sleep(Duration::from_millis(200)).await;
@ -189,7 +159,7 @@ impl DashboardPage {
#[async_trait::async_trait]
impl Page for DashboardPage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/dashboard"
}
@ -199,18 +169,14 @@ impl Page for DashboardPage {
}
}
// =============================================================================
// Chat Page
// =============================================================================
/// Chat interface page object
pub struct ChatPage {
pub base_url: String,
pub bot_name: String,
}
impl ChatPage {
/// Create a new chat page object
#[must_use]
pub fn new(base_url: &str, bot_name: &str) -> Self {
Self {
base_url: base_url.to_string(),
@ -218,68 +184,63 @@ impl ChatPage {
}
}
/// Navigate to the chat page
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser
.goto(&format!("{}/chat/{}", self.base_url, self.bot_name))
.await
}
/// Chat input locator
/// Chat input field locator
#[must_use]
pub fn chat_input() -> Locator {
Locator::css(
"#chat-input, .chat-input, input[name='message'], textarea[name='message'], .message-input",
)
}
/// Send button locator
#[must_use]
pub fn send_button() -> Locator {
Locator::css("#send, .send-btn, button[type='submit'], .send-message")
}
/// Message list container locator
#[must_use]
pub fn message_list() -> Locator {
Locator::css(".messages, .message-list, .chat-messages, #messages")
}
/// Bot message locator
#[must_use]
pub fn bot_message() -> Locator {
Locator::css(".bot-message, .message-bot, .assistant-message, [data-role='bot']")
}
/// User message locator
#[must_use]
pub fn user_message() -> Locator {
Locator::css(".user-message, .message-user, [data-role='user']")
}
/// Typing indicator locator
#[must_use]
pub fn typing_indicator() -> Locator {
Locator::css(".typing, .typing-indicator, .is-typing, [data-typing]")
}
/// File upload button locator
#[must_use]
pub fn file_upload_button() -> Locator {
Locator::css(".upload-btn, .file-upload, input[type='file'], .attach-file")
}
/// Quick reply buttons locator
#[must_use]
pub fn quick_reply_buttons() -> Locator {
Locator::css(".quick-replies, .quick-reply, .suggested-reply")
}
/// Send a message
pub async fn send_message(&self, browser: &Browser, message: &str) -> Result<()> {
browser.fill(Self::chat_input(), message).await?;
browser.click(Self::send_button()).await?;
Ok(())
}
/// Wait for bot response
pub async fn wait_for_response(&self, browser: &Browser, timeout: Duration) -> Result<()> {
let start = std::time::Instant::now();
// First wait for typing indicator to appear
while start.elapsed() < timeout {
if browser.exists(Self::typing_indicator()).await {
break;
@ -287,7 +248,6 @@ impl ChatPage {
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Then wait for typing indicator to disappear
while start.elapsed() < timeout {
if !browser.exists(Self::typing_indicator()).await {
return Ok(());
@ -298,7 +258,6 @@ impl ChatPage {
anyhow::bail!("Timeout waiting for bot response")
}
/// Get all bot messages
pub async fn get_bot_messages(&self, browser: &Browser) -> Result<Vec<String>> {
let elements = browser.find_all(Self::bot_message()).await?;
let mut messages = Vec::new();
@ -310,7 +269,6 @@ impl ChatPage {
Ok(messages)
}
/// Get all user messages
pub async fn get_user_messages(&self, browser: &Browser) -> Result<Vec<String>> {
let elements = browser.find_all(Self::user_message()).await?;
let mut messages = Vec::new();
@ -322,7 +280,6 @@ impl ChatPage {
Ok(messages)
}
/// Get the last bot message
pub async fn get_last_bot_message(&self, browser: &Browser) -> Result<String> {
let messages = self.get_bot_messages(browser).await?;
messages
@ -331,16 +288,13 @@ impl ChatPage {
.ok_or_else(|| anyhow::anyhow!("No bot messages found"))
}
/// Check if typing indicator is visible
pub async fn is_typing(&self, browser: &Browser) -> bool {
browser.exists(Self::typing_indicator()).await
}
/// Click a quick reply button by text
pub async fn click_quick_reply(&self, browser: &Browser, text: &str) -> Result<()> {
let locator = Locator::xpath(&format!(
"//*[contains(@class, 'quick-reply') and contains(text(), '{}')]",
text
"//*[contains(@class, 'quick-reply') and contains(text(), '{text}')]"
));
browser.click(locator).await
}
@ -348,7 +302,7 @@ impl ChatPage {
#[async_trait::async_trait]
impl Page for ChatPage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/chat/"
}
@ -359,61 +313,53 @@ impl Page for ChatPage {
}
}
// =============================================================================
// Queue Panel Page
// =============================================================================
/// Queue management panel page object
pub struct QueuePage {
pub base_url: String,
}
impl QueuePage {
/// Create a new queue page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to the queue panel
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/queue", self.base_url)).await
}
/// Queue panel container locator
#[must_use]
pub fn queue_panel() -> Locator {
Locator::css(".queue-panel, .queue-container, #queue-panel")
}
/// Queue count display locator
#[must_use]
pub fn queue_count() -> Locator {
Locator::css(".queue-count, .waiting-count, #queue-count")
}
/// Queue entry locator
#[must_use]
pub fn queue_entry() -> Locator {
Locator::css(".queue-entry, .queue-item, .waiting-customer")
}
/// Take next button locator
#[must_use]
pub fn take_next_button() -> Locator {
Locator::css(".take-next, #take-next, button:contains('Take Next')")
}
/// Get queue count
pub async fn get_queue_count(&self, browser: &Browser) -> Result<u32> {
let text = browser.text(Self::queue_count()).await?;
text.parse::<u32>()
.map_err(|_| anyhow::anyhow!("Failed to parse queue count: {}", text))
.map_err(|_| anyhow::anyhow!("Failed to parse queue count: {text}"))
}
/// Get all queue entries
pub async fn get_queue_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::queue_entry()).await
}
/// Click take next button
pub async fn take_next(&self, browser: &Browser) -> Result<()> {
browser.click(Self::take_next_button()).await
}
@ -421,7 +367,7 @@ impl QueuePage {
#[async_trait::async_trait]
impl Page for QueuePage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/queue"
}
@ -431,69 +377,61 @@ impl Page for QueuePage {
}
}
// =============================================================================
// Bot Management Page
// =============================================================================
/// Bot management page object
pub struct BotManagementPage {
pub base_url: String,
}
impl BotManagementPage {
/// Create a new bot management page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to bot management
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/admin/bots", self.base_url)).await
}
/// Bot list container locator
#[must_use]
pub fn bot_list() -> Locator {
Locator::css(".bot-list, .bots-container, #bots")
}
/// Bot item locator
#[must_use]
pub fn bot_item() -> Locator {
Locator::css(".bot-item, .bot-card, .bot-entry")
}
/// Create bot button locator
#[must_use]
pub fn create_bot_button() -> Locator {
Locator::css(".create-bot, .new-bot, #create-bot, button:contains('Create')")
}
/// Bot name input locator
#[must_use]
pub fn bot_name_input() -> Locator {
Locator::css("#bot-name, input[name='name'], .bot-name-input")
}
/// Bot description input locator
#[must_use]
pub fn bot_description_input() -> Locator {
Locator::css("#bot-description, textarea[name='description'], .bot-description-input")
}
/// Save button locator
#[must_use]
pub fn save_button() -> Locator {
Locator::css(".save-btn, button[type='submit'], #save, button:contains('Save')")
}
/// Get all bots
pub async fn get_bots(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::bot_item()).await
}
/// Click create bot button
pub async fn click_create_bot(&self, browser: &Browser) -> Result<()> {
browser.click(Self::create_bot_button()).await
}
/// Create a new bot
pub async fn create_bot(&self, browser: &Browser, name: &str, description: &str) -> Result<()> {
self.click_create_bot(browser).await?;
tokio::time::sleep(Duration::from_millis(300)).await;
@ -505,12 +443,9 @@ impl BotManagementPage {
Ok(())
}
/// Click edit on a bot by name
/// Edit a bot by name
pub async fn edit_bot(&self, browser: &Browser, bot_name: &str) -> Result<()> {
let locator = Locator::xpath(&format!(
"//*[contains(@class, 'bot-item') and contains(., '{}')]//button[contains(@class, 'edit')]",
bot_name
"//*[contains(@class, 'bot-item') and contains(., '{bot_name}')]//button[contains(@class, 'edit')]"
));
browser.click(locator).await
}
@ -518,7 +453,7 @@ impl BotManagementPage {
#[async_trait::async_trait]
impl Page for BotManagementPage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/admin/bots"
}
@ -528,60 +463,52 @@ impl Page for BotManagementPage {
}
}
// =============================================================================
// Knowledge Base Page
// =============================================================================
/// Knowledge base management page object
pub struct KnowledgeBasePage {
pub base_url: String,
}
impl KnowledgeBasePage {
/// Create a new knowledge base page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to knowledge base
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/admin/kb", self.base_url)).await
}
/// KB entries list locator
/// KB list container locator
#[must_use]
pub fn kb_list() -> Locator {
Locator::css(".kb-list, .knowledge-base-list, #kb-list")
}
/// KB entry locator
#[must_use]
pub fn kb_entry() -> Locator {
Locator::css(".kb-entry, .kb-item, .knowledge-entry")
}
/// Upload button locator
#[must_use]
pub fn upload_button() -> Locator {
Locator::css(".upload-btn, #upload, button:contains('Upload')")
}
/// File input locator
#[must_use]
pub fn file_input() -> Locator {
Locator::css("input[type='file']")
}
/// Search input locator
#[must_use]
pub fn search_input() -> Locator {
Locator::css(".search-input, #search, input[placeholder*='search']")
}
/// Get all KB entries
pub async fn get_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::kb_entry()).await
}
/// Search the knowledge base
pub async fn search(&self, browser: &Browser, query: &str) -> Result<()> {
browser.fill(Self::search_input(), query).await
}
@ -589,7 +516,7 @@ impl KnowledgeBasePage {
#[async_trait::async_trait]
impl Page for KnowledgeBasePage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/admin/kb"
}
@ -599,46 +526,40 @@ impl Page for KnowledgeBasePage {
}
}
// =============================================================================
// Analytics Page
// =============================================================================
/// Analytics dashboard page object
pub struct AnalyticsPage {
pub base_url: String,
}
impl AnalyticsPage {
/// Create a new analytics page object
#[must_use]
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.to_string(),
}
}
/// Navigate to analytics
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser
.goto(&format!("{}/admin/analytics", self.base_url))
.await
}
/// Charts container locator
#[must_use]
pub fn charts_container() -> Locator {
Locator::css(".charts, .analytics-charts, #charts")
}
/// Date range picker locator
#[must_use]
pub fn date_range_picker() -> Locator {
Locator::css(".date-range, .date-picker, #date-range")
}
/// Metric card locator
#[must_use]
pub fn metric_card() -> Locator {
Locator::css(".metric-card, .analytics-metric, .stat-card")
}
/// Get all metric cards
pub async fn get_metrics(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::metric_card()).await
}
@ -646,7 +567,7 @@ impl AnalyticsPage {
#[async_trait::async_trait]
impl Page for AnalyticsPage {
fn url_pattern(&self) -> &str {
fn url_pattern(&self) -> &'static str {
"/admin/analytics"
}
@ -656,9 +577,6 @@ impl Page for AnalyticsPage {
}
}
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {

View file

@ -156,11 +156,9 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors {
let locator = Locator::css(selector);
if browser.exists(locator.clone()).await {
if browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
}
@ -171,20 +169,19 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
for selector in &logout_selectors {
let locator = Locator::css(selector);
if browser.exists(locator.clone()).await {
if browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
}
}
let current_url = browser.current_url().await.unwrap_or_default();
let base_url_with_slash = format!("{base_url}/");
let logged_out = current_url.contains("/login")
|| current_url.contains("/logout")
|| current_url == format!("{}/", base_url)
|| current_url == base_url.to_string();
|| current_url == base_url_with_slash
|| current_url == base_url;
if logged_out {
return Ok(true);
@ -442,10 +439,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") {
println!("✓ Session persisted after page refresh");
} else {
if current_url.contains("/login") {
eprintln!("✗ Session lost after refresh");
} else {
println!("✓ Session persisted after page refresh");
}
}
@ -454,10 +451,10 @@ async fn test_session_persistence() {
tokio::time::sleep(Duration::from_secs(1)).await;
let current_url = browser.current_url().await.unwrap_or_default();
if !current_url.contains("/login") {
println!("✓ Session maintained across navigation");
} else {
if current_url.contains("/login") {
eprintln!("✗ Session lost during navigation");
} else {
println!("✓ Session maintained across navigation");
}
}
} else {

View file

@ -1,105 +1,85 @@
use super::{should_run_e2e_tests, E2ETestContext};
use anyhow::{bail, Result};
use bottest::prelude::*;
use bottest::web::Locator;
/// Simple "hi" chat test with real botserver
#[tokio::test]
async fn test_chat_hi() {
async fn test_chat_hi() -> Result<()> {
if !should_run_e2e_tests() {
eprintln!("Skipping: E2E tests disabled");
return;
return Ok(());
}
let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx,
Err(e) => {
eprintln!("Test failed: {}", e);
panic!("Failed to setup E2E context: {}", e);
}
};
let ctx = E2ETestContext::setup_with_browser().await?;
if !ctx.has_browser() {
ctx.close().await;
panic!("Browser not available - cannot run E2E test");
bail!("Browser not available - cannot run E2E test");
}
// Chat UI requires botui
if ctx.ui.is_none() {
ctx.close().await;
panic!("BotUI not available - chat tests require botui running on port 3000");
bail!("BotUI not available - chat tests require botui running on port 3000");
}
let browser = ctx.browser.as_ref().unwrap();
// Use botui URL for chat (botserver is API only)
let ui_url = ctx.ui.as_ref().unwrap().url.clone();
let chat_url = format!("{}/#chat", ui_url);
println!("🌐 Navigating to: {}", chat_url);
println!("🌐 Navigating to: {chat_url}");
if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await;
panic!("Failed to navigate to chat: {}", e);
bail!("Failed to navigate to chat: {e}");
}
// Wait for page to load and HTMX to initialize chat content
println!("⏳ Waiting for page to load...");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
// Chat input: botui uses #messageInput or #ai-input
let input = Locator::css("#messageInput, #ai-input, .ai-input");
// Try to find input with retries (HTMX loads content dynamically)
let mut found_input = false;
for attempt in 1..=10 {
if browser.exists(input.clone()).await {
found_input = true;
println!("✓ Chat input found (attempt {})", attempt);
println!("✓ Chat input found (attempt {attempt})");
break;
}
println!(" ... waiting for chat input (attempt {}/10)", attempt);
println!(" ... waiting for chat input (attempt {attempt}/10)");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
if !found_input {
// Take screenshot on failure
if let Ok(screenshot) = browser.screenshot().await {
let _ = std::fs::write("/tmp/bottest-chat-fail.png", &screenshot);
println!("Screenshot saved to /tmp/bottest-chat-fail.png");
}
// Also print page source for debugging
if let Ok(source) = browser.page_source().await {
let preview: String = source.chars().take(2000).collect();
println!("Page source preview:\n{}", preview);
println!("Page source preview:\n{preview}");
}
ctx.close().await;
panic!("Chat input not found after 10 attempts");
bail!("Chat input not found after 10 attempts");
}
// Type "hi"
println!("⌨️ Typing 'hi'...");
if let Err(e) = browser.type_text(input.clone(), "hi").await {
ctx.close().await;
panic!("Failed to type: {}", e);
bail!("Failed to type: {e}");
}
// Click send button or press Enter
let send_btn = Locator::css("#sendBtn, #ai-send, .ai-send, button[type='submit']");
match browser.click(send_btn).await {
Ok(_) => println!("✓ Message sent (click)"),
Err(_) => {
// Try Enter key instead
match browser.press_key(input, "Enter").await {
Ok(_) => println!("✓ Message sent (Enter key)"),
Err(e) => println!("⚠ Send may have failed: {}", e),
}
}
Ok(()) => println!("✓ Message sent (click)"),
Err(_) => match browser.press_key(input, "Enter").await {
Ok(()) => println!("✓ Message sent (Enter key)"),
Err(e) => println!("⚠ Send may have failed: {e}"),
},
}
// Wait for response
println!("⏳ Waiting for bot response...");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// Check for response - botui uses .message.bot or .assistant class
let response =
Locator::css(".message.bot, .message.assistant, .bot-message, .assistant-message");
match browser.find_elements(response).await {
@ -111,7 +91,6 @@ async fn test_chat_hi() {
}
}
// Take final screenshot
if let Ok(screenshot) = browser.screenshot().await {
let _ = std::fs::write("/tmp/bottest-chat-result.png", &screenshot);
println!("📸 Screenshot: /tmp/bottest-chat-result.png");
@ -119,40 +98,34 @@ async fn test_chat_hi() {
ctx.close().await;
println!("✅ Chat test complete!");
Ok(())
}
#[tokio::test]
async fn test_chat_page_loads() {
async fn test_chat_page_loads() -> Result<()> {
if !should_run_e2e_tests() {
return;
return Ok(());
}
let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx,
Err(e) => {
panic!("Setup failed: {}", e);
}
};
let ctx = E2ETestContext::setup_with_browser().await?;
if !ctx.has_browser() {
ctx.close().await;
panic!("Browser not available");
bail!("Browser not available");
}
// Chat UI requires botui
if ctx.ui.is_none() {
ctx.close().await;
panic!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
bail!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
}
let browser = ctx.browser.as_ref().unwrap();
// Use botui URL for chat (botserver is API only)
let ui_url = ctx.ui.as_ref().unwrap().url.clone();
let chat_url = format!("{}/#chat", ui_url);
if let Err(e) = browser.goto(&chat_url).await {
ctx.close().await;
panic!("Navigation failed: {}", e);
bail!("Navigation failed: {e}");
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@ -165,9 +138,10 @@ async fn test_chat_page_loads() {
let _ = std::fs::write("/tmp/bottest-fail.png", &s);
}
ctx.close().await;
panic!("Chat not loaded: {}", e);
bail!("Chat not loaded: {e}");
}
}
ctx.close().await;
Ok(())
}

View file

@ -759,8 +759,6 @@ async fn test_with_fixtures() {
return;
}
// This test inserts fixtures into DB - requires direct DB connection
// When using existing stack, we connect to the existing database
let ctx = match E2ETestContext::setup().await {
Ok(ctx) => ctx,
Err(e) => {
@ -773,10 +771,12 @@ async fn test_with_fixtures() {
let bot = bot_with_kb("e2e-test-bot");
let customer = customer("+15551234567");
// Try to insert - may fail if DB schema doesn't match or DB not accessible
match ctx.ctx.insert_user(&user).await {
Ok(_) => println!("Inserted test user: {}", user.email),
Err(e) => eprintln!("Could not insert user (DB may not be directly accessible): {}", e),
Err(e) => eprintln!(
"Could not insert user (DB may not be directly accessible): {}",
e
),
}
match ctx.ctx.insert_bot(&bot).await {
@ -799,9 +799,6 @@ async fn test_mock_services_available() {
return;
}
// This test checks for harness-started mock services
// When using existing stack (default), harness mocks are started but PostgreSQL is not
// (we connect to the existing PostgreSQL instead)
let ctx = match E2ETestContext::setup().await {
Ok(ctx) => ctx,
Err(e) => {
@ -810,7 +807,6 @@ async fn test_mock_services_available() {
}
};
// Mock services are started by harness in both modes
if ctx.ctx.mock_llm().is_some() {
println!("✓ MockLLM is available");
} else {
@ -823,22 +819,16 @@ async fn test_mock_services_available() {
eprintln!("MockZitadel not available");
}
// PostgreSQL: only started by harness with FRESH_STACK=1
// In existing stack mode, postgres() returns None (we use external DB)
if ctx.ctx.use_existing_stack {
println!("Using existing stack - PostgreSQL is external (not managed by harness)");
// Verify we can connect to the existing database
match ctx.ctx.db_pool().await {
Ok(_pool) => println!("✓ Connected to existing PostgreSQL"),
Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e),
}
} else if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness");
} else {
// Fresh stack mode - harness starts PostgreSQL
if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness");
} else {
eprintln!("PostgreSQL should be started in fresh stack mode");
}
eprintln!("PostgreSQL should be started in fresh stack mode");
}
ctx.close().await;

View file

@ -16,7 +16,6 @@ pub struct E2ETestContext {
browser_service: Option<BrowserService>,
}
/// Check if a service is running at the given URL
async fn is_service_running(url: &str) -> bool {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2))
@ -24,8 +23,7 @@ async fn is_service_running(url: &str) -> bool {
.build()
.unwrap_or_default();
// Try health endpoint first, then root
if let Ok(resp) = client.get(&format!("{}/health", url)).send().await {
if let Ok(resp) = client.get(format!("{url}/health")).send().await {
if resp.status().is_success() {
return true;
}
@ -38,15 +36,6 @@ async fn is_service_running(url: &str) -> bool {
impl E2ETestContext {
pub async fn setup() -> anyhow::Result<Self> {
// Default strategy: Use main botserver stack at https://localhost:8080
// This ensures LLM and all services are properly configured
// User should start botserver normally: cd botserver && cargo run
//
// Override with env vars:
// BOTSERVER_URL=https://localhost:8080
// BOTUI_URL=http://localhost:3000
// FRESH_STACK=1 (to start a new temp stack instead)
let botserver_url =
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
let botui_url =
@ -55,20 +44,16 @@ impl E2ETestContext {
let botserver_running = is_service_running(&botserver_url).await;
let botui_running = is_service_running(&botui_url).await;
// Always use existing stack context (main stack)
let ctx = TestHarness::with_existing_stack().await?;
// Check if botserver is running, if not start it with main stack
let server = if botserver_running {
println!("🔗 Using existing BotServer at {}", botserver_url);
BotServerInstance::existing(&botserver_url)
} else {
// Auto-start botserver with main stack (includes LLM)
println!("🚀 Auto-starting BotServer with main stack...");
BotServerInstance::start_with_main_stack().await?
};
// Ensure botui is running (required for chat UI)
let ui = if botui_running {
println!("🔗 Using existing BotUI at {}", botui_url);
Some(BotUIInstance::existing(&botui_url))
@ -100,15 +85,6 @@ impl E2ETestContext {
}
pub async fn setup_with_browser() -> anyhow::Result<Self> {
// Default strategy: Use main botserver stack at https://localhost:8080
// This ensures LLM and all services are properly configured
// User should start botserver normally: cd botserver && cargo run
//
// Override with env vars:
// BOTSERVER_URL=https://localhost:8080
// BOTUI_URL=http://localhost:3000
// FRESH_STACK=1 (to start a new temp stack instead)
let botserver_url =
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
let botui_url =
@ -117,20 +93,16 @@ impl E2ETestContext {
let botserver_running = is_service_running(&botserver_url).await;
let botui_running = is_service_running(&botui_url).await;
// Always use existing stack context (main stack)
let ctx = TestHarness::with_existing_stack().await?;
// Check if botserver is running, if not start it with main stack
let server = if botserver_running {
println!("🔗 Using existing BotServer at {}", botserver_url);
BotServerInstance::existing(&botserver_url)
} else {
// Auto-start botserver with main stack (includes LLM)
println!("🚀 Auto-starting BotServer with main stack...");
BotServerInstance::start_with_main_stack().await?
};
// Ensure botui is running (required for chat UI)
let ui = if botui_running {
println!("🔗 Using existing BotUI at {}", botui_url);
Some(BotUIInstance::existing(&botui_url))
@ -152,7 +124,6 @@ impl E2ETestContext {
}
};
// Start browser with CDP (no chromedriver needed!)
let browser_service = match BrowserService::start(DEFAULT_DEBUG_PORT).await {
Ok(bs) => {
log::info!("Browser started with CDP on port {}", DEFAULT_DEBUG_PORT);
@ -192,7 +163,6 @@ impl E2ETestContext {
})
}
/// Get the base URL for browser tests - uses botui if available, otherwise botserver
pub fn base_url(&self) -> &str {
if let Some(ref ui) = self.ui {
&ui.url
@ -201,7 +171,6 @@ impl E2ETestContext {
}
}
/// Get the botserver API URL
pub fn api_url(&self) -> &str {
&self.server.url
}
@ -212,7 +181,7 @@ impl E2ETestContext {
pub async fn close(mut self) {
if let Some(browser) = self.browser {
let _ = browser.close().await;
let _ = browser.close();
}
if let Some(mut bs) = self.browser_service.take() {
let _ = bs.stop().await;
@ -221,19 +190,16 @@ impl E2ETestContext {
}
pub fn browser_config() -> BrowserConfig {
// Default: SHOW browser window so user can see tests
// Set HEADLESS=1 to run without browser window (CI/automation)
let headless = std::env::var("HEADLESS").is_ok();
let debug_port = std::env::var("CDP_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_DEBUG_PORT);
// Use CDP directly - no chromedriver needed!
BrowserConfig::default()
.with_browser(BrowserType::Chrome)
.with_debug_port(debug_port)
.headless(headless) // false by default = show browser
.headless(headless)
.with_timeout(Duration::from_secs(30))
.with_window_size(1920, 1080)
}
@ -301,7 +267,6 @@ async fn test_harness_starts_server() {
return;
}
// This test explicitly starts a new server - only run with FRESH_STACK=1
if std::env::var("FRESH_STACK").is_err() {
eprintln!("Skipping: test_harness_starts_server requires FRESH_STACK=1 (uses existing stack by default)");
return;
@ -335,7 +300,6 @@ async fn test_harness_starts_server() {
#[tokio::test]
async fn test_full_harness_has_all_services() {
// This test checks harness-started services - only meaningful with FRESH_STACK=1
if std::env::var("FRESH_STACK").is_err() {
eprintln!("Skipping: test_full_harness_has_all_services requires FRESH_STACK=1 (uses existing stack by default)");
return;
@ -349,7 +313,6 @@ async fn test_full_harness_has_all_services() {
}
};
// Check services that are enabled in full() config
assert!(ctx.postgres().is_some(), "PostgreSQL should be available");
assert!(ctx.mock_llm().is_some(), "MockLLM should be available");
assert!(
@ -357,17 +320,12 @@ async fn test_full_harness_has_all_services() {
"MockZitadel should be available"
);
// MinIO and Redis are disabled in full() config (not in botserver-stack)
// so we don't assert they are present
assert!(ctx.data_dir.exists());
assert!(ctx.data_dir.to_str().unwrap().contains("bottest-"));
}
#[tokio::test]
async fn test_e2e_cleanup() {
// This test creates a temp data dir and cleans it up
// Safe to run in both modes since it only cleans up its own tmp dir
let mut ctx = match TestHarness::full().await {
Ok(ctx) => ctx,
Err(e) => {
@ -384,7 +342,6 @@ async fn test_e2e_cleanup() {
assert!(!data_dir.exists());
}
/// Test that checks the existing running stack is accessible
#[tokio::test]
async fn test_existing_stack_connection() {
if !should_run_e2e_tests() {
@ -392,10 +349,8 @@ async fn test_existing_stack_connection() {
return;
}
// Use existing stack by default
match E2ETestContext::setup().await {
Ok(ctx) => {
// Check botserver is accessible
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()

View file

@ -1,11 +1,3 @@
//! Complete E2E test for General Bots platform flow
//!
//! Tests the full user journey:
//! 1. Platform loading (UI assets)
//! 2. BotServer initialization
//! 3. User login
//! 4. Chat interaction
//! 5. User logout
use bottest::prelude::*;
use bottest::web::{Browser, Locator};
@ -13,14 +5,9 @@ use std::time::Duration;
use super::{check_webdriver_available, should_run_e2e_tests, E2ETestContext};
/// Step 1: Verify platform loads
/// - Check UI is served
/// - Verify health endpoint responds
/// - Confirm database migrations completed
pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()> {
let client = reqwest::Client::new();
// Check health endpoint
let health_url = format!("{}/health", ctx.base_url());
let health_resp = client.get(&health_url).send().await?;
assert!(
@ -31,7 +18,6 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
println!("✓ Platform health check passed");
// Verify API is responsive
let api_url = format!("{}/api/v1", ctx.base_url());
let api_resp = client.get(&api_url).send().await?;
assert!(
@ -45,19 +31,13 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
Ok(())
}
/// Step 2: Verify BotServer is running and initialized
/// - Check service discovery
/// - Verify configuration loaded
/// - Confirm database connection
pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()> {
let client = reqwest::Client::new();
// Check if server is actually running
assert!(ctx.server.is_running(), "BotServer process is not running");
println!("✓ BotServer process running");
// Verify server info endpoint
let info_url = format!("{}/api/v1/server/info", ctx.base_url());
match client.get(&info_url).send().await {
Ok(resp) => {
@ -88,25 +68,17 @@ pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()
Ok(())
}
/// Step 3: User login flow
/// - Navigate to login page
/// - Enter test credentials
/// - Verify session created
/// - Confirm redirect to dashboard
pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
let login_url = format!("{}/login", ctx.base_url());
// Navigate to login page
browser.goto(&login_url).await?;
println!("✓ Navigated to login page: {}", login_url);
// Wait for login form to be visible
browser
.wait_for(Locator::css("input[type='email']"))
.await?;
println!("✓ Login form loaded");
// Fill in test credentials
let test_email = "test@example.com";
let test_password = "TestPassword123!";
@ -120,17 +92,13 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
.await?;
println!("✓ Entered password");
// Submit login form
browser.click(Locator::css("button[type='submit']")).await?;
println!("✓ Clicked login button");
// Wait a bit for redirect
tokio::time::sleep(Duration::from_secs(2)).await;
// Get current URL
let current_url = browser.current_url().await?;
// Check we're not on login page anymore
assert!(
!current_url.contains("/login"),
"Still on login page after login attempt. URL: {}",
@ -139,7 +107,6 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
println!("✓ Redirected from login page to: {}", current_url);
// Verify we can see dashboard or chat area
browser
.wait_for(Locator::css(
"[data-testid='chat-area'], [data-testid='dashboard'], main",
@ -150,18 +117,11 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
Ok(())
}
/// Step 4: Chat interaction
/// - Open chat window
/// - Send test message
/// - Receive bot response
/// - Verify message persisted
pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
// Ensure we're on chat page
let chat_url = format!("{}/chat", ctx.base_url());
browser.goto(&chat_url).await?;
println!("✓ Navigated to chat page");
// Wait for chat interface to load
browser
.wait_for(Locator::css(
"[data-testid='message-input'], textarea.chat-input, input.message",
@ -169,7 +129,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?;
println!("✓ Chat interface loaded");
// Send test message
let test_message = "Hello, I need help";
browser
.fill(
@ -179,7 +138,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?;
println!("✓ Typed message: {}", test_message);
// Click send button
let send_result = browser
.click(Locator::css(
"button[data-testid='send-button'], button.send-btn",
@ -187,7 +145,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await;
if send_result.is_err() {
// Try pressing Enter as alternative - find the input and send Enter key
let input = browser
.find(Locator::css("textarea.chat-input, input.message"))
.await?;
@ -197,7 +154,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
println!("✓ Clicked send button");
}
// Wait for message to appear in chat history
browser
.wait_for(Locator::css(
"[data-testid='message-item'], .message-bubble, [class*='message']",
@ -205,7 +161,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?;
println!("✓ Message appeared in chat");
// Wait for bot response
browser
.wait_for(Locator::css(
"[data-testid='bot-response'], .bot-message, [class*='bot']",
@ -213,7 +168,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?;
println!("✓ Received bot response");
// Get response text
let response_text = browser
.text(Locator::css(
"[data-testid='bot-response'], .bot-message, [class*='bot']",
@ -231,13 +185,7 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
Ok(())
}
/// Step 5: User logout flow
/// - Click logout button
/// - Verify session invalidated
/// - Confirm redirect to login
/// - Verify cannot access protected routes
pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
// Find and click logout button
let logout_selectors = vec![
"button[data-testid='logout-btn']",
"button.logout",
@ -260,7 +208,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
browser.goto(&logout_url).await?;
}
// Wait for redirect to login
tokio::time::sleep(Duration::from_secs(2)).await;
let current_url = browser.current_url().await?;
@ -272,7 +219,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
println!("✓ Redirected to login page after logout: {}", current_url);
// Verify we cannot access protected routes
let chat_url = format!("{}/chat", ctx.base_url());
browser.goto(&chat_url).await?;
@ -289,14 +235,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
Ok(())
}
/// Complete platform flow test
///
/// This test validates the entire user journey:
/// 1. Platform loads successfully
/// 2. BotServer is initialized and running
/// 3. User can login with credentials
/// 4. User can interact with chat
/// 5. User can logout and lose access
#[tokio::test]
async fn test_complete_platform_flow_login_chat_logout() {
if !should_run_e2e_tests() {
@ -311,7 +249,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
println!("\n=== Starting Complete Platform Flow Test ===\n");
// Setup context
let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx,
Err(e) => {
@ -327,7 +264,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
let browser = ctx.browser.as_ref().unwrap();
// Test each phase
println!("\n--- Phase 1: Platform Loading ---");
if let Err(e) = verify_platform_loading(&ctx).await {
eprintln!("Platform loading test failed: {}", e);
@ -349,7 +285,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
println!("\n--- Phase 4: Chat Interaction ---");
if let Err(e) = test_chat_interaction(browser, &ctx).await {
eprintln!("Chat interaction test failed: {}", e);
// Don't return here - try to logout anyway
}
println!("\n--- Phase 5: User Logout ---");
@ -363,7 +298,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
ctx.close().await;
}
/// Simpler test for basic platform loading without browser
#[tokio::test]
async fn test_platform_loading_http_only() {
if !should_run_e2e_tests() {
@ -391,7 +325,6 @@ async fn test_platform_loading_http_only() {
ctx.close().await;
}
/// Test BotServer startup and health
#[tokio::test]
async fn test_botserver_startup() {
if !should_run_e2e_tests() {
@ -409,7 +342,6 @@ async fn test_botserver_startup() {
}
};
// Skip if botserver isn't running (binary not found or failed to start)
if !ctx.server.is_running() {
eprintln!("Skipping: BotServer not running (BOTSERVER_BIN not set or binary not found)");
ctx.close().await;

View file

@ -36,25 +36,6 @@ async fn get_test_server() -> Option<(Option<TestContext>, String)> {
}
}
fn is_server_available_sync() -> bool {
if std::env::var("SKIP_INTEGRATION_TESTS").is_ok() {
return false;
}
if let Some(url) = external_server_url() {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.ok();
if let Some(client) = client {
return client.get(&url).send().is_ok();
}
}
false
}
macro_rules! skip_if_no_server {
($base_url:expr) => {
if $base_url.is_none() {
@ -658,7 +639,7 @@ async fn test_mock_llm_assertions() {
let client = reqwest::Client::new();
let _ = client
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
.post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "test"}]
@ -685,7 +666,7 @@ async fn test_mock_llm_error_simulation() {
let client = reqwest::Client::new();
let response = client
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
.post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "test"}]

View file

@ -1,15 +1,9 @@
use rhai::Engine;
use std::sync::{Arc, Mutex};
// =============================================================================
// Test Utilities
// =============================================================================
/// Create a Rhai engine with BASIC-like functions registered
fn create_basic_engine() -> Engine {
let mut engine = Engine::new();
// Register string functions
engine.register_fn("INSTR", |haystack: &str, needle: &str| -> i64 {
if haystack.is_empty() || needle.is_empty() {
return 0;
@ -49,7 +43,6 @@ fn create_basic_engine() -> Engine {
s.replace(find, replace)
});
// Register math functions
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
engine.register_fn("ABS", |n: f64| -> f64 { n.abs() });
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
@ -72,14 +65,12 @@ fn create_basic_engine() -> Engine {
engine.register_fn("TAN", |n: f64| -> f64 { n.tan() });
engine.register_fn("PI", || -> f64 { std::f64::consts::PI });
// Register type conversion
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
engine.register_fn("STR", |n: i64| -> String { n.to_string() });
engine.register_fn("STR", |n: f64| -> String { n.to_string() });
// Register type checking
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
@ -91,7 +82,6 @@ fn create_basic_engine() -> Engine {
engine
}
/// Mock output collector for TALK commands
#[derive(Clone, Default)]
struct OutputCollector {
messages: Arc<Mutex<Vec<String>>>,
@ -114,7 +104,6 @@ impl OutputCollector {
}
}
/// Mock input provider for HEAR commands
#[derive(Clone)]
struct InputProvider {
inputs: Arc<Mutex<Vec<String>>>,
@ -142,26 +131,18 @@ impl InputProvider {
}
}
/// Create an engine with TALK/HEAR simulation
fn create_conversation_engine(output: OutputCollector, input: InputProvider) -> Engine {
let mut engine = create_basic_engine();
// Register TALK function
let output_clone = output.clone();
engine.register_fn("TALK", move |msg: &str| {
output_clone.add_message(msg.to_string());
output.add_message(msg.to_string());
});
// Register HEAR function
engine.register_fn("HEAR", move || -> String { input.next_input() });
engine
}
// =============================================================================
// String Function Tests with Engine
// =============================================================================
#[test]
fn test_string_concatenation_in_engine() {
let engine = create_basic_engine();
@ -176,11 +157,9 @@ fn test_string_concatenation_in_engine() {
fn test_string_functions_chain() {
let engine = create_basic_engine();
// Test chained operations: UPPER(TRIM(" hello "))
let result: String = engine.eval(r#"UPPER(TRIM(" hello "))"#).unwrap();
assert_eq!(result, "HELLO");
// Test LEN(TRIM(" test "))
let result: i64 = engine.eval(r#"LEN(TRIM(" test "))"#).unwrap();
assert_eq!(result, 4);
}
@ -189,15 +168,12 @@ fn test_string_functions_chain() {
fn test_substring_extraction() {
let engine = create_basic_engine();
// LEFT
let result: String = engine.eval(r#"LEFT("Hello World", 5)"#).unwrap();
assert_eq!(result, "Hello");
// RIGHT
let result: String = engine.eval(r#"RIGHT("Hello World", 5)"#).unwrap();
assert_eq!(result, "World");
// MID
let result: String = engine.eval(r#"MID("Hello World", 7, 5)"#).unwrap();
assert_eq!(result, "World");
}
@ -207,13 +183,13 @@ fn test_instr_function() {
let engine = create_basic_engine();
let result: i64 = engine.eval(r#"INSTR("Hello World", "World")"#).unwrap();
assert_eq!(result, 7); // 1-based index
assert_eq!(result, 7);
let result: i64 = engine.eval(r#"INSTR("Hello World", "xyz")"#).unwrap();
assert_eq!(result, 0); // Not found
assert_eq!(result, 0);
let result: i64 = engine.eval(r#"INSTR("Hello World", "o")"#).unwrap();
assert_eq!(result, 5); // First occurrence
assert_eq!(result, 5);
}
#[test]
@ -229,19 +205,13 @@ fn test_replace_function() {
assert_eq!(result, "bbb");
}
// =============================================================================
// Math Function Tests with Engine
// =============================================================================
#[test]
fn test_math_operations_chain() {
let engine = create_basic_engine();
// SQRT(ABS(-16))
let result: f64 = engine.eval("SQRT(ABS(-16.0))").unwrap();
assert!((result - 4.0).abs() < f64::EPSILON);
// MAX(ABS(-5), ABS(-10))
let result: i64 = engine.eval("MAX(ABS(-5), ABS(-10))").unwrap();
assert_eq!(result, 10);
}
@ -250,21 +220,18 @@ fn test_math_operations_chain() {
fn test_rounding_functions() {
let engine = create_basic_engine();
// ROUND
let result: i64 = engine.eval("ROUND(3.7)").unwrap();
assert_eq!(result, 4);
let result: i64 = engine.eval("ROUND(3.2)").unwrap();
assert_eq!(result, 3);
// FLOOR
let result: i64 = engine.eval("FLOOR(3.9)").unwrap();
assert_eq!(result, 3);
let result: i64 = engine.eval("FLOOR(-3.1)").unwrap();
assert_eq!(result, -4);
// CEIL
let result: i64 = engine.eval("CEIL(3.1)").unwrap();
assert_eq!(result, 4);
@ -293,17 +260,13 @@ fn test_val_function() {
let result: f64 = engine.eval(r#"VAL("42")"#).unwrap();
assert!((result - 42.0).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("3.14")"#).unwrap();
assert!((result - 3.14).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("3.5")"#).unwrap();
assert!((result - 3.5).abs() < f64::EPSILON);
let result: f64 = engine.eval(r#"VAL("invalid")"#).unwrap();
assert!((result - 0.0).abs() < f64::EPSILON);
}
// =============================================================================
// TALK/HEAR Conversation Tests
// =============================================================================
#[test]
fn test_talk_output() {
let output = OutputCollector::new();
@ -422,26 +385,21 @@ fn test_keyword_detection() {
let messages = output.get_messages();
assert_eq!(messages.len(), 1);
// Should match "HELP" first since it appears before "ORDER" in the conditions
assert_eq!(messages[0], "I can help you! What do you need?");
}
// =============================================================================
// Variable and Expression Tests
// =============================================================================
#[test]
fn test_variable_assignment() {
let engine = create_basic_engine();
let result: i64 = engine
.eval(
r#"
r"
let x = 10;
let y = 20;
let z = x + y;
z
"#,
",
)
.unwrap();
assert_eq!(result, 30);
@ -468,22 +426,16 @@ fn test_string_variables() {
fn test_numeric_expressions() {
let engine = create_basic_engine();
// Order of operations
let result: i64 = engine.eval("2 + 3 * 4").unwrap();
assert_eq!(result, 14);
let result: i64 = engine.eval("(2 + 3) * 4").unwrap();
assert_eq!(result, 20);
// Using functions in expressions
let result: i64 = engine.eval("ABS(-5) + MAX(3, 7)").unwrap();
assert_eq!(result, 12);
}
// =============================================================================
// Loop and Control Flow Tests
// =============================================================================
#[test]
fn test_for_loop() {
let output = OutputCollector::new();
@ -513,7 +465,7 @@ fn test_while_loop() {
let result: i64 = engine
.eval(
r#"
r"
let count = 0;
let sum = 0;
while count < 5 {
@ -521,25 +473,19 @@ fn test_while_loop() {
count = count + 1;
}
sum
"#,
",
)
.unwrap();
assert_eq!(result, 10); // 0 + 1 + 2 + 3 + 4 = 10
assert_eq!(result, 10);
}
// =============================================================================
// Error Handling Tests
// =============================================================================
#[test]
fn test_division_by_zero() {
let engine = create_basic_engine();
// Rhai handles division by zero differently for int vs float
let result = engine.eval::<f64>("10.0 / 0.0");
match result {
Ok(val) => assert!(val.is_infinite() || val.is_nan()),
Err(_) => (), // Division by zero error is also acceptable
if let Ok(val) = result {
assert!(val.is_infinite() || val.is_nan());
}
}
@ -547,7 +493,6 @@ fn test_division_by_zero() {
fn test_invalid_function_call() {
let engine = create_basic_engine();
// Calling undefined function should error
let result = engine.eval::<String>(r#"UNDEFINED_FUNCTION("test")"#);
assert!(result.is_err());
}
@ -556,22 +501,16 @@ fn test_invalid_function_call() {
fn test_type_mismatch() {
let engine = create_basic_engine();
// Trying to use string where number expected
let result = engine.eval::<i64>(r#"ABS("not a number")"#);
assert!(result.is_err());
}
// =============================================================================
// Script Fixture Tests
// =============================================================================
#[test]
fn test_greeting_script_logic() {
let output = OutputCollector::new();
let input = InputProvider::new(vec!["HELP".to_string()]);
let engine = create_conversation_engine(output.clone(), input);
// Simulated greeting script logic
engine
.eval::<()>(
r#"
@ -659,10 +598,6 @@ fn test_echo_bot_logic() {
assert_eq!(messages[2], "You said: How are you?");
}
// =============================================================================
// Complex Scenario Tests
// =============================================================================
#[test]
fn test_order_lookup_simulation() {
let output = OutputCollector::new();
@ -675,7 +610,6 @@ fn test_order_lookup_simulation() {
TALK("Please enter your order number:");
let order_num = HEAR();
// Simulate order lookup
let is_valid = INSTR(order_num, "ORD-") == 1 && LEN(order_num) >= 9;
if is_valid {
@ -723,6 +657,5 @@ fn test_price_calculation() {
let messages = output.get_messages();
assert_eq!(messages.len(), 5);
assert!(messages[0].contains("29.99"));
// Subtotal should be 89.97
assert!(messages[2].contains("89.97"));
}

View file

@ -154,37 +154,37 @@ async fn test_query_result_types() {
use diesel::sql_query;
#[derive(QueryableByName)]
struct TypeTestResult {
struct TypeTestRow {
#[diesel(sql_type = diesel::sql_types::Integer)]
int_val: i32,
integer: i32,
#[diesel(sql_type = diesel::sql_types::BigInt)]
bigint_val: i64,
bigint: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
text_val: String,
text: String,
#[diesel(sql_type = diesel::sql_types::Bool)]
bool_val: bool,
flag: bool,
#[diesel(sql_type = diesel::sql_types::Double)]
float_val: f64,
decimal: f64,
}
let mut conn = pool.get().expect("Failed to get connection");
let result: Vec<TypeTestResult> = sql_query(
let result: Vec<TypeTestRow> = sql_query(
"SELECT
42 as int_val,
9223372036854775807::bigint as bigint_val,
'hello' as text_val,
true as bool_val,
3.14159 as float_val",
42 as integer,
9223372036854775807::bigint as bigint,
'hello' as text,
true as flag,
3.125 as decimal",
)
.load(&mut conn)
.expect("Query failed");
assert_eq!(result.len(), 1);
assert_eq!(result[0].int_val, 42);
assert_eq!(result[0].bigint_val, 9223372036854775807_i64);
assert_eq!(result[0].text_val, "hello");
assert!(result[0].bool_val);
assert!((result[0].float_val - 3.14159).abs() < 0.0001);
assert_eq!(result[0].integer, 42);
assert_eq!(result[0].bigint, 9_223_372_036_854_775_807_i64);
assert_eq!(result[0].text, "hello");
assert!(result[0].flag);
assert!((result[0].decimal - 3.125).abs() < 0.0001);
}
#[tokio::test]

View file

@ -29,16 +29,6 @@ pub fn should_run_integration_tests() -> bool {
true
}
#[macro_export]
macro_rules! skip_if_no_services {
() => {
if !crate::integration::should_run_integration_tests() {
eprintln!("Skipping integration test: SKIP_INTEGRATION_TESTS is set");
return;
}
};
}
#[tokio::test]
async fn test_harness_database_only() {
if !should_run_integration_tests() {

View file

@ -1,355 +0,0 @@
//! Unit tests for attendance module from botserver
//!
//! These tests verify the queue priority and ordering logic
//! in the attendance system.
use std::cmp::Ordering;
/// Priority levels matching botserver's attendance queue
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
Low = 0,
Normal = 1,
High = 2,
Urgent = 3,
}
/// Queue entry for testing
#[derive(Debug, Clone)]
pub struct QueueEntry {
pub id: u64,
pub customer_id: String,
pub priority: Priority,
pub entered_at: u64, // Unix timestamp
}
impl QueueEntry {
pub fn new(id: u64, customer_id: &str, priority: Priority, entered_at: u64) -> Self {
Self {
id,
customer_id: customer_id.to_string(),
priority,
entered_at,
}
}
}
/// Compare queue entries: higher priority first, then earlier timestamp
fn compare_queue_entries(a: &QueueEntry, b: &QueueEntry) -> Ordering {
// Higher priority comes first (reverse order)
match b.priority.cmp(&a.priority) {
Ordering::Equal => {
// Same priority: earlier timestamp comes first
a.entered_at.cmp(&b.entered_at)
}
other => other,
}
}
/// Sort a queue by priority and timestamp
fn sort_queue(entries: &mut [QueueEntry]) {
entries.sort_by(compare_queue_entries);
}
/// Get the next entry from the queue (highest priority, earliest time)
fn get_next_in_queue(entries: &[QueueEntry]) -> Option<&QueueEntry> {
if entries.is_empty() {
return None;
}
let mut best = &entries[0];
for entry in entries.iter().skip(1) {
if compare_queue_entries(entry, best) == Ordering::Less {
best = entry;
}
}
Some(best)
}
// =============================================================================
// Priority Comparison Tests
// =============================================================================
#[test]
fn test_priority_ordering() {
assert!(Priority::Urgent > Priority::High);
assert!(Priority::High > Priority::Normal);
assert!(Priority::Normal > Priority::Low);
}
#[test]
fn test_priority_equality() {
assert_eq!(Priority::Normal, Priority::Normal);
assert_ne!(Priority::Normal, Priority::High);
}
// =============================================================================
// Queue Entry Comparison Tests
// =============================================================================
#[test]
fn test_higher_priority_comes_first() {
let high = QueueEntry::new(1, "customer1", Priority::High, 1000);
let normal = QueueEntry::new(2, "customer2", Priority::Normal, 900);
// High priority should come before normal, even if normal entered earlier
assert_eq!(compare_queue_entries(&high, &normal), Ordering::Less);
}
#[test]
fn test_same_priority_earlier_time_first() {
let first = QueueEntry::new(1, "customer1", Priority::Normal, 1000);
let second = QueueEntry::new(2, "customer2", Priority::Normal, 1100);
// Same priority: earlier timestamp comes first
assert_eq!(compare_queue_entries(&first, &second), Ordering::Less);
}
#[test]
fn test_same_priority_same_time() {
let a = QueueEntry::new(1, "customer1", Priority::Normal, 1000);
let b = QueueEntry::new(2, "customer2", Priority::Normal, 1000);
assert_eq!(compare_queue_entries(&a, &b), Ordering::Equal);
}
#[test]
fn test_urgent_beats_everything() {
let urgent = QueueEntry::new(1, "customer1", Priority::Urgent, 2000);
let high = QueueEntry::new(2, "customer2", Priority::High, 1000);
let normal = QueueEntry::new(3, "customer3", Priority::Normal, 500);
let low = QueueEntry::new(4, "customer4", Priority::Low, 100);
assert_eq!(compare_queue_entries(&urgent, &high), Ordering::Less);
assert_eq!(compare_queue_entries(&urgent, &normal), Ordering::Less);
assert_eq!(compare_queue_entries(&urgent, &low), Ordering::Less);
}
// =============================================================================
// Queue Sorting Tests
// =============================================================================
#[test]
fn test_sort_queue_by_priority() {
let mut queue = vec![
QueueEntry::new(1, "low", Priority::Low, 1000),
QueueEntry::new(2, "urgent", Priority::Urgent, 1000),
QueueEntry::new(3, "normal", Priority::Normal, 1000),
QueueEntry::new(4, "high", Priority::High, 1000),
];
sort_queue(&mut queue);
assert_eq!(queue[0].priority, Priority::Urgent);
assert_eq!(queue[1].priority, Priority::High);
assert_eq!(queue[2].priority, Priority::Normal);
assert_eq!(queue[3].priority, Priority::Low);
}
#[test]
fn test_sort_queue_mixed_priority_and_time() {
let mut queue = vec![
QueueEntry::new(1, "normal_late", Priority::Normal, 2000),
QueueEntry::new(2, "high_late", Priority::High, 1500),
QueueEntry::new(3, "normal_early", Priority::Normal, 1000),
QueueEntry::new(4, "high_early", Priority::High, 1200),
];
sort_queue(&mut queue);
// High priority entries first, ordered by time
assert_eq!(queue[0].id, 4); // high_early
assert_eq!(queue[1].id, 2); // high_late
// Then normal priority, ordered by time
assert_eq!(queue[2].id, 3); // normal_early
assert_eq!(queue[3].id, 1); // normal_late
}
#[test]
fn test_sort_empty_queue() {
let mut queue: Vec<QueueEntry> = vec![];
sort_queue(&mut queue);
assert!(queue.is_empty());
}
#[test]
fn test_sort_single_entry() {
let mut queue = vec![QueueEntry::new(1, "only", Priority::Normal, 1000)];
sort_queue(&mut queue);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].id, 1);
}
// =============================================================================
// Get Next in Queue Tests
// =============================================================================
#[test]
fn test_get_next_returns_highest_priority() {
let queue = vec![
QueueEntry::new(1, "low", Priority::Low, 100),
QueueEntry::new(2, "high", Priority::High, 200),
QueueEntry::new(3, "normal", Priority::Normal, 150),
];
let next = get_next_in_queue(&queue).unwrap();
assert_eq!(next.id, 2); // High priority
}
#[test]
fn test_get_next_respects_time_within_priority() {
let queue = vec![
QueueEntry::new(1, "first", Priority::Normal, 1000),
QueueEntry::new(2, "second", Priority::Normal, 1100),
QueueEntry::new(3, "third", Priority::Normal, 1200),
];
let next = get_next_in_queue(&queue).unwrap();
assert_eq!(next.id, 1); // First to enter
}
#[test]
fn test_get_next_empty_queue() {
let queue: Vec<QueueEntry> = vec![];
assert!(get_next_in_queue(&queue).is_none());
}
#[test]
fn test_get_next_single_entry() {
let queue = vec![QueueEntry::new(42, "only_customer", Priority::Normal, 1000)];
let next = get_next_in_queue(&queue).unwrap();
assert_eq!(next.id, 42);
}
// =============================================================================
// Real-world Scenario Tests
// =============================================================================
#[test]
fn test_scenario_customer_support_queue() {
// Simulate a real customer support queue scenario
let mut queue = vec![
// Regular customers entering over time
QueueEntry::new(1, "alice", Priority::Normal, 1000),
QueueEntry::new(2, "bob", Priority::Normal, 1100),
QueueEntry::new(3, "charlie", Priority::Normal, 1200),
// VIP customer enters later
QueueEntry::new(4, "vip_dave", Priority::High, 1300),
// Urgent issue reported
QueueEntry::new(5, "urgent_eve", Priority::Urgent, 1400),
];
sort_queue(&mut queue);
// Service order should be: urgent_eve, vip_dave, alice, bob, charlie
assert_eq!(queue[0].customer_id, "urgent_eve");
assert_eq!(queue[1].customer_id, "vip_dave");
assert_eq!(queue[2].customer_id, "alice");
assert_eq!(queue[3].customer_id, "bob");
assert_eq!(queue[4].customer_id, "charlie");
}
#[test]
fn test_scenario_multiple_urgent_fifo() {
// Multiple urgent requests should still be FIFO within that priority
let mut queue = vec![
QueueEntry::new(1, "urgent1", Priority::Urgent, 1000),
QueueEntry::new(2, "urgent2", Priority::Urgent, 1100),
QueueEntry::new(3, "urgent3", Priority::Urgent, 1050),
];
sort_queue(&mut queue);
// Should be ordered by entry time within urgent priority
assert_eq!(queue[0].customer_id, "urgent1"); // 1000
assert_eq!(queue[1].customer_id, "urgent3"); // 1050
assert_eq!(queue[2].customer_id, "urgent2"); // 1100
}
#[test]
fn test_scenario_priority_upgrade() {
// Simulate upgrading a customer's priority
let mut entry = QueueEntry::new(1, "customer", Priority::Normal, 1000);
// Verify initial priority
assert_eq!(entry.priority, Priority::Normal);
// Upgrade priority (customer complained, escalation, etc.)
entry.priority = Priority::High;
assert_eq!(entry.priority, Priority::High);
}
#[test]
fn test_queue_position_calculation() {
let queue = vec![
QueueEntry::new(1, "first", Priority::Normal, 1000),
QueueEntry::new(2, "second", Priority::Normal, 1100),
QueueEntry::new(3, "third", Priority::Normal, 1200),
QueueEntry::new(4, "fourth", Priority::Normal, 1300),
];
// Find position of customer with id=3
let position = queue.iter().position(|e| e.id == 3).map(|p| p + 1); // 1-based position
assert_eq!(position, Some(3));
}
#[test]
fn test_estimated_wait_time() {
let avg_service_time_minutes = 5;
let _queue = vec![
QueueEntry::new(1, "first", Priority::Normal, 1000),
QueueEntry::new(2, "second", Priority::Normal, 1100),
QueueEntry::new(3, "third", Priority::Normal, 1200),
];
// Customer at position 3 has 2 people ahead
let position = 3;
let people_ahead = position - 1;
let estimated_wait = people_ahead * avg_service_time_minutes;
assert_eq!(estimated_wait, 10); // 2 people * 5 minutes each
}
// =============================================================================
// Edge Case Tests
// =============================================================================
#[test]
fn test_large_queue() {
let mut queue: Vec<QueueEntry> = (0..1000)
.map(|i| {
let priority = match i % 4 {
0 => Priority::Low,
1 => Priority::Normal,
2 => Priority::High,
_ => Priority::Urgent,
};
QueueEntry::new(i, &format!("customer_{}", i), priority, 1000 + i)
})
.collect();
sort_queue(&mut queue);
// First entry should be urgent (i % 4 == 3, first one is i=3)
assert_eq!(queue[0].priority, Priority::Urgent);
// Last entry should be low priority
assert_eq!(queue[999].priority, Priority::Low);
}
#[test]
fn test_all_same_priority_and_time() {
let queue = vec![
QueueEntry::new(1, "a", Priority::Normal, 1000),
QueueEntry::new(2, "b", Priority::Normal, 1000),
QueueEntry::new(3, "c", Priority::Normal, 1000),
];
// All equal, any is valid as "next"
let next = get_next_in_queue(&queue);
assert!(next.is_some());
}

View file

@ -1,537 +0,0 @@
//! Unit tests for BASIC math functions from botserver
//!
//! These tests create a Rhai engine, register math functions the same way
//! botserver does, and verify they work correctly.
use rhai::Engine;
// =============================================================================
// ABS Function Tests
// =============================================================================
#[test]
fn test_abs_positive() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
engine.register_fn("ABS", |n: f64| -> f64 { n.abs() });
let result: i64 = engine.eval("ABS(42)").unwrap();
assert_eq!(result, 42);
}
#[test]
fn test_abs_negative() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
let result: i64 = engine.eval("ABS(-42)").unwrap();
assert_eq!(result, 42);
}
#[test]
fn test_abs_zero() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
let result: i64 = engine.eval("ABS(0)").unwrap();
assert_eq!(result, 0);
}
#[test]
fn test_abs_float() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: f64| -> f64 { n.abs() });
let result: f64 = engine.eval("ABS(-3.14)").unwrap();
assert!((result - 3.14).abs() < f64::EPSILON);
}
// =============================================================================
// ROUND Function Tests
// =============================================================================
#[test]
fn test_round_up() {
let mut engine = Engine::new();
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
let result: i64 = engine.eval("ROUND(3.7)").unwrap();
assert_eq!(result, 4);
}
#[test]
fn test_round_down() {
let mut engine = Engine::new();
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
let result: i64 = engine.eval("ROUND(3.2)").unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_round_half() {
let mut engine = Engine::new();
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
let result: i64 = engine.eval("ROUND(3.5)").unwrap();
assert_eq!(result, 4);
}
#[test]
fn test_round_negative() {
let mut engine = Engine::new();
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
let result: i64 = engine.eval("ROUND(-3.7)").unwrap();
assert_eq!(result, -4);
}
// =============================================================================
// INT / FIX Function Tests (Truncation)
// =============================================================================
#[test]
fn test_int_positive() {
let mut engine = Engine::new();
engine.register_fn("INT", |n: f64| -> i64 { n.trunc() as i64 });
let result: i64 = engine.eval("INT(3.9)").unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_int_negative() {
let mut engine = Engine::new();
engine.register_fn("INT", |n: f64| -> i64 { n.trunc() as i64 });
let result: i64 = engine.eval("INT(-3.9)").unwrap();
assert_eq!(result, -3);
}
#[test]
fn test_fix_alias() {
let mut engine = Engine::new();
engine.register_fn("FIX", |n: f64| -> i64 { n.trunc() as i64 });
let result: i64 = engine.eval("FIX(7.8)").unwrap();
assert_eq!(result, 7);
}
// =============================================================================
// FLOOR / CEIL Function Tests
// =============================================================================
#[test]
fn test_floor_positive() {
let mut engine = Engine::new();
engine.register_fn("FLOOR", |n: f64| -> i64 { n.floor() as i64 });
let result: i64 = engine.eval("FLOOR(3.9)").unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_floor_negative() {
let mut engine = Engine::new();
engine.register_fn("FLOOR", |n: f64| -> i64 { n.floor() as i64 });
let result: i64 = engine.eval("FLOOR(-3.1)").unwrap();
assert_eq!(result, -4);
}
#[test]
fn test_ceil_positive() {
let mut engine = Engine::new();
engine.register_fn("CEIL", |n: f64| -> i64 { n.ceil() as i64 });
let result: i64 = engine.eval("CEIL(3.1)").unwrap();
assert_eq!(result, 4);
}
#[test]
fn test_ceil_negative() {
let mut engine = Engine::new();
engine.register_fn("CEIL", |n: f64| -> i64 { n.ceil() as i64 });
let result: i64 = engine.eval("CEIL(-3.9)").unwrap();
assert_eq!(result, -3);
}
// =============================================================================
// MIN / MAX Function Tests
// =============================================================================
#[test]
fn test_max_basic() {
let mut engine = Engine::new();
engine.register_fn("MAX", |a: i64, b: i64| -> i64 { a.max(b) });
let result: i64 = engine.eval("MAX(5, 10)").unwrap();
assert_eq!(result, 10);
}
#[test]
fn test_max_first_larger() {
let mut engine = Engine::new();
engine.register_fn("MAX", |a: i64, b: i64| -> i64 { a.max(b) });
let result: i64 = engine.eval("MAX(10, 5)").unwrap();
assert_eq!(result, 10);
}
#[test]
fn test_max_equal() {
let mut engine = Engine::new();
engine.register_fn("MAX", |a: i64, b: i64| -> i64 { a.max(b) });
let result: i64 = engine.eval("MAX(7, 7)").unwrap();
assert_eq!(result, 7);
}
#[test]
fn test_max_negative() {
let mut engine = Engine::new();
engine.register_fn("MAX", |a: i64, b: i64| -> i64 { a.max(b) });
let result: i64 = engine.eval("MAX(-5, -10)").unwrap();
assert_eq!(result, -5);
}
#[test]
fn test_min_basic() {
let mut engine = Engine::new();
engine.register_fn("MIN", |a: i64, b: i64| -> i64 { a.min(b) });
let result: i64 = engine.eval("MIN(5, 10)").unwrap();
assert_eq!(result, 5);
}
#[test]
fn test_min_first_smaller() {
let mut engine = Engine::new();
engine.register_fn("MIN", |a: i64, b: i64| -> i64 { a.min(b) });
let result: i64 = engine.eval("MIN(3, 8)").unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_min_negative() {
let mut engine = Engine::new();
engine.register_fn("MIN", |a: i64, b: i64| -> i64 { a.min(b) });
let result: i64 = engine.eval("MIN(-5, -10)").unwrap();
assert_eq!(result, -10);
}
// =============================================================================
// MOD Function Tests
// =============================================================================
#[test]
fn test_mod_basic() {
let mut engine = Engine::new();
engine.register_fn("MOD", |a: i64, b: i64| -> i64 { a % b });
let result: i64 = engine.eval("MOD(17, 5)").unwrap();
assert_eq!(result, 2);
}
#[test]
fn test_mod_no_remainder() {
let mut engine = Engine::new();
engine.register_fn("MOD", |a: i64, b: i64| -> i64 { a % b });
let result: i64 = engine.eval("MOD(10, 5)").unwrap();
assert_eq!(result, 0);
}
#[test]
fn test_mod_smaller_dividend() {
let mut engine = Engine::new();
engine.register_fn("MOD", |a: i64, b: i64| -> i64 { a % b });
let result: i64 = engine.eval("MOD(3, 10)").unwrap();
assert_eq!(result, 3);
}
// =============================================================================
// SGN Function Tests
// =============================================================================
#[test]
fn test_sgn_positive() {
let mut engine = Engine::new();
engine.register_fn("SGN", |n: i64| -> i64 { n.signum() });
let result: i64 = engine.eval("SGN(42)").unwrap();
assert_eq!(result, 1);
}
#[test]
fn test_sgn_negative() {
let mut engine = Engine::new();
engine.register_fn("SGN", |n: i64| -> i64 { n.signum() });
let result: i64 = engine.eval("SGN(-42)").unwrap();
assert_eq!(result, -1);
}
#[test]
fn test_sgn_zero() {
let mut engine = Engine::new();
engine.register_fn("SGN", |n: i64| -> i64 { n.signum() });
let result: i64 = engine.eval("SGN(0)").unwrap();
assert_eq!(result, 0);
}
// =============================================================================
// SQRT / SQR Function Tests
// =============================================================================
#[test]
fn test_sqrt_perfect_square() {
let mut engine = Engine::new();
engine.register_fn("SQRT", |n: f64| -> f64 { n.sqrt() });
let result: f64 = engine.eval("SQRT(16.0)").unwrap();
assert!((result - 4.0).abs() < f64::EPSILON);
}
#[test]
fn test_sqrt_non_perfect() {
let mut engine = Engine::new();
engine.register_fn("SQRT", |n: f64| -> f64 { n.sqrt() });
let result: f64 = engine.eval("SQRT(2.0)").unwrap();
assert!((result - std::f64::consts::SQRT_2).abs() < 0.00001);
}
#[test]
fn test_sqr_alias() {
let mut engine = Engine::new();
engine.register_fn("SQR", |n: f64| -> f64 { n.sqrt() });
let result: f64 = engine.eval("SQR(25.0)").unwrap();
assert!((result - 5.0).abs() < f64::EPSILON);
}
// =============================================================================
// POW Function Tests
// =============================================================================
#[test]
fn test_pow_basic() {
let mut engine = Engine::new();
engine.register_fn("POW", |base: f64, exp: f64| -> f64 { base.powf(exp) });
let result: f64 = engine.eval("POW(2.0, 10.0)").unwrap();
assert!((result - 1024.0).abs() < f64::EPSILON);
}
#[test]
fn test_pow_zero_exponent() {
let mut engine = Engine::new();
engine.register_fn("POW", |base: f64, exp: f64| -> f64 { base.powf(exp) });
let result: f64 = engine.eval("POW(5.0, 0.0)").unwrap();
assert!((result - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_pow_square_root() {
let mut engine = Engine::new();
engine.register_fn("POW", |base: f64, exp: f64| -> f64 { base.powf(exp) });
let result: f64 = engine.eval("POW(9.0, 0.5)").unwrap();
assert!((result - 3.0).abs() < 0.00001);
}
// =============================================================================
// LOG / LOG10 / EXP Function Tests
// =============================================================================
#[test]
fn test_log_e() {
let mut engine = Engine::new();
engine.register_fn("LOG", |n: f64| -> f64 { n.ln() });
let e = std::f64::consts::E;
let result: f64 = engine.eval(&format!("LOG({})", e)).unwrap();
assert!((result - 1.0).abs() < 0.00001);
}
#[test]
fn test_log10_hundred() {
let mut engine = Engine::new();
engine.register_fn("LOG10", |n: f64| -> f64 { n.log10() });
let result: f64 = engine.eval("LOG10(100.0)").unwrap();
assert!((result - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_exp_zero() {
let mut engine = Engine::new();
engine.register_fn("EXP", |n: f64| -> f64 { n.exp() });
let result: f64 = engine.eval("EXP(0.0)").unwrap();
assert!((result - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_exp_one() {
let mut engine = Engine::new();
engine.register_fn("EXP", |n: f64| -> f64 { n.exp() });
let result: f64 = engine.eval("EXP(1.0)").unwrap();
assert!((result - std::f64::consts::E).abs() < 0.00001);
}
// =============================================================================
// Trigonometric Function Tests
// =============================================================================
#[test]
fn test_sin_zero() {
let mut engine = Engine::new();
engine.register_fn("SIN", |n: f64| -> f64 { n.sin() });
let result: f64 = engine.eval("SIN(0.0)").unwrap();
assert!((result - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_cos_zero() {
let mut engine = Engine::new();
engine.register_fn("COS", |n: f64| -> f64 { n.cos() });
let result: f64 = engine.eval("COS(0.0)").unwrap();
assert!((result - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_tan_zero() {
let mut engine = Engine::new();
engine.register_fn("TAN", |n: f64| -> f64 { n.tan() });
let result: f64 = engine.eval("TAN(0.0)").unwrap();
assert!((result - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_pi_constant() {
let mut engine = Engine::new();
engine.register_fn("PI", || -> f64 { std::f64::consts::PI });
let result: f64 = engine.eval("PI()").unwrap();
assert!((result - std::f64::consts::PI).abs() < f64::EPSILON);
}
// =============================================================================
// VAL Function Tests (String to Number)
// =============================================================================
#[test]
fn test_val_integer() {
let mut engine = Engine::new();
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
let result: f64 = engine.eval(r#"VAL("42")"#).unwrap();
assert!((result - 42.0).abs() < f64::EPSILON);
}
#[test]
fn test_val_decimal() {
let mut engine = Engine::new();
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
let result: f64 = engine.eval(r#"VAL("3.14")"#).unwrap();
assert!((result - 3.14).abs() < f64::EPSILON);
}
#[test]
fn test_val_negative() {
let mut engine = Engine::new();
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
let result: f64 = engine.eval(r#"VAL("-17")"#).unwrap();
assert!((result - (-17.0)).abs() < f64::EPSILON);
}
#[test]
fn test_val_invalid_returns_zero() {
let mut engine = Engine::new();
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
let result: f64 = engine.eval(r#"VAL("abc")"#).unwrap();
assert!((result - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_val_with_whitespace() {
let mut engine = Engine::new();
engine.register_fn("VAL", |s: &str| -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
});
let result: f64 = engine.eval(r#"VAL(" 42 ")"#).unwrap();
assert!((result - 42.0).abs() < f64::EPSILON);
}
// =============================================================================
// Combined Math Expression Tests
// =============================================================================
#[test]
fn test_combined_abs_sqrt() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: f64| -> f64 { n.abs() });
engine.register_fn("SQRT", |n: f64| -> f64 { n.sqrt() });
// SQRT(ABS(-16)) should be 4
let result: f64 = engine.eval("SQRT(ABS(-16.0))").unwrap();
assert!((result - 4.0).abs() < f64::EPSILON);
}
#[test]
fn test_combined_round_after_division() {
let mut engine = Engine::new();
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
// ROUND(10.0 / 3.0) should be 3
let result: i64 = engine.eval("ROUND(10.0 / 3.0)").unwrap();
assert_eq!(result, 3);
}
#[test]
fn test_combined_max_of_abs() {
let mut engine = Engine::new();
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
engine.register_fn("MAX", |a: i64, b: i64| -> i64 { a.max(b) });
// MAX(ABS(-5), ABS(-10)) should be 10
let result: i64 = engine.eval("MAX(ABS(-5), ABS(-10))").unwrap();
assert_eq!(result, 10);
}
#[test]
fn test_arithmetic_expression() {
let engine = Engine::new();
// Test standard arithmetic without custom functions
let result: i64 = engine.eval("2 + 3 * 4").unwrap();
assert_eq!(result, 14); // Verify operator precedence
let result: i64 = engine.eval("(2 + 3) * 4").unwrap();
assert_eq!(result, 20);
}

View file

@ -1,16 +1,5 @@
//! Unit Tests for BotServer
//!
//! These tests verify BASIC language functions (string, math, etc.)
//! and core logic like attendance queue handling.
//! No external services required (PostgreSQL, Redis, MinIO).
mod attendance;
mod math_functions;
mod string_functions;
/// Verify the test module loads correctly
#[test]
fn test_unit_module_loads() {
// If this compiles and runs, the test infrastructure is working
assert!(true);
let module_name = module_path!();
assert!(module_name.contains("unit"));
}

View file

@ -1,418 +0,0 @@
//! Unit tests for BASIC string functions from botserver
//!
//! These tests create a Rhai engine, register the same string functions
//! that botserver uses, and verify they work correctly.
//!
//! Note: We test the function logic directly without requiring botserver's
//! full infrastructure (AppState, database, etc.).
use rhai::Engine;
// =============================================================================
// INSTR Function Tests - Testing the actual behavior
// =============================================================================
#[test]
fn test_instr_finds_substring() {
let mut engine = Engine::new();
// Register INSTR the same way botserver does
engine.register_fn("INSTR", |haystack: &str, needle: &str| -> i64 {
if haystack.is_empty() || needle.is_empty() {
return 0;
}
match haystack.find(needle) {
Some(pos) => (pos + 1) as i64, // 1-based index
None => 0,
}
});
let result: i64 = engine.eval(r#"INSTR("Hello World", "World")"#).unwrap();
assert_eq!(result, 7);
}
#[test]
fn test_instr_not_found() {
let mut engine = Engine::new();
engine.register_fn("INSTR", |haystack: &str, needle: &str| -> i64 {
if haystack.is_empty() || needle.is_empty() {
return 0;
}
match haystack.find(needle) {
Some(pos) => (pos + 1) as i64,
None => 0,
}
});
let result: i64 = engine.eval(r#"INSTR("Hello World", "xyz")"#).unwrap();
assert_eq!(result, 0);
}
#[test]
fn test_instr_case_sensitive() {
let mut engine = Engine::new();
engine.register_fn("INSTR", |haystack: &str, needle: &str| -> i64 {
if haystack.is_empty() || needle.is_empty() {
return 0;
}
match haystack.find(needle) {
Some(pos) => (pos + 1) as i64,
None => 0,
}
});
let result: i64 = engine.eval(r#"INSTR("Hello", "hello")"#).unwrap();
assert_eq!(result, 0); // Case sensitive, so not found
}
// =============================================================================
// UPPER / UCASE Function Tests
// =============================================================================
#[test]
fn test_upper_basic() {
let mut engine = Engine::new();
engine.register_fn("UPPER", |s: &str| -> String { s.to_uppercase() });
let result: String = engine.eval(r#"UPPER("hello")"#).unwrap();
assert_eq!(result, "HELLO");
}
#[test]
fn test_upper_mixed_case() {
let mut engine = Engine::new();
engine.register_fn("UPPER", |s: &str| -> String { s.to_uppercase() });
let result: String = engine.eval(r#"UPPER("HeLLo WoRLd")"#).unwrap();
assert_eq!(result, "HELLO WORLD");
}
#[test]
fn test_ucase_alias() {
let mut engine = Engine::new();
engine.register_fn("UCASE", |s: &str| -> String { s.to_uppercase() });
let result: String = engine.eval(r#"UCASE("test")"#).unwrap();
assert_eq!(result, "TEST");
}
// =============================================================================
// LOWER / LCASE Function Tests
// =============================================================================
#[test]
fn test_lower_basic() {
let mut engine = Engine::new();
engine.register_fn("LOWER", |s: &str| -> String { s.to_lowercase() });
let result: String = engine.eval(r#"LOWER("HELLO")"#).unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_lcase_alias() {
let mut engine = Engine::new();
engine.register_fn("LCASE", |s: &str| -> String { s.to_lowercase() });
let result: String = engine.eval(r#"LCASE("TEST")"#).unwrap();
assert_eq!(result, "test");
}
// =============================================================================
// LEN Function Tests
// =============================================================================
#[test]
fn test_len_basic() {
let mut engine = Engine::new();
engine.register_fn("LEN", |s: &str| -> i64 { s.len() as i64 });
let result: i64 = engine.eval(r#"LEN("Hello")"#).unwrap();
assert_eq!(result, 5);
}
#[test]
fn test_len_empty() {
let mut engine = Engine::new();
engine.register_fn("LEN", |s: &str| -> i64 { s.len() as i64 });
let result: i64 = engine.eval(r#"LEN("")"#).unwrap();
assert_eq!(result, 0);
}
#[test]
fn test_len_with_spaces() {
let mut engine = Engine::new();
engine.register_fn("LEN", |s: &str| -> i64 { s.len() as i64 });
let result: i64 = engine.eval(r#"LEN("Hello World")"#).unwrap();
assert_eq!(result, 11);
}
// =============================================================================
// TRIM / LTRIM / RTRIM Function Tests
// =============================================================================
#[test]
fn test_trim_both_sides() {
let mut engine = Engine::new();
engine.register_fn("TRIM", |s: &str| -> String { s.trim().to_string() });
let result: String = engine.eval(r#"TRIM(" hello ")"#).unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_ltrim() {
let mut engine = Engine::new();
engine.register_fn("LTRIM", |s: &str| -> String { s.trim_start().to_string() });
let result: String = engine.eval(r#"LTRIM(" hello ")"#).unwrap();
assert_eq!(result, "hello ");
}
#[test]
fn test_rtrim() {
let mut engine = Engine::new();
engine.register_fn("RTRIM", |s: &str| -> String { s.trim_end().to_string() });
let result: String = engine.eval(r#"RTRIM(" hello ")"#).unwrap();
assert_eq!(result, " hello");
}
// =============================================================================
// LEFT Function Tests
// =============================================================================
#[test]
fn test_left_basic() {
let mut engine = Engine::new();
engine.register_fn("LEFT", |s: &str, count: i64| -> String {
let count = count.max(0) as usize;
s.chars().take(count).collect()
});
let result: String = engine.eval(r#"LEFT("Hello World", 5)"#).unwrap();
assert_eq!(result, "Hello");
}
#[test]
fn test_left_exceeds_length() {
let mut engine = Engine::new();
engine.register_fn("LEFT", |s: &str, count: i64| -> String {
let count = count.max(0) as usize;
s.chars().take(count).collect()
});
let result: String = engine.eval(r#"LEFT("Hi", 10)"#).unwrap();
assert_eq!(result, "Hi");
}
#[test]
fn test_left_zero() {
let mut engine = Engine::new();
engine.register_fn("LEFT", |s: &str, count: i64| -> String {
let count = count.max(0) as usize;
s.chars().take(count).collect()
});
let result: String = engine.eval(r#"LEFT("Hello", 0)"#).unwrap();
assert_eq!(result, "");
}
// =============================================================================
// RIGHT Function Tests
// =============================================================================
#[test]
fn test_right_basic() {
let mut engine = Engine::new();
engine.register_fn("RIGHT", |s: &str, count: i64| -> String {
let count = count.max(0) as usize;
let len = s.chars().count();
if count >= len {
s.to_string()
} else {
s.chars().skip(len - count).collect()
}
});
let result: String = engine.eval(r#"RIGHT("Hello World", 5)"#).unwrap();
assert_eq!(result, "World");
}
#[test]
fn test_right_exceeds_length() {
let mut engine = Engine::new();
engine.register_fn("RIGHT", |s: &str, count: i64| -> String {
let count = count.max(0) as usize;
let len = s.chars().count();
if count >= len {
s.to_string()
} else {
s.chars().skip(len - count).collect()
}
});
let result: String = engine.eval(r#"RIGHT("Hi", 10)"#).unwrap();
assert_eq!(result, "Hi");
}
// =============================================================================
// MID Function Tests
// =============================================================================
#[test]
fn test_mid_with_length() {
let mut engine = Engine::new();
engine.register_fn("MID", |s: &str, start: i64, length: i64| -> String {
let start_idx = if start < 1 { 0 } else { (start - 1) as usize };
let len = length.max(0) as usize;
s.chars().skip(start_idx).take(len).collect()
});
let result: String = engine.eval(r#"MID("Hello World", 7, 5)"#).unwrap();
assert_eq!(result, "World");
}
#[test]
fn test_mid_one_based_index() {
let mut engine = Engine::new();
engine.register_fn("MID", |s: &str, start: i64, length: i64| -> String {
let start_idx = if start < 1 { 0 } else { (start - 1) as usize };
let len = length.max(0) as usize;
s.chars().skip(start_idx).take(len).collect()
});
// BASIC uses 1-based indexing, so MID("ABCDE", 1, 1) = "A"
let result: String = engine.eval(r#"MID("ABCDE", 1, 1)"#).unwrap();
assert_eq!(result, "A");
let result: String = engine.eval(r#"MID("ABCDE", 3, 1)"#).unwrap();
assert_eq!(result, "C");
}
// =============================================================================
// REPLACE Function Tests
// =============================================================================
#[test]
fn test_replace_basic() {
let mut engine = Engine::new();
engine.register_fn("REPLACE", |s: &str, find: &str, replace: &str| -> String {
s.replace(find, replace)
});
let result: String = engine
.eval(r#"REPLACE("Hello World", "World", "Rust")"#)
.unwrap();
assert_eq!(result, "Hello Rust");
}
#[test]
fn test_replace_multiple() {
let mut engine = Engine::new();
engine.register_fn("REPLACE", |s: &str, find: &str, replace: &str| -> String {
s.replace(find, replace)
});
let result: String = engine.eval(r#"REPLACE("aaa", "a", "b")"#).unwrap();
assert_eq!(result, "bbb");
}
#[test]
fn test_replace_not_found() {
let mut engine = Engine::new();
engine.register_fn("REPLACE", |s: &str, find: &str, replace: &str| -> String {
s.replace(find, replace)
});
let result: String = engine.eval(r#"REPLACE("Hello", "xyz", "abc")"#).unwrap();
assert_eq!(result, "Hello");
}
// =============================================================================
// IS_NUMERIC Function Tests
// =============================================================================
#[test]
fn test_is_numeric_integer() {
let mut engine = Engine::new();
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
trimmed.parse::<i64>().is_ok() || trimmed.parse::<f64>().is_ok()
});
let result: bool = engine.eval(r#"IS_NUMERIC("42")"#).unwrap();
assert!(result);
}
#[test]
fn test_is_numeric_decimal() {
let mut engine = Engine::new();
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
trimmed.parse::<i64>().is_ok() || trimmed.parse::<f64>().is_ok()
});
let result: bool = engine.eval(r#"IS_NUMERIC("3.14")"#).unwrap();
assert!(result);
}
#[test]
fn test_is_numeric_invalid() {
let mut engine = Engine::new();
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
trimmed.parse::<i64>().is_ok() || trimmed.parse::<f64>().is_ok()
});
let result: bool = engine.eval(r#"IS_NUMERIC("abc")"#).unwrap();
assert!(!result);
}
#[test]
fn test_is_numeric_empty() {
let mut engine = Engine::new();
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
trimmed.parse::<i64>().is_ok() || trimmed.parse::<f64>().is_ok()
});
let result: bool = engine.eval(r#"IS_NUMERIC("")"#).unwrap();
assert!(!result);
}
// =============================================================================
// Combined Expression Tests
// =============================================================================
#[test]
fn test_combined_string_operations() {
let mut engine = Engine::new();
engine.register_fn("UPPER", |s: &str| -> String { s.to_uppercase() });
engine.register_fn("TRIM", |s: &str| -> String { s.trim().to_string() });
engine.register_fn("LEN", |s: &str| -> i64 { s.len() as i64 });
// UPPER(TRIM(" hello ")) should be "HELLO"
let result: String = engine.eval(r#"UPPER(TRIM(" hello "))"#).unwrap();
assert_eq!(result, "HELLO");
// LEN(TRIM(" hi ")) should be 2
let result: i64 = engine.eval(r#"LEN(TRIM(" hi "))"#).unwrap();
assert_eq!(result, 2);
}