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" description = "Comprehensive test suite for General Bots - Unit, Integration, and E2E testing"
license = "AGPL-3.0" license = "AGPL-3.0"
repository = "https://github.com/GeneralBots/BotServer" repository = "https://github.com/GeneralBots/BotServer"
readme = "README.md"
keywords = ["testing", "bot", "integration-testing", "e2e", "general-bots"]
categories = ["development-tools::testing"]
[dependencies] [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 # Async runtime
tokio = { version = "1.41", features = ["full", "test-util", "macros"] } tokio = { workspace = true, features = ["full", "test-util", "macros"] }
async-trait = "0.1" async-trait = { workspace = true }
futures = "0.3" futures = { workspace = true }
futures-util = "0.3"
# Database # Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } diesel = { workspace = true, features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel_migrations = "2.1.0"
# HTTP mocking and testing # HTTP mocking and testing
wiremock = "0.6" wiremock = { workspace = true }
cookie = "0.18" cookie = { workspace = true }
mockito = "1.7" reqwest = { workspace = true, features = ["json", "cookies", "blocking", "rustls-tls"] }
reqwest = { version = "0.12", features = ["json", "cookies", "blocking"] }
# Web/E2E testing - using Chrome DevTools Protocol directly (no chromedriver) # Web/E2E testing - using Chrome DevTools Protocol directly (no chromedriver)
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false } chromiumoxide = { workspace = true }
# 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"] }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = "1.0" serde_json = { workspace = true }
# Utilities # Utilities
uuid = { version = "1.11", features = ["serde", "v4"] } uuid = { workspace = true, features = ["serde", "v4"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
rand = "0.9" which = { workspace = true }
tempfile = "3" regex = { workspace = true }
which = "7"
regex = "1.11"
base64 = "0.22"
url = "2.5"
dirs = "5.0"
# Process management for services # Process management for services
nix = { version = "0.29", features = ["signal", "process"] } nix = { workspace = true }
# Archive extraction # Archive extraction
zip = "2.2" zip = { workspace = true }
# Logging and tracing # Logging and tracing
log = "0.4" log = { workspace = true }
env_logger = "0.11" env_logger = { workspace = true }
tracing = "0.1" tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] }
# Error handling # Error handling
anyhow = "1.0" anyhow = { workspace = true }
thiserror = "2.0"
# Test framework enhancements # Test framework enhancements
pretty_assertions = "1.4"
# Async testing
tokio-test = "0.4"
# Rhai for BASIC function testing # Rhai for BASIC function testing
rhai = { git = "https://github.com/therealprof/rhai.git", branch = "features/use-web-time", features = ["sync"] } rhai = { workspace = true, features = ["sync"] }
# Configuration
dotenvy = "0.15"
[dev-dependencies] [dev-dependencies]
insta = { version = "1.40", features = ["json", "yaml"] } insta = { workspace = true }
[features] [features]
default = ["full"] default = ["full"]
@ -105,3 +81,6 @@ required-features = ["e2e"]
[[bin]] [[bin]]
name = "bottest" name = "bottest"
path = "src/main.rs" 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 self
} }
pub fn on_channel(mut self, channel: Channel) -> Self { pub const fn on_channel(mut self, channel: Channel) -> Self {
self.channel = channel; self.channel = channel;
self self
} }
@ -48,7 +48,7 @@ impl ConversationBuilder {
self 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.config.response_timeout = timeout;
self self
} }
@ -58,12 +58,12 @@ impl ConversationBuilder {
self self
} }
pub fn without_recording(mut self) -> Self { pub const fn without_recording(mut self) -> Self {
self.config.record = false; self.config.record = false;
self 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.config.use_mock_llm = false;
self self
} }
@ -131,13 +131,13 @@ impl ConversationTest {
ConversationBuilder::new(bot_name).build() 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(); let mut conv = ConversationBuilder::new(bot_name).build();
conv.llm_url = Some(ctx.llm_url()); conv.llm_url = Some(ctx.llm_url());
Ok(conv) Ok(conv)
} }
pub fn id(&self) -> Uuid { pub const fn id(&self) -> Uuid {
self.id self.id
} }
@ -145,15 +145,15 @@ impl ConversationTest {
&self.bot_name &self.bot_name
} }
pub fn customer(&self) -> &Customer { pub const fn customer(&self) -> &Customer {
&self.customer &self.customer
} }
pub fn channel(&self) -> Channel { pub const fn channel(&self) -> Channel {
self.channel self.channel
} }
pub fn state(&self) -> ConversationState { pub const fn state(&self) -> ConversationState {
self.state self.state
} }
@ -165,15 +165,15 @@ impl ConversationTest {
&self.sent_messages &self.sent_messages
} }
pub fn last_response(&self) -> Option<&BotResponse> { pub const fn last_response(&self) -> Option<&BotResponse> {
self.last_response.as_ref() self.last_response.as_ref()
} }
pub fn last_latency(&self) -> Option<Duration> { pub const fn last_latency(&self) -> Option<Duration> {
self.last_latency self.last_latency
} }
pub fn record(&self) -> &ConversationRecord { pub const fn record(&self) -> &ConversationRecord {
&self.record &self.record
} }
@ -203,7 +203,7 @@ impl ConversationTest {
self.record.messages.push(RecordedMessage { self.record.messages.push(RecordedMessage {
timestamp: Utc::now(), timestamp: Utc::now(),
direction: MessageDirection::Outgoing, direction: MessageDirection::Outgoing,
content: response.content.clone(), content: response.content,
latency_ms: Some(latency.as_millis() as u64), latency_ms: Some(latency.as_millis() as u64),
}); });
} }
@ -242,7 +242,7 @@ impl ConversationTest {
BotResponse { BotResponse {
id: Uuid::new_v4(), id: Uuid::new_v4(),
content: format!("Response to: {}", user_message), content: format!("Response to: {user_message}"),
content_type: ResponseContentType::Text, content_type: ResponseContentType::Text,
metadata: self.build_response_metadata(), metadata: self.build_response_metadata(),
latency_ms: start.elapsed().as_millis() as u64, latency_ms: start.elapsed().as_millis() as u64,
@ -269,7 +269,7 @@ impl ConversationTest {
}); });
let response = client let response = client
.post(format!("{}/v1/chat/completions", llm_url)) .post(format!("{llm_url}/v1/chat/completions"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(&request_body) .json(&request_body)
.send() .send()
@ -306,13 +306,13 @@ impl ConversationTest {
metadata 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 { let result = if let Some(ref response) = self.last_response {
if response.content.contains(text) { if response.content.contains(text) {
AssertionResult::pass(&format!("Response contains '{}'", text)) AssertionResult::pass(&format!("Response contains '{text}'"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
&format!("Response should contain '{}'", text), &format!("Response should contain '{text}'"),
text, text,
&response.content, &response.content,
) )
@ -325,10 +325,10 @@ impl ConversationTest {
self 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 { let result = if let Some(ref response) = self.last_response {
if response.content == text { if response.content == text {
AssertionResult::pass(&format!("Response equals '{}'", text)) AssertionResult::pass(&format!("Response equals '{text}'"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"Response should equal expected text", "Response should equal expected text",
@ -344,22 +344,22 @@ impl ConversationTest {
self 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 { let result = if let Some(ref response) = self.last_response {
match regex::Regex::new(pattern) { match regex::Regex::new(pattern) {
Ok(re) => { Ok(re) => {
if re.is_match(&response.content) { if re.is_match(&response.content) {
AssertionResult::pass(&format!("Response matches pattern '{}'", pattern)) AssertionResult::pass(&format!("Response matches pattern '{pattern}'"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
&format!("Response should match pattern '{}'", pattern), &format!("Response should match pattern '{pattern}'"),
pattern, pattern,
&response.content, &response.content,
) )
} }
} }
Err(e) => AssertionResult::fail( Err(e) => AssertionResult::fail(
&format!("Invalid regex pattern: {}", e), &format!("Invalid regex pattern: {e}"),
pattern, pattern,
"<invalid pattern>", "<invalid pattern>",
), ),
@ -372,16 +372,16 @@ impl ConversationTest {
self 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 { let result = if let Some(ref response) = self.last_response {
if !response.content.contains(text) { if response.content.contains(text) {
AssertionResult::pass(&format!("Response does not contain '{}'", text))
} else {
AssertionResult::fail( AssertionResult::fail(
&format!("Response should not contain '{}'", text), &format!("Response should not contain '{text}'"),
&format!("not containing '{}'", text), &format!("not containing '{text}'"),
&response.content, &response.content,
) )
} else {
AssertionResult::pass(&format!("Response does not contain '{text}'"))
} }
} else { } else {
AssertionResult::pass("No response (nothing to contain)") AssertionResult::pass("No response (nothing to contain)")
@ -391,17 +391,13 @@ impl ConversationTest {
self 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 let is_transferred = self.state == ConversationState::Transferred
|| self || self.last_response.as_ref().is_some_and(|r| {
.last_response
.as_ref()
.map(|r| {
r.content.to_lowercase().contains("transfer") r.content.to_lowercase().contains("transfer")
|| r.content.to_lowercase().contains("human") || r.content.to_lowercase().contains("human")
|| r.content.to_lowercase().contains("agent") || r.content.to_lowercase().contains("agent")
}) });
.unwrap_or(false);
let result = if is_transferred { let result = if is_transferred {
self.state = ConversationState::Transferred; self.state = ConversationState::Transferred;
@ -418,15 +414,15 @@ impl ConversationTest {
self 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 let actual = self
.context .context
.get("queue_position") .get("queue_position")
.and_then(|v| v.as_u64()) .and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize; .unwrap_or(0) as usize;
let result = if actual == expected { let result = if actual == expected {
AssertionResult::pass(&format!("Queue position is {}", expected)) AssertionResult::pass(&format!("Queue position is {expected}"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"Queue position mismatch", "Queue position mismatch",
@ -439,21 +435,21 @@ impl ConversationTest {
self 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 { let result = if let Some(latency) = self.last_latency {
if latency <= max_duration { if latency <= max_duration {
AssertionResult::pass(&format!("Response within {:?}", max_duration)) AssertionResult::pass(&format!("Response within {max_duration:?}"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"Response too slow", "Response too slow",
&format!("{:?}", max_duration), &format!("{max_duration:?}"),
&format!("{:?}", latency), &format!("{latency:?}"),
) )
} }
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"No latency recorded", "No latency recorded",
&format!("{:?}", max_duration), &format!("{max_duration:?}"),
"<no latency>", "<no latency>",
) )
}; };
@ -462,11 +458,11 @@ impl ConversationTest {
self 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 actual = self.responses.len();
let result = if actual == expected { let result = if actual == expected {
AssertionResult::pass(&format!("Response count is {}", expected)) AssertionResult::pass(&format!("Response count is {expected}"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"Response count mismatch", "Response count mismatch",
@ -479,21 +475,21 @@ impl ConversationTest {
self 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 { let result = if let Some(ref response) = self.last_response {
if response.content_type == expected { if response.content_type == expected {
AssertionResult::pass(&format!("Response type is {:?}", expected)) AssertionResult::pass(&format!("Response type is {expected:?}"))
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"Response type mismatch", "Response type mismatch",
&format!("{:?}", expected), &format!("{expected:?}"),
&format!("{:?}", response.content_type), &format!("{:?}", response.content_type),
) )
} }
} else { } else {
AssertionResult::fail( AssertionResult::fail(
"No response to check", "No response to check",
&format!("{:?}", expected), &format!("{expected:?}"),
"<no response>", "<no response>",
) )
}; };
@ -511,13 +507,13 @@ impl ConversationTest {
self.context.get(key) self.context.get(key)
} }
pub async fn end(&mut self) -> &mut Self { pub fn end(&mut self) -> &mut Self {
self.state = ConversationState::Ended; self.state = ConversationState::Ended;
self.record.ended_at = Some(Utc::now()); self.record.ended_at = Some(Utc::now());
self self
} }
pub fn all_passed(&self) -> bool { pub const fn all_passed(&self) -> bool {
self.record.passed self.record.passed
} }
@ -584,7 +580,7 @@ mod tests {
async fn test_assert_response_contains() { async fn test_assert_response_contains() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; conv.user_says("test").await;
conv.assert_response_contains("Response").await; conv.assert_response_contains("Response");
assert!(conv.all_passed()); assert!(conv.all_passed());
} }
@ -593,7 +589,7 @@ mod tests {
async fn test_assert_response_not_contains() { async fn test_assert_response_not_contains() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; conv.user_says("test").await;
conv.assert_response_not_contains("nonexistent").await; conv.assert_response_not_contains("nonexistent");
assert!(conv.all_passed()); assert!(conv.all_passed());
} }
@ -633,7 +629,7 @@ mod tests {
async fn test_end_conversation() { async fn test_end_conversation() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("bye").await; conv.user_says("bye").await;
conv.end().await; conv.end();
assert_eq!(conv.state(), ConversationState::Ended); assert_eq!(conv.state(), ConversationState::Ended);
assert!(conv.record().ended_at.is_some()); assert!(conv.record().ended_at.is_some());
@ -643,7 +639,7 @@ mod tests {
async fn test_failed_assertions() { async fn test_failed_assertions() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("test").await; 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!(!conv.all_passed());
assert_eq!(conv.failed_assertions().len(), 1); assert_eq!(conv.failed_assertions().len(), 1);
@ -673,13 +669,13 @@ mod tests {
let mut conv = ConversationTest::new("support-bot"); let mut conv = ConversationTest::new("support-bot");
conv.user_says("Hi").await; conv.user_says("Hi").await;
conv.assert_response_contains("Response").await; conv.assert_response_contains("Response");
conv.user_says("I need help").await; 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.user_says("Thanks, bye").await;
conv.end().await; conv.end();
assert_eq!(conv.sent_messages().len(), 3); assert_eq!(conv.sent_messages().len(), 3);
assert_eq!(conv.responses().len(), 3); assert_eq!(conv.responses().len(), 3);
@ -690,7 +686,7 @@ mod tests {
async fn test_response_time_assertion() { async fn test_response_time_assertion() {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("quick test").await; 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()); assert!(conv.all_passed());
} }
@ -700,7 +696,7 @@ mod tests {
let mut conv = ConversationTest::new("test-bot"); let mut conv = ConversationTest::new("test-bot");
conv.user_says("one").await; conv.user_says("one").await;
conv.user_says("two").await; conv.user_says("two").await;
conv.assert_response_count(2).await; conv.assert_response_count(2);
assert!(conv.all_passed()); 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 conversation;
mod runner; mod runner;
@ -12,7 +8,6 @@ use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use uuid::Uuid; use uuid::Uuid;
/// Response from the bot
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotResponse { pub struct BotResponse {
pub id: Uuid, pub id: Uuid,
@ -22,10 +17,11 @@ pub struct BotResponse {
pub latency_ms: u64, pub latency_ms: u64,
} }
/// Type of response content
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum ResponseContentType { pub enum ResponseContentType {
#[default]
Text, Text,
Image, Image,
Audio, Audio,
@ -37,13 +33,7 @@ pub enum ResponseContentType {
Contact, Contact,
} }
impl Default for ResponseContentType {
fn default() -> Self {
Self::Text
}
}
/// Assertion result for conversation tests
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AssertionResult { pub struct AssertionResult {
pub passed: bool, pub passed: bool,
@ -53,6 +43,7 @@ pub struct AssertionResult {
} }
impl AssertionResult { impl AssertionResult {
#[must_use]
pub fn pass(message: &str) -> Self { pub fn pass(message: &str) -> Self {
Self { Self {
passed: true, passed: true,
@ -62,6 +53,7 @@ impl AssertionResult {
} }
} }
#[must_use]
pub fn fail(message: &str, expected: &str, actual: &str) -> Self { pub fn fail(message: &str, expected: &str, actual: &str) -> Self {
Self { Self {
passed: false, passed: false,
@ -72,16 +64,11 @@ impl AssertionResult {
} }
} }
/// Configuration for conversation tests
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConversationConfig { pub struct ConversationConfig {
/// Maximum time to wait for a response
pub response_timeout: Duration, pub response_timeout: Duration,
/// Whether to record the conversation for later analysis
pub record: bool, pub record: bool,
/// Whether to use the mock LLM
pub use_mock_llm: bool, pub use_mock_llm: bool,
/// Custom variables to inject into the conversation
pub variables: HashMap<String, String>, pub variables: HashMap<String, String>,
} }
@ -96,7 +83,6 @@ impl Default for ConversationConfig {
} }
} }
/// Recorded conversation for analysis
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationRecord { pub struct ConversationRecord {
pub id: Uuid, pub id: Uuid,
@ -108,7 +94,6 @@ pub struct ConversationRecord {
pub passed: bool, pub passed: bool,
} }
/// Recorded message in a conversation
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedMessage { pub struct RecordedMessage {
pub timestamp: chrono::DateTime<chrono::Utc>, pub timestamp: chrono::DateTime<chrono::Utc>,
@ -117,7 +102,6 @@ pub struct RecordedMessage {
pub latency_ms: Option<u64>, pub latency_ms: Option<u64>,
} }
/// Recorded assertion
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssertionRecord { pub struct AssertionRecord {
pub timestamp: chrono::DateTime<chrono::Utc>, pub timestamp: chrono::DateTime<chrono::Utc>,
@ -126,28 +110,18 @@ pub struct AssertionRecord {
pub message: String, pub message: String,
} }
/// State of a conversation flow
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
pub enum ConversationState { pub enum ConversationState {
/// Initial state, conversation not started #[default]
Initial, Initial,
/// Waiting for user input
WaitingForUser, WaitingForUser,
/// Waiting for bot response
WaitingForBot, WaitingForBot,
/// Conversation transferred to human
Transferred, Transferred,
/// Conversation ended normally
Ended, Ended,
/// Conversation ended with error
Error, Error,
} }
impl Default for ConversationState {
fn default() -> Self {
Self::Initial
}
}
#[cfg(test)] #[cfg(test)]
mod tests { 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 super::{BotResponse, ConversationState, ResponseContentType};
use crate::fixtures::{Bot, Channel, Customer, Session}; use crate::fixtures::{Bot, Channel, Customer, Session};
use crate::harness::TestContext; use crate::harness::TestContext;
@ -13,20 +8,13 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use uuid::Uuid; use uuid::Uuid;
/// Configuration for the bot runner
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BotRunnerConfig { pub struct BotRunnerConfig {
/// Working directory for the bot
pub working_dir: PathBuf, pub working_dir: PathBuf,
/// Maximum execution time for a single request
pub timeout: Duration, pub timeout: Duration,
/// Whether to use mock services
pub use_mocks: bool, pub use_mocks: bool,
/// Environment variables to set
pub env_vars: HashMap<String, String>, pub env_vars: HashMap<String, String>,
/// Whether to capture logs
pub capture_logs: bool, pub capture_logs: bool,
/// Log level
pub log_level: LogLevel, pub log_level: LogLevel,
} }
@ -43,23 +31,18 @@ impl Default for BotRunnerConfig {
} }
} }
/// Log level for bot runner
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
pub enum LogLevel { pub enum LogLevel {
Trace, Trace,
Debug, Debug,
#[default]
Info, Info,
Warn, Warn,
Error, Error,
} }
impl Default for LogLevel {
fn default() -> Self {
Self::Info
}
}
/// Bot runner for executing bot scripts and simulating conversations
pub struct BotRunner { pub struct BotRunner {
config: BotRunnerConfig, config: BotRunnerConfig,
bot: Option<Bot>, bot: Option<Bot>,
@ -68,7 +51,6 @@ pub struct BotRunner {
metrics: Arc<Mutex<RunnerMetrics>>, metrics: Arc<Mutex<RunnerMetrics>>,
} }
/// Internal session state
struct SessionState { struct SessionState {
session: Session, session: Session,
customer: Customer, customer: Customer,
@ -79,7 +61,6 @@ struct SessionState {
started_at: Instant, started_at: Instant,
} }
/// Metrics collected by the runner
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct RunnerMetrics { pub struct RunnerMetrics {
pub total_requests: u64, pub total_requests: u64,
@ -93,8 +74,7 @@ pub struct RunnerMetrics {
} }
impl RunnerMetrics { impl RunnerMetrics {
/// Get average latency in milliseconds pub const fn avg_latency_ms(&self) -> u64 {
pub fn avg_latency_ms(&self) -> u64 {
if self.total_requests > 0 { if self.total_requests > 0 {
self.total_latency_ms / self.total_requests self.total_latency_ms / self.total_requests
} else { } else {
@ -102,7 +82,6 @@ impl RunnerMetrics {
} }
} }
/// Get success rate as percentage
pub fn success_rate(&self) -> f64 { pub fn success_rate(&self) -> f64 {
if self.total_requests > 0 { if self.total_requests > 0 {
(self.successful_requests as f64 / self.total_requests as f64) * 100.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)] #[derive(Debug, Clone)]
pub struct ExecutionResult { pub struct ExecutionResult {
pub session_id: Uuid, pub session_id: Uuid,
@ -123,7 +101,6 @@ pub struct ExecutionResult {
pub error: Option<String>, pub error: Option<String>,
} }
/// A log entry captured during execution
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LogEntry { pub struct LogEntry {
pub timestamp: chrono::DateTime<chrono::Utc>, pub timestamp: chrono::DateTime<chrono::Utc>,
@ -133,12 +110,10 @@ pub struct LogEntry {
} }
impl BotRunner { impl BotRunner {
/// Create a new bot runner with default configuration
pub fn new() -> Self { pub fn new() -> Self {
Self::with_config(BotRunnerConfig::default()) Self::with_config(BotRunnerConfig::default())
} }
/// Create a new bot runner with custom configuration
pub fn with_config(config: BotRunnerConfig) -> Self { pub fn with_config(config: BotRunnerConfig) -> Self {
Self { Self {
config, config,
@ -149,19 +124,16 @@ impl BotRunner {
} }
} }
/// Create a bot runner with a test context
pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self { pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self {
Self::with_config(config) Self::with_config(config)
} }
/// Set the bot to run
pub fn set_bot(&mut self, bot: Bot) -> &mut Self { pub fn set_bot(&mut self, bot: Bot) -> &mut Self {
self.bot = Some(bot); self.bot = Some(bot);
self self
} }
/// Load a BASIC script pub fn load_script(&self, name: &str, content: &str) -> &Self {
pub fn load_script(&mut self, name: &str, content: &str) -> &mut Self {
self.script_cache self.script_cache
.lock() .lock()
.unwrap() .unwrap()
@ -169,10 +141,9 @@ impl BotRunner {
self self
} }
/// Load a script from a file pub fn load_script_file(&self, name: &str, path: &PathBuf) -> Result<&Self> {
pub fn load_script_file(&mut self, name: &str, path: &PathBuf) -> Result<&mut Self> {
let content = std::fs::read_to_string(path) 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 self.script_cache
.lock() .lock()
.unwrap() .unwrap()
@ -180,10 +151,9 @@ impl BotRunner {
Ok(self) Ok(self)
} }
/// Start a new session pub fn start_session(&self, customer: Customer) -> Result<Uuid> {
pub fn start_session(&mut self, customer: Customer) -> Result<Uuid> {
let session_id = Uuid::new_v4(); 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 { let session = Session {
id: session_id, id: session_id,
@ -208,36 +178,30 @@ impl BotRunner {
Ok(session_id) Ok(session_id)
} }
/// End a session pub fn end_session(&self, session_id: Uuid) -> Result<()> {
pub fn end_session(&mut self, session_id: Uuid) -> Result<()> {
self.sessions.lock().unwrap().remove(&session_id); self.sessions.lock().unwrap().remove(&session_id);
Ok(()) Ok(())
} }
/// Process a message in a session
pub async fn process_message( pub async fn process_message(
&mut self, &self,
session_id: Uuid, session_id: Uuid,
message: &str, message: &str,
) -> Result<ExecutionResult> { ) -> Result<ExecutionResult> {
let start = Instant::now(); let start = Instant::now();
let mut logs = Vec::new(); let mut logs = Vec::new();
// Update metrics
{ {
let mut metrics = self.metrics.lock().unwrap(); let mut metrics = self.metrics.lock().unwrap();
metrics.total_requests += 1; metrics.total_requests += 1;
} }
// Get session state
let state = { let state = {
let sessions = self.sessions.lock().unwrap(); let sessions = self.sessions.lock().unwrap();
sessions.get(&session_id).cloned() sessions.get(&session_id).cloned()
}; };
let state = match state { let Some(state) = state else {
Some(s) => s,
None => {
return Ok(ExecutionResult { return Ok(ExecutionResult {
session_id, session_id,
response: None, response: None,
@ -246,24 +210,21 @@ impl BotRunner {
logs, logs,
error: Some("Session not found".to_string()), error: Some("Session not found".to_string()),
}); });
}
}; };
if self.config.capture_logs { if self.config.capture_logs {
logs.push(LogEntry { logs.push(LogEntry {
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
level: LogLevel::Debug, level: LogLevel::Debug,
message: format!("Processing message: {}", message), message: format!("Processing message: {message}"),
context: HashMap::new(), context: HashMap::new(),
}); });
} }
// Execute bot logic (placeholder - would call actual bot runtime)
let response = self.execute_bot_logic(session_id, message, &state).await; let response = self.execute_bot_logic(session_id, message, &state).await;
let execution_time = start.elapsed(); let execution_time = start.elapsed();
// Update metrics
{ {
let mut metrics = self.metrics.lock().unwrap(); let mut metrics = self.metrics.lock().unwrap();
let latency_ms = execution_time.as_millis() as u64; 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(); let mut sessions = self.sessions.lock().unwrap();
if let Some(session_state) = sessions.get_mut(&session_id) { 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( async fn execute_bot_logic(
&self, &self,
_session_id: Uuid, session_id: Uuid,
message: &str, message: &str,
_state: &SessionState, state: &SessionState,
) -> Result<BotResponse> { ) -> Result<BotResponse> {
// In a real implementation, this would: let start = Instant::now();
// 1. Load the bot's BASIC script
// 2. Execute it with the message as input let bot = self.bot.as_ref().context("No bot configured")?;
// 3. Return the bot's response
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 { Ok(BotResponse {
id: Uuid::new_v4(), id: Uuid::new_v4(),
content: format!("Echo: {}", message), content: response_content,
content_type: ResponseContentType::Text, content_type: ResponseContentType::Text,
metadata: HashMap::new(), metadata: HashMap::from([
latency_ms: 50, (
"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 fn evaluate_basic_script(
pub async fn execute_script( script: &str,
&mut self,
script_name: &str,
input: &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 session_id = Uuid::new_v4();
let start = Instant::now(); let start = Instant::now();
let mut logs = Vec::new(); let mut logs = Vec::new();
// Get script from cache
let script = { let script = {
let cache = self.script_cache.lock().unwrap(); let cache = self.script_cache.lock().unwrap();
cache.get(script_name).cloned() cache.get(script_name).cloned()
}; };
let script = match script { let Some(script) = script else {
Some(s) => s,
None => {
return Ok(ExecutionResult { return Ok(ExecutionResult {
session_id, session_id,
response: None, response: None,
state: ConversationState::Error, state: ConversationState::Error,
execution_time: start.elapsed(), execution_time: start.elapsed(),
logs, logs,
error: Some(format!("Script '{}' not found", script_name)), error: Some(format!("Script '{script_name}' not found")),
}); });
}
}; };
if self.config.capture_logs { if self.config.capture_logs {
logs.push(LogEntry { logs.push(LogEntry {
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
level: LogLevel::Debug, level: LogLevel::Debug,
message: format!("Executing script: {}", script_name), message: format!("Executing script: {script_name}"),
context: HashMap::new(), context: HashMap::new(),
}); });
} }
// Update metrics
{ {
let mut metrics = self.metrics.lock().unwrap(); let mut metrics = self.metrics.lock().unwrap();
metrics.script_executions += 1; metrics.script_executions += 1;
} }
// Execute script (placeholder) let result = Self::execute_script_internal(&script, input);
let result = self.execute_script_internal(&script, input).await;
let execution_time = start.elapsed(); let execution_time = start.elapsed();
@ -410,42 +453,37 @@ impl BotRunner {
} }
} }
/// Internal script execution (placeholder) fn execute_script_internal(script: &str, input: &str) -> Result<String> {
async fn execute_script_internal(&self, _script: &str, input: &str) -> Result<String> { let context = HashMap::new();
// In a real implementation, this would parse and execute the BASIC script Self::evaluate_basic_script(script, input, &context)
// For now, just echo the input
Ok(format!("Script output for: {}", input))
} }
/// Get current metrics
pub fn metrics(&self) -> RunnerMetrics { pub fn metrics(&self) -> RunnerMetrics {
self.metrics.lock().unwrap().clone() self.metrics.lock().unwrap().clone()
} }
/// Reset metrics pub fn reset_metrics(&self) {
pub fn reset_metrics(&mut self) {
*self.metrics.lock().unwrap() = RunnerMetrics::default(); *self.metrics.lock().unwrap() = RunnerMetrics::default();
} }
/// Get active session count
pub fn active_session_count(&self) -> usize { pub fn active_session_count(&self) -> usize {
self.sessions.lock().unwrap().len() self.sessions.lock().unwrap().len()
} }
/// Get session info
pub fn get_session_info(&self, session_id: Uuid) -> Option<SessionInfo> { pub fn get_session_info(&self, session_id: Uuid) -> Option<SessionInfo> {
let sessions = self.sessions.lock().unwrap(); 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, session_id: s.session.id,
customer_id: s.customer.id, customer_id: s.customer.id,
channel: s.channel, channel: s.channel,
message_count: s.message_count, message_count: s.message_count,
state: s.conversation_state, state: s.conversation_state,
duration: s.started_at.elapsed(), 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 { pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self {
self.config self.config
.env_vars .env_vars
@ -453,8 +491,7 @@ impl BotRunner {
self self
} }
/// Set timeout pub const fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
self.config.timeout = timeout; self.config.timeout = timeout;
self self
} }
@ -466,7 +503,6 @@ impl Default for BotRunner {
} }
} }
/// Information about a session
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SessionInfo { pub struct SessionInfo {
pub session_id: Uuid, pub session_id: Uuid,
@ -477,7 +513,6 @@ pub struct SessionInfo {
pub duration: Duration, pub duration: Duration,
} }
// Implement Clone for SessionState
impl Clone for SessionState { impl Clone for SessionState {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
@ -506,27 +541,31 @@ mod tests {
#[test] #[test]
fn test_runner_metrics_avg_latency() { fn test_runner_metrics_avg_latency() {
let mut metrics = RunnerMetrics::default(); let metrics = RunnerMetrics {
metrics.total_requests = 10; total_requests: 10,
metrics.total_latency_ms = 1000; total_latency_ms: 1000,
..Default::default()
};
assert_eq!(metrics.avg_latency_ms(), 100); assert_eq!(metrics.avg_latency_ms(), 100);
} }
#[test] #[test]
fn test_runner_metrics_success_rate() { fn test_runner_metrics_success_rate() {
let mut metrics = RunnerMetrics::default(); let metrics = RunnerMetrics {
metrics.total_requests = 100; total_requests: 100,
metrics.successful_requests = 95; successful_requests: 95,
..Default::default()
};
assert_eq!(metrics.success_rate(), 95.0); assert!((metrics.success_rate() - 95.0).abs() < f64::EPSILON);
} }
#[test] #[test]
fn test_runner_metrics_zero_requests() { fn test_runner_metrics_zero_requests() {
let metrics = RunnerMetrics::default(); let metrics = RunnerMetrics::default();
assert_eq!(metrics.avg_latency_ms(), 0); assert_eq!(metrics.avg_latency_ms(), 0);
assert_eq!(metrics.success_rate(), 0.0); assert!(metrics.success_rate().abs() < f64::EPSILON);
} }
#[test] #[test]
@ -537,16 +576,17 @@ mod tests {
#[test] #[test]
fn test_load_script() { fn test_load_script() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
runner.load_script("test", "TALK \"Hello\""); runner.load_script("test", "TALK \"Hello\"");
let cache = runner.script_cache.lock().unwrap(); let cache = runner.script_cache.lock().unwrap();
assert!(cache.contains_key("test")); assert!(cache.contains_key("test"));
drop(cache);
} }
#[test] #[test]
fn test_start_session() { fn test_start_session() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
let customer = Customer::default(); let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap(); let session_id = runner.start_session(customer).unwrap();
@ -557,7 +597,7 @@ mod tests {
#[test] #[test]
fn test_end_session() { fn test_end_session() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
let customer = Customer::default(); let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap(); let session_id = runner.start_session(customer).unwrap();
@ -569,7 +609,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_process_message() { async fn test_process_message() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
let customer = Customer::default(); let customer = Customer::default();
let session_id = runner.start_session(customer).unwrap(); let session_id = runner.start_session(customer).unwrap();
@ -582,7 +622,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_process_message_invalid_session() { async fn test_process_message_invalid_session() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
let invalid_session_id = Uuid::new_v4(); let invalid_session_id = Uuid::new_v4();
let result = runner let result = runner
@ -595,22 +635,22 @@ mod tests {
assert_eq!(result.state, ConversationState::Error); assert_eq!(result.state, ConversationState::Error);
} }
#[tokio::test] #[test]
async fn test_execute_script() { fn test_execute_script() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
runner.load_script("greeting", "TALK \"Hello\""); 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.response.is_some());
assert!(result.error.is_none()); assert!(result.error.is_none());
} }
#[tokio::test] #[test]
async fn test_execute_script_not_found() { fn test_execute_script_not_found() {
let mut runner = BotRunner::new(); 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.response.is_none());
assert!(result.error.is_some()); assert!(result.error.is_some());
@ -628,9 +668,8 @@ mod tests {
#[test] #[test]
fn test_reset_metrics() { fn test_reset_metrics() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
// Manually update metrics
{ {
let mut metrics = runner.metrics.lock().unwrap(); let mut metrics = runner.metrics.lock().unwrap();
metrics.total_requests = 100; metrics.total_requests = 100;
@ -663,7 +702,7 @@ mod tests {
#[test] #[test]
fn test_session_info() { fn test_session_info() {
let mut runner = BotRunner::new(); let runner = BotRunner::new();
let customer = Customer::default(); let customer = Customer::default();
let customer_id = customer.id; 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 anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
/// Configuration for desktop application testing
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DesktopConfig { pub struct DesktopConfig {
/// Path to the application executable
pub app_path: PathBuf, pub app_path: PathBuf,
/// Command line arguments for the application
pub args: Vec<String>, pub args: Vec<String>,
/// Environment variables to set
pub env_vars: HashMap<String, String>, pub env_vars: HashMap<String, String>,
/// Working directory for the application
pub working_dir: Option<PathBuf>, pub working_dir: Option<PathBuf>,
/// Timeout for operations
pub timeout: Duration, pub timeout: Duration,
/// Whether to capture screenshots on failure
pub screenshot_on_failure: bool, pub screenshot_on_failure: bool,
/// Directory to save screenshots
pub screenshot_dir: PathBuf, pub screenshot_dir: PathBuf,
} }
@ -46,7 +30,6 @@ impl Default for DesktopConfig {
} }
impl DesktopConfig { impl DesktopConfig {
/// Create a new config for the given application path
pub fn new(app_path: impl Into<PathBuf>) -> Self { pub fn new(app_path: impl Into<PathBuf>) -> Self {
Self { Self {
app_path: app_path.into(), 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 { pub fn with_args(mut self, args: Vec<String>) -> Self {
self.args = args; self.args = args;
self self
} }
/// Add an environment variable #[must_use]
pub fn with_env(mut self, key: &str, value: &str) -> Self { pub fn with_env(mut self, key: &str, value: &str) -> Self {
self.env_vars.insert(key.to_string(), value.to_string()); self.env_vars.insert(key.to_string(), value.to_string());
self self
} }
/// Set the working directory
pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self { pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.working_dir = Some(dir.into()); self.working_dir = Some(dir.into());
self self
} }
/// Set the timeout #[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self { pub const fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout; self.timeout = timeout;
self self
} }
} }
/// Platform type for desktop testing
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform { pub enum Platform {
Windows, Windows,
@ -88,20 +69,19 @@ pub enum Platform {
} }
impl Platform { impl Platform {
/// Detect the current platform #[must_use]
pub fn current() -> Self { pub const fn current() -> Self {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return Platform::Windows; return Platform::Windows;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
return Platform::MacOS; return Platform::MacOS;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
return Platform::Linux; return Self::Linux;
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
panic!("Unsupported platform for desktop testing"); panic!("Unsupported platform for desktop testing");
} }
} }
/// Desktop application handle for testing
pub struct DesktopApp { pub struct DesktopApp {
config: DesktopConfig, config: DesktopConfig,
platform: Platform, platform: Platform,
@ -110,8 +90,8 @@ pub struct DesktopApp {
} }
impl DesktopApp { impl DesktopApp {
/// Create a new desktop app handle #[must_use]
pub fn new(config: DesktopConfig) -> Self { pub const fn new(config: DesktopConfig) -> Self {
Self { Self {
config, config,
platform: Platform::current(), platform: Platform::current(),
@ -120,7 +100,6 @@ impl DesktopApp {
} }
} }
/// Launch the application
pub async fn launch(&mut self) -> Result<()> { pub async fn launch(&mut self) -> Result<()> {
use std::process::Command; use std::process::Command;
@ -139,16 +118,13 @@ impl DesktopApp {
self.pid = Some(child.id()); self.pid = Some(child.id());
self.process = Some(child); self.process = Some(child);
// Wait for application to start
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
Ok(()) Ok(())
} }
/// Close the application
pub async fn close(&mut self) -> Result<()> { pub async fn close(&mut self) -> Result<()> {
if let Some(ref mut process) = self.process { if let Some(ref mut process) = self.process {
// Try graceful shutdown first
#[cfg(unix)] #[cfg(unix)]
{ {
use nix::sys::signal::{kill, Signal}; 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; tokio::time::sleep(Duration::from_millis(500)).await;
// Force kill if still running
let _ = process.kill(); let _ = process.kill();
let _ = process.wait(); let _ = process.wait();
self.process = None; self.process = None;
@ -170,7 +144,6 @@ impl DesktopApp {
Ok(()) Ok(())
} }
/// Check if the application is running
pub fn is_running(&mut self) -> bool { pub fn is_running(&mut self) -> bool {
if let Some(ref mut process) = self.process { if let Some(ref mut process) = self.process {
match process.try_wait() { match process.try_wait() {
@ -187,64 +160,56 @@ impl DesktopApp {
} }
} }
/// Get the process ID #[must_use]
pub fn pid(&self) -> Option<u32> { pub const fn pid(&self) -> Option<u32> {
self.pid self.pid
} }
/// Get the platform #[must_use]
pub fn platform(&self) -> Platform { pub const fn platform(&self) -> Platform {
self.platform self.platform
} }
/// Find a window by title pub fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
pub async fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
// Platform-specific window finding
match self.platform { match self.platform {
Platform::Windows => self.find_window_windows(title).await, Platform::Windows => Self::find_window_windows(title),
Platform::MacOS => self.find_window_macos(title).await, Platform::MacOS => Self::find_window_macos(title),
Platform::Linux => self.find_window_linux(title).await, Platform::Linux => Self::find_window_linux(title),
} }
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
async fn find_window_windows(&self, _title: &str) -> Result<Option<WindowHandle>> { fn find_window_windows(_title: &str) -> Result<Option<WindowHandle>> {
// Windows-specific implementation using Win32 API
// Would use FindWindow or EnumWindows
anyhow::bail!("Windows desktop testing not yet implemented") anyhow::bail!("Windows desktop testing not yet implemented")
} }
#[cfg(not(target_os = "windows"))] #[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") anyhow::bail!("Windows desktop testing not available on this platform")
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
async fn find_window_macos(&self, _title: &str) -> Result<Option<WindowHandle>> { fn find_window_macos(_title: &str) -> Result<Option<WindowHandle>> {
// macOS-specific implementation using Accessibility API
// Would use AXUIElement APIs
anyhow::bail!("macOS desktop testing not yet implemented") anyhow::bail!("macOS desktop testing not yet implemented")
} }
#[cfg(not(target_os = "macos"))] #[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") anyhow::bail!("macOS desktop testing not available on this platform")
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
async fn find_window_linux(&self, _title: &str) -> Result<Option<WindowHandle>> { fn find_window_linux(_title: &str) -> Result<Option<WindowHandle>> {
// Linux-specific implementation using AT-SPI or X11/Wayland
// Would use libatspi or XGetWindowProperty
anyhow::bail!("Linux desktop testing not yet implemented") anyhow::bail!("Linux desktop testing not yet implemented")
} }
#[cfg(not(target_os = "linux"))] #[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") anyhow::bail!("Linux desktop testing not available on this platform")
} }
/// Take a screenshot of the application pub fn screenshot(&self) -> Result<Screenshot> {
pub async fn screenshot(&self) -> Result<Screenshot> { let _ = &self.platform;
anyhow::bail!("Screenshot functionality not yet implemented") anyhow::bail!("Screenshot functionality not yet implemented")
} }
} }
@ -258,29 +223,20 @@ impl Drop for DesktopApp {
} }
} }
/// Handle to a window
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WindowHandle { pub struct WindowHandle {
/// Platform-specific window identifier
pub id: WindowId, pub id: WindowId,
/// Window title
pub title: String, pub title: String,
/// Window bounds
pub bounds: WindowBounds, pub bounds: WindowBounds,
} }
/// Platform-specific window identifier
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WindowId { pub enum WindowId {
/// Windows HWND (as usize)
Windows(usize), Windows(usize),
/// macOS AXUIElement reference (opaque pointer)
MacOS(usize), MacOS(usize),
/// Linux X11 Window ID or AT-SPI path
Linux(String), Linux(String),
} }
/// Window bounds
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct WindowBounds { pub struct WindowBounds {
pub x: i32, pub x: i32,
@ -289,121 +245,105 @@ pub struct WindowBounds {
pub height: u32, pub height: u32,
} }
/// Screenshot data
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Screenshot { pub struct Screenshot {
/// Raw pixel data (RGBA)
pub data: Vec<u8>, pub data: Vec<u8>,
/// Width in pixels
pub width: u32, pub width: u32,
/// Height in pixels
pub height: u32, pub height: u32,
} }
impl Screenshot { impl Screenshot {
/// Save screenshot to a file
pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> { pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> {
let _ = (&self.data, self.width, self.height);
let path = path.into(); let path = path.into();
// Would use image crate to save PNG anyhow::bail!("Screenshot save not yet implemented: {}", path.display())
anyhow::bail!("Screenshot save not yet implemented: {:?}", path)
} }
} }
/// Element locator for desktop UI
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ElementLocator { pub enum ElementLocator {
/// Accessibility ID
AccessibilityId(String), AccessibilityId(String),
/// Element name/label
Name(String), Name(String),
/// Element type/role
Role(String), Role(String),
/// XPath-like path
Path(String), Path(String),
/// Combination of properties
Properties(HashMap<String, String>), Properties(HashMap<String, String>),
} }
impl ElementLocator { impl ElementLocator {
#[must_use]
pub fn accessibility_id(id: &str) -> Self { pub fn accessibility_id(id: &str) -> Self {
Self::AccessibilityId(id.to_string()) Self::AccessibilityId(id.to_string())
} }
#[must_use]
pub fn name(name: &str) -> Self { pub fn name(name: &str) -> Self {
Self::Name(name.to_string()) Self::Name(name.to_string())
} }
#[must_use]
pub fn role(role: &str) -> Self { pub fn role(role: &str) -> Self {
Self::Role(role.to_string()) Self::Role(role.to_string())
} }
#[must_use]
pub fn path(path: &str) -> Self { pub fn path(path: &str) -> Self {
Self::Path(path.to_string()) Self::Path(path.to_string())
} }
} }
/// Desktop UI element
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Element { pub struct Element {
/// Element locator used to find this element
pub locator: ElementLocator, pub locator: ElementLocator,
/// Element role/type
pub role: String, pub role: String,
/// Element name/label
pub name: Option<String>, pub name: Option<String>,
/// Element value
pub value: Option<String>, pub value: Option<String>,
/// Element bounds
pub bounds: WindowBounds, pub bounds: WindowBounds,
/// Whether the element is enabled
pub enabled: bool, pub enabled: bool,
/// Whether the element is focused
pub focused: bool, pub focused: bool,
} }
impl Element { impl Element {
/// Click the element pub fn click(&self) -> Result<()> {
pub async fn click(&self) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element click not yet implemented") anyhow::bail!("Element click not yet implemented")
} }
/// Double-click the element pub fn double_click(&self) -> Result<()> {
pub async fn double_click(&self) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element double-click not yet implemented") anyhow::bail!("Element double-click not yet implemented")
} }
/// Right-click the element pub fn right_click(&self) -> Result<()> {
pub async fn right_click(&self) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element right-click not yet implemented") anyhow::bail!("Element right-click not yet implemented")
} }
/// Type text into the element pub fn type_text(&self, _text: &str) -> Result<()> {
pub async fn type_text(&self, _text: &str) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element type_text not yet implemented") anyhow::bail!("Element type_text not yet implemented")
} }
/// Clear the element's text pub fn clear(&self) -> Result<()> {
pub async fn clear(&self) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element clear not yet implemented") anyhow::bail!("Element clear not yet implemented")
} }
/// Get the element's text content #[must_use]
pub fn text(&self) -> Option<&str> { pub fn text(&self) -> Option<&str> {
self.value.as_deref() self.value.as_deref()
} }
/// Check if element is displayed/visible #[must_use]
pub fn is_displayed(&self) -> bool { pub const fn is_displayed(&self) -> bool {
self.bounds.width > 0 && self.bounds.height > 0 self.bounds.width > 0 && self.bounds.height > 0
} }
/// Focus the element pub fn focus(&self) -> Result<()> {
pub async fn focus(&self) -> Result<()> { let _ = &self.locator;
anyhow::bail!("Element focus not yet implemented") anyhow::bail!("Element focus not yet implemented")
} }
} }
/// Result of a desktop test
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopTestResult { pub struct DesktopTestResult {
pub name: String, pub name: String,
@ -414,7 +354,6 @@ pub struct DesktopTestResult {
pub error: Option<String>, pub error: Option<String>,
} }
/// A step in a desktop test
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStep { pub struct TestStep {
pub name: String, pub name: String,
@ -450,7 +389,6 @@ mod tests {
#[test] #[test]
fn test_platform_detection() { fn test_platform_detection() {
let platform = Platform::current(); let platform = Platform::current();
// Just verify it doesn't panic
assert!(matches!( assert!(matches!(
platform, platform,
Platform::Windows | Platform::MacOS | Platform::Linux 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::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
/// Sample configuration data #[must_use]
pub fn sample_config() -> HashMap<String, String> { pub fn sample_config() -> HashMap<String, String> {
let mut config = HashMap::new(); let mut config = HashMap::new();
config.insert("llm-model".to_string(), "gpt-4".to_string()); config.insert("llm-model".to_string(), "gpt-4".to_string());
@ -20,7 +15,7 @@ pub fn sample_config() -> HashMap<String, String> {
config config
} }
/// Sample bot configuration as JSON #[must_use]
pub fn sample_bot_config() -> Value { pub fn sample_bot_config() -> Value {
json!({ json!({
"name": "test-bot", "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 { pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
json!({ json!({
"object": "whatsapp_business_account", "object": "whatsapp_business_account",
@ -74,7 +69,7 @@ pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
}], }],
"messages": [{ "messages": [{
"from": from, "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(), "timestamp": chrono::Utc::now().timestamp().to_string(),
"type": "text", "type": "text",
"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 { pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) -> Value {
json!({ json!({
"object": "whatsapp_business_account", "object": "whatsapp_business_account",
@ -109,7 +104,7 @@ pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) ->
}], }],
"messages": [{ "messages": [{
"from": from, "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(), "timestamp": chrono::Utc::now().timestamp().to_string(),
"type": "interactive", "type": "interactive",
"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 { pub fn teams_message_activity(from_id: &str, from_name: &str, text: &str) -> Value {
json!({ json!({
"type": "message", "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 { pub fn openai_chat_request(messages: Vec<(&str, &str)>) -> Value {
let msgs: Vec<Value> = messages let msgs: Vec<Value> = messages
.into_iter() .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 { pub fn openai_chat_response(content: &str) -> Value {
json!({ json!({
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()), "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 { pub fn openai_embedding_response(dimensions: usize) -> Value {
let embedding: Vec<f64> = (0..dimensions) let embedding: Vec<f64> = (0..dimensions)
.map(|i| (i as f64) / (dimensions as f64)) .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> { pub fn sample_kb_entries() -> Vec<KBEntry> {
vec![ vec![
KBEntry { KBEntry {
@ -258,7 +253,6 @@ pub fn sample_kb_entries() -> Vec<KBEntry> {
] ]
} }
/// Knowledge base entry
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KBEntry { pub struct KBEntry {
pub id: String, pub id: String,
@ -268,7 +262,7 @@ pub struct KBEntry {
pub tags: Vec<String>, pub tags: Vec<String>,
} }
/// Sample product data #[must_use]
pub fn sample_products() -> Vec<Product> { pub fn sample_products() -> Vec<Product> {
vec![ vec![
Product { Product {
@ -298,7 +292,6 @@ pub fn sample_products() -> Vec<Product> {
] ]
} }
/// Product data
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product { pub struct Product {
pub sku: String, pub sku: String,
@ -309,7 +302,7 @@ pub struct Product {
pub category: String, pub category: String,
} }
/// Sample FAQ data #[must_use]
pub fn sample_faqs() -> Vec<FAQ> { pub fn sample_faqs() -> Vec<FAQ> {
vec![ vec![
FAQ { FAQ {
@ -339,8 +332,8 @@ pub fn sample_faqs() -> Vec<FAQ> {
] ]
} }
/// FAQ data
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub struct FAQ { pub struct FAQ {
pub id: u32, pub id: u32,
pub question: String, pub question: String,
@ -348,10 +341,10 @@ pub struct FAQ {
pub category: String, pub category: String,
} }
/// Sample error responses
pub mod errors { pub mod errors {
use serde_json::{json, Value}; use serde_json::{json, Value};
#[must_use]
pub fn validation_error(field: &str, message: &str) -> Value { pub fn validation_error(field: &str, message: &str) -> Value {
json!({ json!({
"error": { "error": {
@ -365,6 +358,7 @@ pub mod errors {
}) })
} }
#[must_use]
pub fn not_found(resource: &str, id: &str) -> Value { pub fn not_found(resource: &str, id: &str) -> Value {
json!({ json!({
"error": { "error": {
@ -374,6 +368,7 @@ pub mod errors {
}) })
} }
#[must_use]
pub fn unauthorized() -> Value { pub fn unauthorized() -> Value {
json!({ json!({
"error": { "error": {
@ -383,6 +378,7 @@ pub mod errors {
}) })
} }
#[must_use]
pub fn forbidden() -> Value { pub fn forbidden() -> Value {
json!({ json!({
"error": { "error": {
@ -392,6 +388,7 @@ pub mod errors {
}) })
} }
#[must_use]
pub fn rate_limited(retry_after: u32) -> Value { pub fn rate_limited(retry_after: u32) -> Value {
json!({ json!({
"error": { "error": {
@ -402,6 +399,7 @@ pub mod errors {
}) })
} }
#[must_use]
pub fn internal_error() -> Value { pub fn internal_error() -> Value {
json!({ json!({
"error": { "error": {
@ -427,10 +425,12 @@ mod tests {
fn test_whatsapp_text_message() { fn test_whatsapp_text_message() {
let payload = whatsapp_text_message("15551234567", "Hello"); let payload = whatsapp_text_message("15551234567", "Hello");
assert_eq!(payload["object"], "whatsapp_business_account"); assert_eq!(payload["object"], "whatsapp_business_account");
assert!(payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"] assert!(
payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
.as_str() .as_str()
.unwrap() .unwrap()
.contains("Hello")); .contains("Hello")
);
} }
#[test] #[test]
@ -455,7 +455,9 @@ mod tests {
fn test_sample_kb_entries() { fn test_sample_kb_entries() {
let entries = sample_kb_entries(); let entries = sample_kb_entries();
assert!(!entries.is_empty()); 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] #[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 data;
pub mod scripts; pub mod scripts;
@ -11,9 +6,6 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
// Re-export common fixtures
/// A test user
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
@ -39,23 +31,18 @@ impl Default for User {
} }
} }
/// User role
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum Role { pub enum Role {
Admin, Admin,
Attendant, Attendant,
#[default]
User, User,
Guest, Guest,
} }
impl Default for Role {
fn default() -> Self {
Self::User
}
}
/// A customer (end user interacting with bot)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Customer { pub struct Customer {
pub id: Uuid, pub id: Uuid,
@ -85,10 +72,12 @@ impl Default for Customer {
} }
} }
/// Communication channel
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[allow(clippy::upper_case_acronyms)]
#[derive(Default)]
pub enum Channel { pub enum Channel {
#[default]
WhatsApp, WhatsApp,
Teams, Teams,
Web, Web,
@ -97,13 +86,7 @@ pub enum Channel {
API, API,
} }
impl Default for Channel {
fn default() -> Self {
Self::WhatsApp
}
}
/// A bot configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bot { pub struct Bot {
pub id: Uuid, pub id: Uuid,
@ -135,7 +118,6 @@ impl Default for Bot {
} }
} }
/// A conversation session
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session { pub struct Session {
pub id: Uuid, pub id: Uuid,
@ -165,23 +147,18 @@ impl Default for Session {
} }
} }
/// Session state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SessionState { pub enum SessionState {
#[default]
Active, Active,
Waiting, Waiting,
Transferred, Transferred,
Ended, Ended,
} }
impl Default for SessionState {
fn default() -> Self {
Self::Active
}
}
/// A conversation message
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message { pub struct Message {
pub id: Uuid, pub id: Uuid,
@ -207,7 +184,6 @@ impl Default for Message {
} }
} }
/// Message direction
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum MessageDirection { pub enum MessageDirection {
@ -215,10 +191,11 @@ pub enum MessageDirection {
Outgoing, Outgoing,
} }
/// Content type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ContentType { pub enum ContentType {
#[default]
Text, Text,
Image, Image,
Audio, Audio,
@ -229,13 +206,7 @@ pub enum ContentType {
Interactive, Interactive,
} }
impl Default for ContentType {
fn default() -> Self {
Self::Text
}
}
/// Queue entry for attendance
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueEntry { pub struct QueueEntry {
pub id: Uuid, pub id: Uuid,
@ -263,26 +234,23 @@ impl Default for QueueEntry {
} }
} }
/// Queue priority
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum Priority { pub enum Priority {
Low = 0, Low = 0,
#[default]
Normal = 1, Normal = 1,
High = 2, High = 2,
Urgent = 3, Urgent = 3,
} }
impl Default for Priority {
fn default() -> Self {
Self::Normal
}
}
/// Queue status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum QueueStatus { pub enum QueueStatus {
#[default]
Waiting, Waiting,
Assigned, Assigned,
InProgress, InProgress,
@ -290,17 +258,8 @@ pub enum QueueStatus {
Cancelled, Cancelled,
} }
impl Default for QueueStatus {
fn default() -> Self {
Self::Waiting
}
}
// ============================================================================= #[must_use]
// Factory Functions
// =============================================================================
/// Create an admin user
pub fn admin_user() -> User { pub fn admin_user() -> User {
User { User {
email: "admin@test.com".to_string(), 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 { pub fn attendant_user() -> User {
User { User {
email: "attendant@test.com".to_string(), 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 { pub fn regular_user() -> User {
User { User {
email: "user@test.com".to_string(), 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 { pub fn user_with_email(email: &str) -> User {
User { User {
email: email.to_string(), 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 { pub fn customer(phone: &str) -> Customer {
Customer { Customer {
phone: Some(phone.to_string()), 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 { pub fn customer_on_channel(channel: Channel) -> Customer {
Customer { Customer {
channel, channel,
@ -356,7 +315,7 @@ pub fn customer_on_channel(channel: Channel) -> Customer {
} }
} }
/// Create a Teams customer #[must_use]
pub fn teams_customer() -> Customer { pub fn teams_customer() -> Customer {
Customer { Customer {
channel: Channel::Teams, channel: Channel::Teams,
@ -365,7 +324,7 @@ pub fn teams_customer() -> Customer {
} }
} }
/// Create a web customer #[must_use]
pub fn web_customer() -> Customer { pub fn web_customer() -> Customer {
Customer { Customer {
channel: Channel::Web, 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 { pub fn basic_bot(name: &str) -> Bot {
Bot { Bot {
name: name.to_string(), 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 { pub fn bot_with_kb(name: &str) -> Bot {
Bot { Bot {
name: name.to_string(), 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 { pub fn rule_based_bot(name: &str) -> Bot {
Bot { Bot {
name: name.to_string(), 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 { pub fn session_for(bot: &Bot, customer: &Customer) -> Session {
Session { Session {
bot_id: bot.id, 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 { pub fn active_session() -> Session {
Session { Session {
state: SessionState::Active, 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 { pub fn incoming_message(content: &str) -> Message {
Message { Message {
direction: MessageDirection::Incoming, 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 { pub fn outgoing_message(content: &str) -> Message {
Message { Message {
direction: MessageDirection::Outgoing, 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( pub fn message_in_session(
session: &Session, session: &Session,
content: &str, 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 { pub fn queue_entry_for(customer: &Customer, session: &Session) -> QueueEntry {
QueueEntry { QueueEntry {
customer_id: customer.id, 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 { pub fn high_priority_queue_entry() -> QueueEntry {
QueueEntry { QueueEntry {
priority: Priority::High, 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 { pub fn urgent_queue_entry() -> QueueEntry {
QueueEntry { QueueEntry {
priority: Priority::Urgent, 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; use std::collections::HashMap;
/// Get a script fixture by name #[must_use]
pub fn get_script(name: &str) -> Option<&'static str> { pub fn get_script(name: &str) -> Option<&'static str> {
match name { match name {
"greeting" => Some(GREETING_SCRIPT), "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> { pub fn available_scripts() -> Vec<&'static str> {
vec![ vec![
"greeting", "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> { pub fn all_scripts() -> HashMap<&'static str, &'static str> {
let mut scripts = HashMap::new(); let mut scripts = HashMap::new();
for name in available_scripts() { for name in available_scripts() {
@ -49,7 +45,6 @@ pub fn all_scripts() -> HashMap<&'static str, &'static str> {
scripts scripts
} }
/// Simple greeting flow script
pub const GREETING_SCRIPT: &str = r#" pub const GREETING_SCRIPT: &str = r#"
' Greeting Flow Script ' Greeting Flow Script
' Simple greeting and response pattern ' Simple greeting and response pattern
@ -72,7 +67,6 @@ ELSE
END IF END IF
"#; "#;
/// Knowledge base search script
pub const KB_SEARCH_SCRIPT: &str = r#" pub const KB_SEARCH_SCRIPT: &str = r#"
' Knowledge Base Search Script ' Knowledge Base Search Script
' Demonstrates searching the knowledge base ' Demonstrates searching the knowledge base
@ -98,7 +92,6 @@ ELSE
END IF END IF
"#; "#;
/// Human handoff / attendance flow script
pub const ATTENDANCE_SCRIPT: &str = r#" pub const ATTENDANCE_SCRIPT: &str = r#"
' Attendance / Human Handoff Script ' Attendance / Human Handoff Script
' Demonstrates transferring to human agents ' Demonstrates transferring to human agents
@ -130,7 +123,6 @@ ELSE
END IF END IF
"#; "#;
/// Error handling patterns script
pub const ERROR_HANDLING_SCRIPT: &str = r#" pub const ERROR_HANDLING_SCRIPT: &str = r#"
' Error Handling Script ' Error Handling Script
' Demonstrates ON ERROR RESUME NEXT patterns ' Demonstrates ON ERROR RESUME NEXT patterns
@ -167,7 +159,6 @@ TALK "Processing your request: " + LEFT$(userInput$, 50) + "..."
retry_input: retry_input:
"#; "#;
/// LLM with tools script
pub const LLM_TOOLS_SCRIPT: &str = r#" pub const LLM_TOOLS_SCRIPT: &str = r#"
' LLM Tools Script ' LLM Tools Script
' Demonstrates LLM with function calling / tools ' Demonstrates LLM with function calling / tools
@ -209,7 +200,6 @@ TALK llm.response
GOTO conversation_loop GOTO conversation_loop
"#; "#;
/// Data operations script
pub const DATA_OPERATIONS_SCRIPT: &str = r#" pub const DATA_OPERATIONS_SCRIPT: &str = r#"
' Data Operations Script ' Data Operations Script
' Demonstrates FIND, SAVE, UPDATE, DELETE operations ' Demonstrates FIND, SAVE, UPDATE, DELETE operations
@ -251,7 +241,6 @@ BEGIN TRANSACTION
COMMIT TRANSACTION COMMIT TRANSACTION
"#; "#;
/// HTTP integration script
pub const HTTP_INTEGRATION_SCRIPT: &str = r#" pub const HTTP_INTEGRATION_SCRIPT: &str = r#"
' HTTP Integration Script ' HTTP Integration Script
' Demonstrates POST, GET, GRAPHQL, SOAP calls ' 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 TALK "Product: " + soap_response.ProductName
"#; "#;
/// Menu-driven conversation flow
pub const MENU_FLOW_SCRIPT: &str = r#" pub const MENU_FLOW_SCRIPT: &str = r#"
' Menu Flow Script ' Menu Flow Script
' Demonstrates interactive menu-based conversation ' Demonstrates interactive menu-based conversation
@ -364,7 +352,6 @@ return_item:
RETURN RETURN
"#; "#;
/// Simple echo script for basic testing
pub const SIMPLE_ECHO_SCRIPT: &str = r#" pub const SIMPLE_ECHO_SCRIPT: &str = r#"
' Simple Echo Script ' Simple Echo Script
' Echoes back whatever the user says ' Echoes back whatever the user says
@ -383,7 +370,6 @@ TALK "You said: " + input$
GOTO echo_loop GOTO echo_loop
"#; "#;
/// Variables and expressions script
pub const VARIABLES_SCRIPT: &str = r#" pub const VARIABLES_SCRIPT: &str = r#"
' Variables and Expressions Script ' Variables and Expressions Script
' Demonstrates variable types and operations ' Demonstrates variable types and operations

View file

@ -35,7 +35,8 @@ impl Default for TestConfig {
} }
impl TestConfig { impl TestConfig {
pub fn minimal() -> Self { #[must_use]
pub const fn minimal() -> Self {
Self { Self {
postgres: false, postgres: false,
minio: false, minio: false,
@ -46,31 +47,32 @@ impl TestConfig {
} }
} }
pub fn full() -> Self { #[must_use]
pub const fn full() -> Self {
Self { Self {
postgres: false, // Botserver will bootstrap its own PostgreSQL postgres: false,
minio: false, // Botserver will bootstrap its own MinIO minio: false,
redis: false, // Botserver will bootstrap its own Redis redis: false,
mock_zitadel: true, mock_zitadel: true,
mock_llm: true, mock_llm: true,
run_migrations: false, // Let botserver run its own migrations run_migrations: false,
} }
} }
/// Auto-install mode: let botserver bootstrap all services #[must_use]
/// No need for pre-installed PostgreSQL binaries pub const fn auto_install() -> Self {
pub fn auto_install() -> Self {
Self { Self {
postgres: false, // Botserver will install PostgreSQL postgres: false,
minio: false, // Botserver will install MinIO minio: false,
redis: false, // Botserver will install Redis redis: false,
mock_zitadel: true, mock_zitadel: true,
mock_llm: 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 { Self {
postgres: true, postgres: true,
run_migrations: 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 { Self {
postgres: false, postgres: false,
minio: false, minio: false,
@ -116,29 +119,22 @@ pub struct TestContext {
} }
impl TestContext { impl TestContext {
pub fn test_id(&self) -> Uuid { pub const fn test_id(&self) -> Uuid {
self.test_id self.test_id
} }
pub fn database_url(&self) -> String { pub fn database_url(&self) -> String {
if self.use_existing_stack { 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 host = std::env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DB_PORT") let port = std::env::var("DB_PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(DefaultPorts::POSTGRES); .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 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 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()); let database = std::env::var("DB_NAME").unwrap_or_else(|_| "botserver".to_string());
format!( format!("postgres://{user}:{password}@{host}:{port}/{database}")
"postgres://{}:{}@{}:{}/{}",
user, password, host, port, database
)
} else { } else {
// For test-managed postgres, use test credentials
format!( format!(
"postgres://bottest:bottest@127.0.0.1:{}/bottest", "postgres://bottest:bottest@127.0.0.1:{}/bottest",
self.ports.postgres self.ports.postgres
@ -181,28 +177,28 @@ impl TestContext {
Pool::builder() Pool::builder()
.max_size(5) .max_size(5)
.build(manager) .build(manager)
.map_err(|e| anyhow::anyhow!("Failed to create pool: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to create pool: {e}"))
}) })
.await .await
} }
pub fn mock_zitadel(&self) -> Option<&MockZitadel> { pub const fn mock_zitadel(&self) -> Option<&MockZitadel> {
self.mock_zitadel.as_ref() 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() self.mock_llm.as_ref()
} }
pub fn postgres(&self) -> Option<&PostgresService> { pub const fn postgres(&self) -> Option<&PostgresService> {
self.postgres.as_ref() self.postgres.as_ref()
} }
pub fn minio(&self) -> Option<&MinioService> { pub const fn minio(&self) -> Option<&MinioService> {
self.minio.as_ref() self.minio.as_ref()
} }
pub fn redis(&self) -> Option<&RedisService> { pub const fn redis(&self) -> Option<&RedisService> {
self.redis.as_ref() self.redis.as_ref()
} }
@ -452,11 +448,11 @@ pub struct BotServerInstance {
} }
impl BotServerInstance { impl BotServerInstance {
/// Create an instance pointing to an already-running botserver #[must_use]
pub fn existing(url: &str) -> Self { pub fn existing(url: &str) -> Self {
let port = url let port = url
.split(':') .split(':')
.last() .next_back()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(8080); .unwrap_or(8080);
Self { 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> { pub async fn start_with_main_stack() -> Result<Self> {
let port = 8080; let port = 8080;
let url = "https://localhost:8080".to_string(); let url = "https://localhost:8080".to_string();
@ -477,23 +470,20 @@ impl BotServerInstance {
let botserver_bin = std::env::var("BOTSERVER_BIN") let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string()); .unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
// Check if binary exists
if !PathBuf::from(&botserver_bin).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!( anyhow::bail!(
"Botserver binary not found at: {}. Run: cd ../botserver && cargo build", "Botserver binary not found at: {botserver_bin}. Run: cd ../botserver && cargo build"
botserver_bin
); );
} }
// Get absolute path to botserver directory (where botserver-stack lives)
let botserver_bin_path = let botserver_bin_path =
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin)); std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
let botserver_dir = botserver_bin_path let botserver_dir = botserver_bin_path
.parent() // target/debug .parent()
.and_then(|p| p.parent()) // target .and_then(|p| p.parent())
.and_then(|p| p.parent()) // botserver .and_then(|p| p.parent())
.map(|p| p.to_path_buf()) .map(std::path::Path::to_path_buf)
.unwrap_or_else(|| { .unwrap_or_else(|| {
std::fs::canonicalize("../botserver") std::fs::canonicalize("../botserver")
.unwrap_or_else(|_| PathBuf::from("../botserver")) .unwrap_or_else(|_| PathBuf::from("../botserver"))
@ -501,22 +491,21 @@ impl BotServerInstance {
let stack_path = botserver_dir.join("botserver-stack"); let stack_path = botserver_dir.join("botserver-stack");
// Check if main stack exists
if !stack_path.exists() { if !stack_path.exists() {
anyhow::bail!( 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", 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!("🚀 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) let process = std::process::Command::new(&botserver_bin_path)
.current_dir(&botserver_dir) .current_dir(&botserver_dir)
.arg("--noconsole") .arg("--noconsole")
@ -527,9 +516,8 @@ impl BotServerInstance {
.ok(); .ok();
if process.is_some() { if process.is_some() {
// Wait for botserver to be ready (may take time for LLM to load) let max_wait = 120;
let max_wait = 120; // 2 minutes for LLM log::info!("Waiting for botserver to start (max {max_wait}s)...");
log::info!("Waiting for botserver to start (max {}s)...", max_wait);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
@ -538,10 +526,10 @@ impl BotServerInstance {
.unwrap_or_default(); .unwrap_or_default();
for i in 0..max_wait { 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() { if resp.status().is_success() {
log::info!("Botserver ready on port {}", port); log::info!("Botserver ready on port {port}");
println!(" ✓ BotServer ready at {}", url); println!(" ✓ BotServer ready at {url}");
return Ok(Self { return Ok(Self {
url, url,
port, port,
@ -551,8 +539,8 @@ impl BotServerInstance {
} }
} }
if i % 10 == 0 && i > 0 { if i % 10 == 0 && i > 0 {
log::info!("Still waiting for botserver... ({}s)", i); log::info!("Still waiting for botserver... ({i}s)");
println!(" ... waiting ({}s)", i); println!(" ... waiting ({i}s)");
} }
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
} }
@ -576,11 +564,11 @@ pub struct BotUIInstance {
} }
impl BotUIInstance { impl BotUIInstance {
/// Create an instance pointing to an already-running botui #[must_use]
pub fn existing(url: &str) -> Self { pub fn existing(url: &str) -> Self {
let port = url let port = url
.split(':') .split(':')
.last() .next_back()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(3000); .unwrap_or(3000);
Self { Self {
@ -594,14 +582,13 @@ impl BotUIInstance {
impl BotUIInstance { impl BotUIInstance {
pub async fn start(ctx: &TestContext, botserver_url: &str) -> Result<Self> { pub async fn start(ctx: &TestContext, botserver_url: &str) -> Result<Self> {
let port = crate::ports::PortAllocator::allocate(); 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") let botui_bin = std::env::var("BOTUI_BIN")
.unwrap_or_else(|_| "../botui/target/debug/botui".to_string()); .unwrap_or_else(|_| "../botui/target/debug/botui".to_string());
// Check if binary exists
if !PathBuf::from(&botui_bin).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 { return Ok(Self {
url, url,
port, 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 = let botui_bin_path =
std::fs::canonicalize(&botui_bin).unwrap_or_else(|_| PathBuf::from(&botui_bin)); std::fs::canonicalize(&botui_bin).unwrap_or_else(|_| PathBuf::from(&botui_bin));
let botui_dir = botui_bin_path let botui_dir = botui_bin_path
.parent() // target/debug .parent()
.and_then(|p| p.parent()) // target .and_then(|p| p.parent())
.and_then(|p| p.parent()) // botui .and_then(|p| p.parent())
.map(|p| p.to_path_buf()) .map(std::path::Path::to_path_buf)
.unwrap_or_else(|| { .unwrap_or_else(|| {
std::fs::canonicalize("../botui").unwrap_or_else(|_| PathBuf::from("../botui")) std::fs::canonicalize("../botui").unwrap_or_else(|_| PathBuf::from("../botui"))
}); });
log::info!("Starting botui from: {} on port {}", botui_bin, port); log::info!("Starting botui from: {botui_bin} on port {port}");
log::info!(" BOTUI_PORT={}", port); log::info!(" BOTUI_PORT={port}");
log::info!(" BOTSERVER_URL={}", botserver_url); log::info!(" BOTSERVER_URL={botserver_url}");
log::info!(" Working directory: {:?}", botui_dir); 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) let process = std::process::Command::new(&botui_bin_path)
.current_dir(&botui_dir) .current_dir(&botui_dir)
.env("BOTUI_PORT", port.to_string()) .env("BOTUI_PORT", port.to_string())
@ -640,25 +623,23 @@ impl BotUIInstance {
.ok(); .ok();
if process.is_some() { if process.is_some() {
// Wait for botui to be ready
let max_wait = 30; 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 { 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() { 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 }); 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 let Ok(resp) = reqwest::get(&url).await {
if resp.status().is_success() { 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 }); return Ok(Self { url, port, process });
} }
} }
if i % 5 == 0 { 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; 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() self.process.is_some()
} }
} }
@ -687,24 +669,20 @@ impl Drop for BotUIInstance {
} }
impl BotServerInstance { impl BotServerInstance {
/// Start botserver, creating a fresh stack from scratch for testing
pub async fn start(ctx: &TestContext) -> Result<Self> { pub async fn start(ctx: &TestContext) -> Result<Self> {
let port = ctx.ports.botserver; 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"); let stack_path = ctx.data_dir.join("botserver-stack");
std::fs::create_dir_all(&stack_path)?; std::fs::create_dir_all(&stack_path)?;
let stack_path = stack_path.canonicalize().unwrap_or(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") let botserver_bin = std::env::var("BOTSERVER_BIN")
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string()); .unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
// Check if binary exists
if !PathBuf::from(&botserver_bin).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 { return Ok(Self {
url, url,
port, 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 = let botserver_bin_path =
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin)); std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
let botserver_dir = botserver_bin_path let botserver_dir = botserver_bin_path
.parent() // target/release .parent()
.and_then(|p| p.parent()) // target .and_then(|p| p.parent())
.and_then(|p| p.parent()) // botserver .and_then(|p| p.parent())
.map(|p| p.to_path_buf()) .map(std::path::Path::to_path_buf)
.unwrap_or_else(|| { .unwrap_or_else(|| {
std::fs::canonicalize("../botserver") std::fs::canonicalize("../botserver")
.unwrap_or_else(|_| PathBuf::from("../botserver")) .unwrap_or_else(|_| PathBuf::from("../botserver"))
}); });
log::info!("Botserver working directory: {:?}", botserver_dir); log::info!("Botserver working directory: {}", botserver_dir.display());
log::info!("Stack path (absolute): {:?}", stack_path); 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 = botserver_dir.join("botserver-installers");
let installers_path = installers_path.canonicalize().unwrap_or(installers_path); 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) 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(&stack_path) .arg(&stack_path)
.arg("--port") .arg("--port")
.arg(port.to_string()) .arg(port.to_string())
.arg("--noconsole") .arg("--noconsole")
.env_remove("RUST_LOG") // Remove to avoid logger conflict .env_remove("RUST_LOG")
// Use local installers - DO NOT download
.env("BOTSERVER_INSTALLERS_PATH", &installers_path) .env("BOTSERVER_INSTALLERS_PATH", &installers_path)
// Database - DATABASE_URL is the standard fallback
.env("DATABASE_URL", ctx.database_url()) .env("DATABASE_URL", ctx.database_url())
// Directory (Zitadel) - use SecretsManager fallback env vars
.env("DIRECTORY_URL", ctx.zitadel_url()) .env("DIRECTORY_URL", ctx.zitadel_url())
.env("ZITADEL_CLIENT_ID", "test-client-id") .env("ZITADEL_CLIENT_ID", "test-client-id")
.env("ZITADEL_CLIENT_SECRET", "test-client-secret") .env("ZITADEL_CLIENT_SECRET", "test-client-secret")
// Drive (MinIO) - use SecretsManager fallback env vars
.env("DRIVE_ACCESSKEY", "minioadmin") .env("DRIVE_ACCESSKEY", "minioadmin")
.env("DRIVE_SECRET", "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()) .stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit())
.spawn() .spawn()
.ok(); .ok();
if process.is_some() { if process.is_some() {
// Give time for botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
let max_wait = 600; let max_wait = 600;
log::info!( log::info!("Waiting for botserver to bootstrap and become ready... (max {max_wait}s)");
"Waiting for botserver to bootstrap and become ready... (max {}s)",
max_wait
);
// Give more time for botserver to bootstrap services
for i in 0..max_wait { 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() { if resp.status().is_success() {
log::info!("Botserver is ready on port {}", port); log::info!("Botserver is ready on port {port}");
return Ok(Self { return Ok(Self {
url, url,
port, port,
@ -792,7 +748,7 @@ impl BotServerInstance {
} }
} }
if i % 10 == 0 { 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; 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() self.process.is_some()
} }
/// Setup minimal config files so botserver thinks services are configured fn setup_test_stack_config(stack_path: &std::path::Path, ctx: &TestContext) -> Result<()> {
fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> {
// Create directory config path
let directory_conf = stack_path.join("conf/directory"); let directory_conf = stack_path.join("conf/directory");
std::fs::create_dir_all(&directory_conf)?; std::fs::create_dir_all(&directory_conf)?;
// Create zitadel.yaml pointing to our mock Zitadel
let zitadel_config = format!( let zitadel_config = format!(
r#"Log: r#"Log:
Level: info Level: info
@ -842,27 +796,22 @@ ExternalPort: {}
std::fs::write(directory_conf.join("zitadel.yaml"), zitadel_config)?; std::fs::write(directory_conf.join("zitadel.yaml"), zitadel_config)?;
log::info!("Created test zitadel.yaml config"); log::info!("Created test zitadel.yaml config");
// Create system certificates directory
let certs_dir = stack_path.join("conf/system/certificates"); let certs_dir = stack_path.join("conf/system/certificates");
std::fs::create_dir_all(&certs_dir)?; std::fs::create_dir_all(&certs_dir)?;
// Generate minimal self-signed certificates for API
Self::generate_test_certificates(&certs_dir)?; Self::generate_test_certificates(&certs_dir)?;
Ok(()) Ok(())
} }
/// Generate minimal test certificates fn generate_test_certificates(certs_dir: &std::path::Path) -> Result<()> {
fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> {
use std::process::Command; use std::process::Command;
let api_dir = certs_dir.join("api"); let api_dir = certs_dir.join("api");
std::fs::create_dir_all(&api_dir)?; std::fs::create_dir_all(&api_dir)?;
// Check if openssl is available
let openssl_check = Command::new("which").arg("openssl").output(); let openssl_check = Command::new("which").arg("openssl").output();
if openssl_check.map(|o| o.status.success()).unwrap_or(false) { 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 key_path = api_dir.join("server.key");
let cert_path = api_dir.join("server.crt"); let cert_path = api_dir.join("server.crt");
@ -914,12 +863,9 @@ impl TestHarness {
Self::setup_internal(TestConfig::use_existing_stack(), true).await 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() { fn cleanup_existing_processes() {
log::info!("Cleaning up any existing stack processes before test..."); log::info!("Cleaning up any existing stack processes before test...");
// List of process patterns to kill
let patterns = [ let patterns = [
"botserver", "botserver",
"botui", "botui",
@ -936,24 +882,19 @@ impl TestHarness {
]; ];
for pattern in patterns { for pattern in patterns {
// Use pkill to kill processes matching pattern
// Ignore errors - process might not exist
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill")
.args(["-9", "-f", pattern]) .args(["-9", "-f", pattern])
.output(); .output();
} }
// Clean up browser profile directories using shell rm
let _ = std::process::Command::new("rm") let _ = std::process::Command::new("rm")
.args(["-rf", "/tmp/browser-test-*"]) .args(["-rf", "/tmp/browser-test-*"])
.output(); .output();
// Clean up old test data directories (older than 1 hour)
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.args(["-c", "find ./tmp -maxdepth 1 -name 'bottest-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null"]) .args(["-c", "find ./tmp -maxdepth 1 -name 'bottest-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null"])
.output(); .output();
// Give processes time to terminate
std::thread::sleep(std::time::Duration::from_millis(1000)); std::thread::sleep(std::time::Duration::from_millis(1000));
log::info!("Process cleanup completed"); log::info!("Process cleanup completed");
@ -962,14 +903,12 @@ impl TestHarness {
async fn setup_internal(config: TestConfig, use_existing_stack: bool) -> Result<TestContext> { async fn setup_internal(config: TestConfig, use_existing_stack: bool) -> Result<TestContext> {
let _ = env_logger::builder().is_test(true).try_init(); 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 { if !use_existing_stack {
Self::cleanup_existing_processes(); Self::cleanup_existing_processes();
} }
let test_id = Uuid::new_v4(); 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)?; std::fs::create_dir_all(&data_dir)?;
@ -987,11 +926,8 @@ impl TestHarness {
}; };
log::info!( log::info!(
"Test {} allocated ports: {:?}, data_dir: {:?}, use_existing_stack: {}", "Test {test_id} allocated ports: {ports:?}, data_dir: {}, use_existing_stack: {use_existing_stack}",
test_id, data_dir.display()
ports,
data_dir,
use_existing_stack
); );
let data_dir_str = data_dir.to_str().unwrap().to_string(); 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); log::info!("Starting PostgreSQL on port {}...", ctx.ports.postgres);
let pg = PostgresService::start(ctx.ports.postgres, &data_dir_str).await?; let pg = PostgresService::start(ctx.ports.postgres, &data_dir_str).await?;
if config.run_migrations { if config.run_migrations {
pg.run_migrations().await?; pg.run_migrations()?;
} }
ctx.postgres = Some(pg); ctx.postgres = Some(pg);
} }
@ -1050,11 +986,7 @@ impl TestHarness {
Self::setup(TestConfig::default()).await 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> { 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() { if std::env::var("FRESH_STACK").is_ok() {
Self::setup(TestConfig::full()).await Self::setup(TestConfig::full()).await
} else { } else {
@ -1062,7 +994,6 @@ impl TestHarness {
} }
} }
/// Setup with botserver auto-installing all services
pub async fn with_auto_install() -> Result<TestContext> { pub async fn with_auto_install() -> Result<TestContext> {
Self::setup(TestConfig::auto_install()).await Self::setup(TestConfig::auto_install()).await
} }
@ -1101,12 +1032,12 @@ mod tests {
#[test] #[test]
fn test_config_full() { fn test_config_full() {
let config = TestConfig::full(); let config = TestConfig::full();
assert!(!config.postgres); // Botserver handles PostgreSQL assert!(!config.postgres);
assert!(!config.minio); // Botserver handles MinIO assert!(!config.minio);
assert!(!config.redis); // Botserver handles Redis assert!(!config.redis);
assert!(config.mock_zitadel); assert!(config.mock_zitadel);
assert!(config.mock_llm); assert!(config.mock_llm);
assert!(!config.run_migrations); // Botserver handles migrations assert!(!config.run_migrations);
} }
#[test] #[test]

View file

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

View file

@ -1,6 +1,7 @@
use super::{new_expectation_store, Expectation, ExpectationStore}; use super::{new_expectation_store, Expectation, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@ -89,6 +90,7 @@ struct ChatChoice {
} }
#[derive(Serialize)] #[derive(Serialize)]
#[allow(clippy::struct_field_names)]
struct Usage { struct Usage {
prompt_tokens: u32, prompt_tokens: u32,
completion_tokens: u32, completion_tokens: u32,
@ -167,7 +169,7 @@ struct ErrorDetail {
impl MockLLM { impl MockLLM {
pub async fn start(port: u16) -> Result<Self> { 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")?; .context("Failed to bind MockLLM port")?;
let server = MockServer::builder().listener(listener).start().await; let server = MockServer::builder().listener(listener).start().await;
@ -219,11 +221,13 @@ impl MockLLM {
.unwrap() .unwrap()
.push(expectation.clone()); .push(expectation.clone());
{
let mut store = self.expectations.lock().unwrap(); let mut store = self.expectations.lock().unwrap();
store.insert( store.insert(
format!("completion:{}", prompt_contains), format!("completion:{prompt_contains}"),
Expectation::new(&format!("completion containing '{}'", prompt_contains)), Expectation::new(&format!("completion containing '{prompt_contains}'")),
); );
}
let response_text = response.to_string(); let response_text = response.to_string();
let model = self.default_model.clone(); let model = self.default_model.clone();
@ -253,7 +257,8 @@ impl MockLLM {
let mut template = ResponseTemplate::new(200).set_body_json(&response_body); 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); template = template.set_delay(delay);
} }
@ -274,7 +279,7 @@ impl MockLLM {
prompt_contains: Some(prompt_contains.to_string()), prompt_contains: Some(prompt_contains.to_string()),
response: chunks.join(""), response: chunks.join(""),
stream: true, stream: true,
chunks: chunks.iter().map(|s| s.to_string()).collect(), chunks: chunks.iter().map(|s| (*s).to_string()).collect(),
tool_calls: Vec::new(), tool_calls: Vec::new(),
}; };
@ -303,10 +308,11 @@ impl MockLLM {
finish_reason: None, finish_reason: None,
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&first_chunk).unwrap() serde_json::to_string(&first_chunk).unwrap()
)); );
for chunk_text in &chunks { for chunk_text in &chunks {
let chunk = StreamChunk { let chunk = StreamChunk {
@ -318,15 +324,16 @@ impl MockLLM {
index: 0, index: 0,
delta: StreamDelta { delta: StreamDelta {
role: None, role: None,
content: Some(chunk_text.to_string()), content: Some((*chunk_text).to_string()),
}, },
finish_reason: None, finish_reason: None,
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&chunk).unwrap() serde_json::to_string(&chunk).unwrap()
)); );
} }
let final_chunk = StreamChunk { let final_chunk = StreamChunk {
@ -343,10 +350,11 @@ impl MockLLM {
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
}], }],
}; };
sse_body.push_str(&format!( let _ = writeln!(
"data: {}\n\n", sse_body,
"data: {}\n",
serde_json::to_string(&final_chunk).unwrap() serde_json::to_string(&final_chunk).unwrap()
)); );
sse_body.push_str("data: [DONE]\n\n"); sse_body.push_str("data: [DONE]\n\n");
let template = ResponseTemplate::new(200) let template = ResponseTemplate::new(200)
@ -470,7 +478,7 @@ impl MockLLM {
error: ErrorDetail { error: ErrorDetail {
message: message.to_string(), message: message.to_string(),
r#type: "error".to_string(), r#type: "error".to_string(),
code: format!("error_{}", status), code: format!("error_{status}"),
}, },
}; };
@ -563,11 +571,13 @@ impl MockLLM {
.await; .await;
} }
#[must_use]
pub fn url(&self) -> String { pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port) format!("http://127.0.0.1:{}", self.port)
} }
pub fn port(&self) -> u16 { #[must_use]
pub const fn port(&self) -> u16 {
self.port self.port
} }
@ -594,19 +604,14 @@ impl MockLLM {
} }
pub async fn call_count(&self) -> usize { pub async fn call_count(&self) -> usize {
self.server self.server.received_requests().await.map_or(0, |r| r.len())
.received_requests()
.await
.map(|r| r.len())
.unwrap_or(0)
} }
pub async fn assert_called_times(&self, expected: usize) { pub async fn assert_called_times(&self, expected: usize) {
let actual = self.call_count().await; let actual = self.call_count().await;
assert_eq!( assert_eq!(
actual, expected, actual, expected,
"Expected {} calls to MockLLM, but got {}", "Expected {expected} calls to MockLLM, but got {actual}"
expected, actual
); );
} }
@ -620,7 +625,7 @@ impl MockLLM {
pub async fn assert_not_called(&self) { pub async fn assert_not_called(&self) {
let count = self.call_count().await; 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 { let response = ChatCompletionResponse {
id: "test-id".to_string(), id: "test-id".to_string(),
object: "chat.completion".to_string(), object: "chat.completion".to_string(),
created: 1234567890, created: 1_234_567_890,
model: "gpt-4".to_string(), model: "gpt-4".to_string(),
choices: vec![ChatChoice { choices: vec![ChatChoice {
index: 0, 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 llm;
mod teams; mod teams;
@ -20,7 +13,6 @@ use anyhow::Result;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
/// Registry of all mock servers for a test
pub struct MockRegistry { pub struct MockRegistry {
pub llm: Option<MockLLM>, pub llm: Option<MockLLM>,
pub whatsapp: Option<MockWhatsApp>, pub whatsapp: Option<MockWhatsApp>,
@ -29,8 +21,8 @@ pub struct MockRegistry {
} }
impl MockRegistry { impl MockRegistry {
/// Create an empty registry #[must_use]
pub fn new() -> Self { pub const fn new() -> Self {
Self { Self {
llm: None, llm: None,
whatsapp: None, whatsapp: None,
@ -39,27 +31,26 @@ impl MockRegistry {
} }
} }
/// Get the LLM mock, panics if not configured #[must_use]
pub fn llm(&self) -> &MockLLM { pub const fn llm(&self) -> &MockLLM {
self.llm.as_ref().expect("LLM mock not configured") self.llm.as_ref().expect("LLM mock not configured")
} }
/// Get the WhatsApp mock, panics if not configured #[must_use]
pub fn whatsapp(&self) -> &MockWhatsApp { pub const fn whatsapp(&self) -> &MockWhatsApp {
self.whatsapp.as_ref().expect("WhatsApp mock not configured") self.whatsapp.as_ref().expect("WhatsApp mock not configured")
} }
/// Get the Teams mock, panics if not configured #[must_use]
pub fn teams(&self) -> &MockTeams { pub const fn teams(&self) -> &MockTeams {
self.teams.as_ref().expect("Teams mock not configured") self.teams.as_ref().expect("Teams mock not configured")
} }
/// Get the Zitadel mock, panics if not configured #[must_use]
pub fn zitadel(&self) -> &MockZitadel { pub const fn zitadel(&self) -> &MockZitadel {
self.zitadel.as_ref().expect("Zitadel mock not configured") self.zitadel.as_ref().expect("Zitadel mock not configured")
} }
/// Verify all mock expectations were met
pub fn verify_all(&self) -> Result<()> { pub fn verify_all(&self) -> Result<()> {
if let Some(ref llm) = self.llm { if let Some(ref llm) = self.llm {
llm.verify()?; llm.verify()?;
@ -76,7 +67,6 @@ impl MockRegistry {
Ok(()) Ok(())
} }
/// Reset all mock servers
pub async fn reset_all(&self) { pub async fn reset_all(&self) {
if let Some(ref llm) = self.llm { if let Some(ref llm) = self.llm {
llm.reset().await; llm.reset().await;
@ -99,7 +89,6 @@ impl Default for MockRegistry {
} }
} }
/// Expectation tracking for mock verification
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Expectation { pub struct Expectation {
pub name: String, pub name: String,
@ -109,6 +98,7 @@ pub struct Expectation {
} }
impl Expectation { impl Expectation {
#[must_use]
pub fn new(name: &str) -> Self { pub fn new(name: &str) -> Self {
Self { Self {
name: name.to_string(), 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.expected_calls = Some(n);
self self
} }
pub fn record_call(&mut self) { pub const fn record_call(&mut self) {
self.actual_calls += 1; self.actual_calls += 1;
self.matched = true; 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>>>; pub type ExpectationStore = Arc<Mutex<HashMap<String, Expectation>>>;
/// Create a new expectation store #[must_use]
pub fn new_expectation_store() -> ExpectationStore { pub fn new_expectation_store() -> ExpectationStore {
Arc::new(Mutex::new(HashMap::new())) 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 super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,7 +7,6 @@ use uuid::Uuid;
use wiremock::matchers::{method, path, path_regex}; use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock Teams Bot Framework server
pub struct MockTeams { pub struct MockTeams {
server: MockServer, server: MockServer,
port: u16, port: u16,
@ -25,9 +19,9 @@ pub struct MockTeams {
service_url: String, service_url: String,
} }
/// Bot Framework Activity
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Activity { pub struct Activity {
#[serde(rename = "type")] #[serde(rename = "type")]
pub activity_type: String, pub activity_type: String,
@ -88,7 +82,6 @@ impl Default for Activity {
} }
} }
/// Channel account (user or bot)
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ChannelAccount { pub struct ChannelAccount {
@ -101,7 +94,6 @@ pub struct ChannelAccount {
pub role: Option<String>, pub role: Option<String>,
} }
/// Conversation account
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ConversationAccount { pub struct ConversationAccount {
@ -116,7 +108,6 @@ pub struct ConversationAccount {
pub tenant_id: Option<String>, pub tenant_id: Option<String>,
} }
/// Attachment in an activity
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Attachment { pub struct Attachment {
@ -131,9 +122,9 @@ pub struct Attachment {
pub thumbnail_url: Option<String>, pub thumbnail_url: Option<String>,
} }
/// Entity in an activity (mentions, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(clippy::struct_field_names)]
pub struct Entity { pub struct Entity {
#[serde(rename = "type")] #[serde(rename = "type")]
pub entity_type: String, pub entity_type: String,
@ -145,7 +136,6 @@ pub struct Entity {
pub additional: HashMap<String, serde_json::Value>, pub additional: HashMap<String, serde_json::Value>,
} }
/// Conversation information stored by the mock
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConversationInfo { pub struct ConversationInfo {
pub id: String, pub id: String,
@ -155,13 +145,11 @@ pub struct ConversationInfo {
pub is_group: bool, pub is_group: bool,
} }
/// Resource response from sending an activity
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ResourceResponse { pub struct ResourceResponse {
pub id: String, pub id: String,
} }
/// Conversations result
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ConversationsResult { pub struct ConversationsResult {
@ -170,14 +158,12 @@ pub struct ConversationsResult {
pub conversations: Vec<ConversationMembers>, pub conversations: Vec<ConversationMembers>,
} }
/// Conversation members
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ConversationMembers { pub struct ConversationMembers {
pub id: String, pub id: String,
pub members: Vec<ChannelAccount>, pub members: Vec<ChannelAccount>,
} }
/// Teams channel account (extended)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TeamsChannelAccount { pub struct TeamsChannelAccount {
@ -198,7 +184,6 @@ pub struct TeamsChannelAccount {
pub surname: Option<String>, pub surname: Option<String>,
} }
/// Teams meeting info
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct TeamsMeetingInfo { pub struct TeamsMeetingInfo {
@ -209,7 +194,6 @@ pub struct TeamsMeetingInfo {
pub title: Option<String>, pub title: Option<String>,
} }
/// Adaptive card action response
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AdaptiveCardInvokeResponse { pub struct AdaptiveCardInvokeResponse {
@ -220,20 +204,17 @@ pub struct AdaptiveCardInvokeResponse {
pub value: Option<serde_json::Value>, pub value: Option<serde_json::Value>,
} }
/// Error response
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ErrorResponse { pub struct ErrorResponse {
pub error: ErrorBody, pub error: ErrorBody,
} }
/// Error body
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct ErrorBody { pub struct ErrorBody {
pub code: String, pub code: String,
pub message: String, pub message: String,
} }
/// Invoke response for bot actions
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InvokeResponse { pub struct InvokeResponse {
@ -243,22 +224,18 @@ pub struct InvokeResponse {
} }
impl MockTeams { impl MockTeams {
/// Default bot ID for testing
pub const DEFAULT_BOT_ID: &'static str = "28:test-bot-id"; pub const DEFAULT_BOT_ID: &'static str = "28:test-bot-id";
/// Default bot name
pub const DEFAULT_BOT_NAME: &'static str = "TestBot"; pub const DEFAULT_BOT_NAME: &'static str = "TestBot";
/// Default tenant ID
pub const DEFAULT_TENANT_ID: &'static str = "test-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> { 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")?; .context("Failed to bind MockTeams port")?;
let server = MockServer::builder().listener(listener).start().await; 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 { let mock = Self {
server, server,
@ -277,18 +254,17 @@ impl MockTeams {
Ok(mock) Ok(mock)
} }
/// Start with custom bot configuration
pub async fn start_with_config( pub async fn start_with_config(
port: u16, port: u16,
bot_id: &str, bot_id: &str,
bot_name: &str, bot_name: &str,
tenant_id: &str, tenant_id: &str,
) -> Result<Self> { ) -> 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")?; .context("Failed to bind MockTeams port")?;
let server = MockServer::builder().listener(listener).start().await; 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 { let mock = Self {
server, server,
@ -307,11 +283,9 @@ impl MockTeams {
Ok(mock) Ok(mock)
} }
/// Set up default API routes
async fn setup_default_routes(&self) { async fn setup_default_routes(&self) {
let sent_activities = self.sent_activities.clone(); let sent_activities = self.sent_activities.clone();
// Send to conversation endpoint
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path_regex(r"/v3/conversations/.+/activities")) .and(path_regex(r"/v3/conversations/.+/activities"))
.respond_with(move |req: &wiremock::Request| { .respond_with(move |req: &wiremock::Request| {
@ -345,16 +319,13 @@ impl MockTeams {
sent_activities.lock().unwrap().push(activity.clone()); sent_activities.lock().unwrap().push(activity.clone());
let response = ResourceResponse { let response = ResourceResponse { id: activity.id };
id: activity.id.clone(),
};
ResponseTemplate::new(200).set_body_json(&response) ResponseTemplate::new(200).set_body_json(&response)
}) })
.mount(&self.server) .mount(&self.server)
.await; .await;
// Reply to activity endpoint
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path_regex(r"/v3/conversations/.+/activities/.+")) .and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -366,7 +337,6 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Update activity endpoint
Mock::given(method("PUT")) Mock::given(method("PUT"))
.and(path_regex(r"/v3/conversations/.+/activities/.+")) .and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -378,14 +348,12 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Delete activity endpoint
Mock::given(method("DELETE")) Mock::given(method("DELETE"))
.and(path_regex(r"/v3/conversations/.+/activities/.+")) .and(path_regex(r"/v3/conversations/.+/activities/.+"))
.respond_with(ResponseTemplate::new(200)) .respond_with(ResponseTemplate::new(200))
.mount(&self.server) .mount(&self.server)
.await; .await;
// Get conversation members endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/v3/conversations/.+/members")) .and(path_regex(r"/v3/conversations/.+/members"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -404,7 +372,6 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Get single member endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/v3/conversations/.+/members/.+")) .and(path_regex(r"/v3/conversations/.+/members/.+"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -423,7 +390,6 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Create conversation endpoint
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/v3/conversations")) .and(path("/v3/conversations"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -439,7 +405,6 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Get conversations endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/v3/conversations")) .and(path("/v3/conversations"))
.respond_with(|_req: &wiremock::Request| { .respond_with(|_req: &wiremock::Request| {
@ -452,7 +417,6 @@ impl MockTeams {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Token endpoint (for bot authentication simulation)
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/botframework.com/oauth2/v2.0/token")) .and(path("/botframework.com/oauth2/v2.0/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -464,7 +428,7 @@ impl MockTeams {
.await; .await;
} }
/// Simulate an incoming message from a user #[must_use]
pub fn simulate_message(&self, from_id: &str, from_name: &str, text: &str) -> Activity { pub fn simulate_message(&self, from_id: &str, from_name: &str, text: &str) -> Activity {
let conversation_id = format!("conv-{}", Uuid::new_v4()); 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 { 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 mut activity = self.simulate_message(from_id, from_name, text);
let mention_text = format!("<at>{}</at>", self.bot_name); 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 { activity.entities = Some(vec![Entity {
entity_type: "mention".to_string(), entity_type: "mention".to_string(),
@ -533,7 +497,7 @@ impl MockTeams {
activity activity
} }
/// Simulate a conversation update (member added) #[must_use]
pub fn simulate_member_added(&self, member_id: &str, member_name: &str) -> Activity { pub fn simulate_member_added(&self, member_id: &str, member_name: &str) -> Activity {
let conversation_id = format!("conv-{}", Uuid::new_v4()); 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( pub fn simulate_invoke(
&self, &self,
from_id: &str, from_id: &str,
@ -634,7 +598,7 @@ impl MockTeams {
} }
} }
/// Simulate an adaptive card action #[must_use]
pub fn simulate_adaptive_card_action( pub fn simulate_adaptive_card_action(
&self, &self,
from_id: &str, from_id: &str,
@ -655,7 +619,7 @@ impl MockTeams {
) )
} }
/// Simulate a message reaction #[must_use]
pub fn simulate_reaction( pub fn simulate_reaction(
&self, &self,
from_id: &str, 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) { pub async fn expect_error(&self, code: &str, message: &str) {
let error_response = ErrorResponse { let error_response = ErrorResponse {
error: ErrorBody { error: ErrorBody {
@ -724,45 +687,41 @@ impl MockTeams {
.await; .await;
} }
/// Expect an unauthorized error
pub async fn expect_unauthorized(&self) { pub async fn expect_unauthorized(&self) {
self.expect_error("Unauthorized", "Token validation failed") self.expect_error("Unauthorized", "Token validation failed")
.await; .await;
} }
/// Expect a not found error
pub async fn expect_not_found(&self) { pub async fn expect_not_found(&self) {
self.expect_error("NotFound", "Conversation not found") self.expect_error("NotFound", "Conversation not found")
.await; .await;
} }
/// Get all sent activities #[must_use]
pub fn sent_activities(&self) -> Vec<Activity> { pub fn sent_activities(&self) -> Vec<Activity> {
self.sent_activities.lock().unwrap().clone() self.sent_activities.lock().unwrap().clone()
} }
/// Get sent activities with specific text #[must_use]
pub fn sent_activities_containing(&self, text: &str) -> Vec<Activity> { pub fn sent_activities_containing(&self, text: &str) -> Vec<Activity> {
self.sent_activities self.sent_activities
.lock() .lock()
.unwrap() .unwrap()
.iter() .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() .cloned()
.collect() .collect()
} }
/// Get the last sent activity #[must_use]
pub fn last_sent_activity(&self) -> Option<Activity> { pub fn last_sent_activity(&self) -> Option<Activity> {
self.sent_activities.lock().unwrap().last().cloned() self.sent_activities.lock().unwrap().last().cloned()
} }
/// Clear sent activities
pub fn clear_sent_activities(&self) { pub fn clear_sent_activities(&self) {
self.sent_activities.lock().unwrap().clear(); self.sent_activities.lock().unwrap().clear();
} }
/// Register a conversation
pub fn register_conversation(&self, info: ConversationInfo) { pub fn register_conversation(&self, info: ConversationInfo) {
self.conversations self.conversations
.lock() .lock()
@ -770,37 +729,36 @@ impl MockTeams {
.insert(info.id.clone(), info); .insert(info.id.clone(), info);
} }
/// Get the server URL #[must_use]
pub fn url(&self) -> String { pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port) format!("http://127.0.0.1:{}", self.port)
} }
/// Get the service URL (same as server URL) #[must_use]
pub fn service_url(&self) -> String { pub fn service_url(&self) -> String {
self.service_url.clone() self.service_url.clone()
} }
/// Get the port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Get the bot ID #[must_use]
pub fn bot_id(&self) -> &str { pub fn bot_id(&self) -> &str {
&self.bot_id &self.bot_id
} }
/// Get the bot name #[must_use]
pub fn bot_name(&self) -> &str { pub fn bot_name(&self) -> &str {
&self.bot_name &self.bot_name
} }
/// Get the tenant ID #[must_use]
pub fn tenant_id(&self) -> &str { pub fn tenant_id(&self) -> &str {
&self.tenant_id &self.tenant_id
} }
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> { pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap(); let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() { for (_, exp) in store.iter() {
@ -809,7 +767,6 @@ impl MockTeams {
Ok(()) Ok(())
} }
/// Reset all mocks
pub async fn reset(&self) { pub async fn reset(&self) {
self.server.reset().await; self.server.reset().await;
self.sent_activities.lock().unwrap().clear(); self.sent_activities.lock().unwrap().clear();
@ -818,13 +775,11 @@ impl MockTeams {
self.setup_default_routes().await; self.setup_default_routes().await;
} }
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> { pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default() self.server.received_requests().await.unwrap_or_default()
} }
} }
/// Helper to create an adaptive card attachment
pub fn adaptive_card(content: serde_json::Value) -> Attachment { pub fn adaptive_card(content: serde_json::Value) -> Attachment {
Attachment { Attachment {
content_type: "application/vnd.microsoft.card.adaptive".to_string(), 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 { pub fn hero_card(title: &str, subtitle: Option<&str>, text: Option<&str>) -> Attachment {
Attachment { Attachment {
content_type: "application/vnd.microsoft.card.hero".to_string(), 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( pub fn thumbnail_card(
title: &str, title: &str,
subtitle: Option<&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 super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -11,7 +6,6 @@ use uuid::Uuid;
use wiremock::matchers::{method, path_regex}; use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock WhatsApp Business API server
pub struct MockWhatsApp { pub struct MockWhatsApp {
server: MockServer, server: MockServer,
port: u16, port: u16,
@ -23,7 +17,6 @@ pub struct MockWhatsApp {
access_token: String, access_token: String,
} }
/// A message that was "sent" through the mock
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentMessage { pub struct SentMessage {
pub id: String, pub id: String,
@ -33,8 +26,7 @@ pub struct SentMessage {
pub timestamp: u64, pub timestamp: u64,
} }
/// Type of message #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum MessageType { pub enum MessageType {
Text, Text,
@ -49,7 +41,6 @@ pub enum MessageType {
Reaction, Reaction,
} }
/// Message content variants
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum MessageContent { pub enum MessageContent {
@ -80,28 +71,24 @@ pub enum MessageContent {
}, },
} }
/// Webhook event from WhatsApp
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEvent { pub struct WebhookEvent {
pub object: String, pub object: String,
pub entry: Vec<WebhookEntry>, pub entry: Vec<WebhookEntry>,
} }
/// Entry in a webhook event
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEntry { pub struct WebhookEntry {
pub id: String, pub id: String,
pub changes: Vec<WebhookChange>, pub changes: Vec<WebhookChange>,
} }
/// Change in a webhook entry
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookChange { pub struct WebhookChange {
pub value: WebhookValue, pub value: WebhookValue,
pub field: String, pub field: String,
} }
/// Value in a webhook change
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookValue { pub struct WebhookValue {
pub messaging_product: String, pub messaging_product: String,
@ -114,27 +101,23 @@ pub struct WebhookValue {
pub statuses: Option<Vec<MessageStatus>>, pub statuses: Option<Vec<MessageStatus>>,
} }
/// Metadata in webhook
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookMetadata { pub struct WebhookMetadata {
pub display_phone_number: String, pub display_phone_number: String,
pub phone_number_id: String, pub phone_number_id: String,
} }
/// Contact in webhook
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookContact { pub struct WebhookContact {
pub profile: ContactProfile, pub profile: ContactProfile,
pub wa_id: String, pub wa_id: String,
} }
/// Contact profile
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactProfile { pub struct ContactProfile {
pub name: String, pub name: String,
} }
/// Incoming message from webhook
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage { pub struct IncomingMessage {
pub from: String, pub from: String,
@ -154,13 +137,11 @@ pub struct IncomingMessage {
pub interactive: Option<InteractiveReply>, pub interactive: Option<InteractiveReply>,
} }
/// Text message content
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextMessage { pub struct TextMessage {
pub body: String, pub body: String,
} }
/// Media message content
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaMessage { pub struct MediaMessage {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -173,14 +154,12 @@ pub struct MediaMessage {
pub caption: Option<String>, pub caption: Option<String>,
} }
/// Button reply
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ButtonReply { pub struct ButtonReply {
pub payload: String, pub payload: String,
pub text: String, pub text: String,
} }
/// Interactive reply (list/button)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractiveReply { pub struct InteractiveReply {
#[serde(rename = "type")] #[serde(rename = "type")]
@ -191,14 +170,12 @@ pub struct InteractiveReply {
pub list_reply: Option<ListReplyContent>, pub list_reply: Option<ListReplyContent>,
} }
/// Button reply content
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ButtonReplyContent { pub struct ButtonReplyContent {
pub id: String, pub id: String,
pub title: String, pub title: String,
} }
/// List reply content
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListReplyContent { pub struct ListReplyContent {
pub id: String, pub id: String,
@ -207,7 +184,6 @@ pub struct ListReplyContent {
pub description: Option<String>, pub description: Option<String>,
} }
/// Message status update
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageStatus { pub struct MessageStatus {
pub id: String, pub id: String,
@ -220,7 +196,6 @@ pub struct MessageStatus {
pub pricing: Option<Pricing>, pub pricing: Option<Pricing>,
} }
/// Conversation info
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conversation { pub struct Conversation {
pub id: String, pub id: String,
@ -228,22 +203,20 @@ pub struct Conversation {
pub origin: Option<ConversationOrigin>, pub origin: Option<ConversationOrigin>,
} }
/// Conversation origin
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationOrigin { pub struct ConversationOrigin {
#[serde(rename = "type")] #[serde(rename = "type")]
pub origin_type: String, pub origin_type: String,
} }
/// Pricing info
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pricing { pub struct Pricing {
pub billable: bool, pub billable: bool,
pub pricing_model: String, #[serde(alias = "pricing_model")]
pub model: String,
pub category: String, pub category: String,
} }
/// Send message API request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SendMessageRequest { struct SendMessageRequest {
messaging_product: String, messaging_product: String,
@ -255,7 +228,6 @@ struct SendMessageRequest {
content: serde_json::Value, content: serde_json::Value,
} }
/// Send message API response
#[derive(Serialize)] #[derive(Serialize)]
struct SendMessageResponse { struct SendMessageResponse {
messaging_product: String, messaging_product: String,
@ -263,26 +235,22 @@ struct SendMessageResponse {
messages: Vec<MessageResponse>, messages: Vec<MessageResponse>,
} }
/// Contact in send response
#[derive(Serialize)] #[derive(Serialize)]
struct ContactResponse { struct ContactResponse {
input: String, input: String,
wa_id: String, wa_id: String,
} }
/// Message in send response
#[derive(Serialize)] #[derive(Serialize)]
struct MessageResponse { struct MessageResponse {
id: String, id: String,
} }
/// Error response
#[derive(Serialize)] #[derive(Serialize)]
struct ErrorResponse { struct ErrorResponse {
error: ErrorDetail, error: ErrorDetail,
} }
/// Error detail
#[derive(Serialize)] #[derive(Serialize)]
struct ErrorDetail { struct ErrorDetail {
message: String, message: String,
@ -292,7 +260,6 @@ struct ErrorDetail {
fbtrace_id: String, fbtrace_id: String,
} }
/// Expectation builder for message sending
pub struct MessageExpectation { pub struct MessageExpectation {
to: String, to: String,
message_type: Option<MessageType>, message_type: Option<MessageType>,
@ -300,20 +267,17 @@ pub struct MessageExpectation {
} }
impl MessageExpectation { impl MessageExpectation {
/// Expect a specific message type pub const fn of_type(mut self, t: MessageType) -> Self {
pub fn of_type(mut self, t: MessageType) -> Self {
self.message_type = Some(t); self.message_type = Some(t);
self self
} }
/// Expect message to contain specific text
pub fn containing(mut self, text: &str) -> Self { pub fn containing(mut self, text: &str) -> Self {
self.contains = Some(text.to_string()); self.contains = Some(text.to_string());
self self
} }
} }
/// Expectation builder for template messages
pub struct TemplateExpectation { pub struct TemplateExpectation {
name: String, name: String,
to: Option<String>, to: Option<String>,
@ -321,13 +285,11 @@ pub struct TemplateExpectation {
} }
impl TemplateExpectation { impl TemplateExpectation {
/// Expect template to be sent to specific number
pub fn to(mut self, phone: &str) -> Self { pub fn to(mut self, phone: &str) -> Self {
self.to = Some(phone.to_string()); self.to = Some(phone.to_string());
self self
} }
/// Expect template with specific language
pub fn with_language(mut self, lang: &str) -> Self { pub fn with_language(mut self, lang: &str) -> Self {
self.language = Some(lang.to_string()); self.language = Some(lang.to_string());
self self
@ -335,18 +297,14 @@ impl TemplateExpectation {
} }
impl MockWhatsApp { impl MockWhatsApp {
/// Default phone number ID for testing
pub const DEFAULT_PHONE_NUMBER_ID: &'static str = "123456789012345"; pub const DEFAULT_PHONE_NUMBER_ID: &'static str = "123456789012345";
/// Default business account ID
pub const DEFAULT_BUSINESS_ACCOUNT_ID: &'static str = "987654321098765"; pub const DEFAULT_BUSINESS_ACCOUNT_ID: &'static str = "987654321098765";
/// Default access token
pub const DEFAULT_ACCESS_TOKEN: &'static str = "test_access_token_12345"; 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> { 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")?; .context("Failed to bind MockWhatsApp port")?;
let server = MockServer::builder().listener(listener).start().await; let server = MockServer::builder().listener(listener).start().await;
@ -367,14 +325,13 @@ impl MockWhatsApp {
Ok(mock) Ok(mock)
} }
/// Start with custom configuration
pub async fn start_with_config( pub async fn start_with_config(
port: u16, port: u16,
phone_number_id: &str, phone_number_id: &str,
business_account_id: &str, business_account_id: &str,
access_token: &str, access_token: &str,
) -> Result<Self> { ) -> 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")?; .context("Failed to bind MockWhatsApp port")?;
let server = MockServer::builder().listener(listener).start().await; let server = MockServer::builder().listener(listener).start().await;
@ -395,11 +352,8 @@ impl MockWhatsApp {
Ok(mock) Ok(mock)
} }
/// Set up default API routes
async fn setup_default_routes(&self) { async fn setup_default_routes(&self) {
// Send message endpoint
let sent_messages = self.sent_messages.clone(); let sent_messages = self.sent_messages.clone();
let _phone_id = self.phone_number_id.clone();
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/messages")) .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 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 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() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@ -458,7 +412,6 @@ impl MockWhatsApp {
id: message_id.clone(), id: message_id.clone(),
to: to.to_string(), to: to.to_string(),
message_type: match msg_type { message_type: match msg_type {
"text" => MessageType::Text,
"template" => MessageType::Template, "template" => MessageType::Template,
"image" => MessageType::Image, "image" => MessageType::Image,
"document" => MessageType::Document, "document" => MessageType::Document,
@ -489,7 +442,6 @@ impl MockWhatsApp {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Media upload endpoint
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path_regex(r"/v\d+\.\d+/\d+/media")) .and(path_regex(r"/v\d+\.\d+/\d+/media"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -498,7 +450,6 @@ impl MockWhatsApp {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Media download endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/v\d+\.\d+/\d+")) .and(path_regex(r"/v\d+\.\d+/\d+"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -511,7 +462,6 @@ impl MockWhatsApp {
.mount(&self.server) .mount(&self.server)
.await; .await;
// Business profile endpoint
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path_regex(r"/v\d+\.\d+/\d+/whatsapp_business_profile")) .and(path_regex(r"/v\d+\.\d+/\d+/whatsapp_business_profile"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
@ -529,8 +479,9 @@ impl MockWhatsApp {
.await; .await;
} }
/// Expect a message to be sent to a specific number #[must_use]
pub fn expect_send_message(&self, to: &str) -> MessageExpectation { pub fn expect_send_message(&self, to: &str) -> MessageExpectation {
let _ = self;
MessageExpectation { MessageExpectation {
to: to.to_string(), to: to.to_string(),
message_type: None, 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 { pub fn expect_send_template(&self, name: &str) -> TemplateExpectation {
let _ = self;
TemplateExpectation { TemplateExpectation {
name: name.to_string(), name: name.to_string(),
to: None, to: None,
@ -547,9 +499,8 @@ impl MockWhatsApp {
} }
} }
/// Simulate an incoming message
pub fn simulate_incoming(&self, from: &str, text: &str) -> Result<WebhookEvent> { 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() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
@ -597,14 +548,13 @@ impl MockWhatsApp {
Ok(event) Ok(event)
} }
/// Simulate an incoming image message
pub fn simulate_incoming_image( pub fn simulate_incoming_image(
&self, &self,
from: &str, from: &str,
media_id: &str, media_id: &str,
caption: Option<&str>, caption: Option<&str>,
) -> Result<WebhookEvent> { ) -> 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() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
@ -638,7 +588,7 @@ impl MockWhatsApp {
id: Some(media_id.to_string()), id: Some(media_id.to_string()),
mime_type: Some("image/jpeg".to_string()), mime_type: Some("image/jpeg".to_string()),
sha256: Some("abc123".to_string()), sha256: Some("abc123".to_string()),
caption: caption.map(|c| c.to_string()), caption: caption.map(std::string::ToString::to_string),
}), }),
document: None, document: None,
button: None, button: None,
@ -655,14 +605,13 @@ impl MockWhatsApp {
Ok(event) Ok(event)
} }
/// Simulate a button reply
pub fn simulate_button_reply( pub fn simulate_button_reply(
&self, &self,
from: &str, from: &str,
button_id: &str, button_id: &str,
button_text: &str, button_text: &str,
) -> Result<WebhookEvent> { ) -> 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() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
@ -715,13 +664,11 @@ impl MockWhatsApp {
Ok(event) Ok(event)
} }
/// Simulate a webhook event
pub fn simulate_webhook(&self, event: WebhookEvent) -> Result<()> { pub fn simulate_webhook(&self, event: WebhookEvent) -> Result<()> {
self.received_webhooks.lock().unwrap().push(event); self.received_webhooks.lock().unwrap().push(event);
Ok(()) Ok(())
} }
/// Simulate a message status update
pub fn simulate_status( pub fn simulate_status(
&self, &self,
message_id: &str, message_id: &str,
@ -760,7 +707,7 @@ impl MockWhatsApp {
}), }),
pricing: Some(Pricing { pricing: Some(Pricing {
billable: true, billable: true,
pricing_model: "CBP".to_string(), model: "CBP".to_string(),
category: "business_initiated".to_string(), category: "business_initiated".to_string(),
}), }),
}]), }]),
@ -774,7 +721,6 @@ impl MockWhatsApp {
Ok(event) Ok(event)
} }
/// Expect an error response for the next request
pub async fn expect_error(&self, code: u32, message: &str) { pub async fn expect_error(&self, code: u32, message: &str) {
let error_response = ErrorResponse { let error_response = ErrorResponse {
error: ErrorDetail { error: ErrorDetail {
@ -792,12 +738,10 @@ impl MockWhatsApp {
.await; .await;
} }
/// Expect rate limit error
pub async fn expect_rate_limit(&self) { pub async fn expect_rate_limit(&self) {
self.expect_error(80007, "Rate limit hit").await; self.expect_error(80007, "Rate limit hit").await;
} }
/// Expect invalid token error
pub async fn expect_invalid_token(&self) { pub async fn expect_invalid_token(&self) {
let error_response = ErrorResponse { let error_response = ErrorResponse {
error: ErrorDetail { error: ErrorDetail {
@ -815,12 +759,12 @@ impl MockWhatsApp {
.await; .await;
} }
/// Get all sent messages #[must_use]
pub fn sent_messages(&self) -> Vec<SentMessage> { pub fn sent_messages(&self) -> Vec<SentMessage> {
self.sent_messages.lock().unwrap().clone() 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> { pub fn sent_messages_to(&self, phone: &str) -> Vec<SentMessage> {
self.sent_messages self.sent_messages
.lock() .lock()
@ -831,47 +775,45 @@ impl MockWhatsApp {
.collect() .collect()
} }
/// Get the last sent message #[must_use]
pub fn last_sent_message(&self) -> Option<SentMessage> { pub fn last_sent_message(&self) -> Option<SentMessage> {
self.sent_messages.lock().unwrap().last().cloned() self.sent_messages.lock().unwrap().last().cloned()
} }
/// Clear sent messages
pub fn clear_sent_messages(&self) { pub fn clear_sent_messages(&self) {
self.sent_messages.lock().unwrap().clear(); self.sent_messages.lock().unwrap().clear();
} }
/// Get the server URL #[must_use]
pub fn url(&self) -> String { pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port) format!("http://127.0.0.1:{}", self.port)
} }
/// Get the Graph API base URL #[must_use]
pub fn graph_api_url(&self) -> String { pub fn graph_api_url(&self) -> String {
format!("http://127.0.0.1:{}/v17.0", self.port) format!("http://127.0.0.1:{}/v17.0", self.port)
} }
/// Get the port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Get the phone number ID #[must_use]
pub fn phone_number_id(&self) -> &str { pub fn phone_number_id(&self) -> &str {
&self.phone_number_id &self.phone_number_id
} }
/// Get the business account ID #[must_use]
pub fn business_account_id(&self) -> &str { pub fn business_account_id(&self) -> &str {
&self.business_account_id &self.business_account_id
} }
/// Get the access token #[must_use]
pub fn access_token(&self) -> &str { pub fn access_token(&self) -> &str {
&self.access_token &self.access_token
} }
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> { pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap(); let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() { for (_, exp) in store.iter() {
@ -880,7 +822,6 @@ impl MockWhatsApp {
Ok(()) Ok(())
} }
/// Reset all mocks
pub async fn reset(&self) { pub async fn reset(&self) {
self.server.reset().await; self.server.reset().await;
self.sent_messages.lock().unwrap().clear(); self.sent_messages.lock().unwrap().clear();
@ -889,7 +830,6 @@ impl MockWhatsApp {
self.setup_default_routes().await; self.setup_default_routes().await;
} }
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> { pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default() 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 super::{new_expectation_store, ExpectationStore};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -13,7 +8,6 @@ use uuid::Uuid;
use wiremock::matchers::{body_string_contains, header, method, path}; use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
/// Mock Zitadel server for OIDC testing
pub struct MockZitadel { pub struct MockZitadel {
server: MockServer, server: MockServer,
port: u16, port: u16,
@ -23,7 +17,6 @@ pub struct MockZitadel {
issuer: String, issuer: String,
} }
/// Test user for authentication
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestUser { pub struct TestUser {
pub id: String, pub id: String,
@ -47,7 +40,6 @@ impl Default for TestUser {
} }
} }
/// Token information stored by the mock
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct TokenInfo { struct TokenInfo {
user_id: String, user_id: String,
@ -58,7 +50,6 @@ struct TokenInfo {
active: bool, active: bool,
} }
/// Token response from authorization endpoints
#[derive(Serialize)] #[derive(Serialize)]
struct TokenResponse { struct TokenResponse {
access_token: String, access_token: String,
@ -71,7 +62,6 @@ struct TokenResponse {
scope: String, scope: String,
} }
/// OIDC discovery document
#[derive(Serialize)] #[derive(Serialize)]
struct OIDCDiscovery { struct OIDCDiscovery {
issuer: String, issuer: String,
@ -89,7 +79,6 @@ struct OIDCDiscovery {
claims_supported: Vec<String>, claims_supported: Vec<String>,
} }
/// Introspection response
#[derive(Serialize)] #[derive(Serialize)]
struct IntrospectionResponse { struct IntrospectionResponse {
active: bool, active: bool,
@ -113,7 +102,6 @@ struct IntrospectionResponse {
iss: Option<String>, iss: Option<String>,
} }
/// User info response
#[derive(Serialize)] #[derive(Serialize)]
struct UserInfoResponse { struct UserInfoResponse {
sub: String, sub: String,
@ -125,7 +113,6 @@ struct UserInfoResponse {
roles: Option<Vec<String>>, roles: Option<Vec<String>>,
} }
/// Error response
#[derive(Serialize)] #[derive(Serialize)]
struct ErrorResponse { struct ErrorResponse {
error: String, error: String,
@ -133,13 +120,12 @@ struct ErrorResponse {
} }
impl MockZitadel { impl MockZitadel {
/// Start a new mock Zitadel server on the specified port
pub async fn start(port: u16) -> Result<Self> { 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")?; .context("Failed to bind MockZitadel port")?;
let server = MockServer::builder().listener(listener).start().await; 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 { let mock = Self {
server, server,
@ -156,18 +142,17 @@ impl MockZitadel {
Ok(mock) Ok(mock)
} }
/// Set up the OIDC discovery endpoint
async fn setup_discovery_endpoint(&self) { async fn setup_discovery_endpoint(&self) {
let base_url = self.url(); let base_url = self.url();
let discovery = OIDCDiscovery { let discovery = OIDCDiscovery {
issuer: base_url.clone(), issuer: base_url.clone(),
authorization_endpoint: format!("{}/oauth/v2/authorize", base_url), authorization_endpoint: format!("{base_url}/oauth/v2/authorize"),
token_endpoint: format!("{}/oauth/v2/token", base_url), token_endpoint: format!("{base_url}/oauth/v2/token"),
userinfo_endpoint: format!("{}/oidc/v1/userinfo", base_url), userinfo_endpoint: format!("{base_url}/oidc/v1/userinfo"),
introspection_endpoint: format!("{}/oauth/v2/introspect", base_url), introspection_endpoint: format!("{base_url}/oauth/v2/introspect"),
revocation_endpoint: format!("{}/oauth/v2/revoke", base_url), revocation_endpoint: format!("{base_url}/oauth/v2/revoke"),
jwks_uri: format!("{}/oauth/v2/keys", base_url), jwks_uri: format!("{base_url}/oauth/v2/keys"),
response_types_supported: vec![ response_types_supported: vec![
"code".to_string(), "code".to_string(),
"token".to_string(), "token".to_string(),
@ -210,9 +195,7 @@ impl MockZitadel {
.await; .await;
} }
/// Set up the JWKS endpoint with a mock key
async fn setup_jwks_endpoint(&self) { async fn setup_jwks_endpoint(&self) {
// Simple mock JWKS - in production this would be a real RSA key
let jwks = serde_json::json!({ let jwks = serde_json::json!({
"keys": [{ "keys": [{
"kty": "RSA", "kty": "RSA",
@ -231,7 +214,7 @@ impl MockZitadel {
.await; .await;
} }
/// Create a test user and return their ID #[must_use]
pub fn create_test_user(&self, email: &str) -> TestUser { pub fn create_test_user(&self, email: &str) -> TestUser {
let user = TestUser { let user = TestUser {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
@ -248,7 +231,7 @@ impl MockZitadel {
user user
} }
/// Create a test user with specific details #[must_use]
pub fn create_user(&self, user: TestUser) -> TestUser { pub fn create_user(&self, user: TestUser) -> TestUser {
self.users self.users
.lock() .lock()
@ -257,7 +240,6 @@ impl MockZitadel {
user user
} }
/// Expect a login with specific credentials and return a token response
pub async fn expect_login(&self, email: &str, password: &str) -> String { pub async fn expect_login(&self, email: &str, password: &str) -> String {
let user = self let user = self
.users .users
@ -286,7 +268,6 @@ impl MockZitadel {
.as_secs(); .as_secs();
let expires_in = 3600u64; let expires_in = 3600u64;
// Store token info
self.tokens.lock().unwrap().insert( self.tokens.lock().unwrap().insert(
access_token.clone(), access_token.clone(),
TokenInfo { TokenInfo {
@ -312,10 +293,9 @@ impl MockZitadel {
scope: "openid profile email".to_string(), scope: "openid profile email".to_string(),
}; };
// Set up the mock for password grant
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/oauth/v2/token")) .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)) .respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
.mount(&self.server) .mount(&self.server)
.await; .await;
@ -323,7 +303,6 @@ impl MockZitadel {
access_token access_token
} }
/// Expect token refresh
pub async fn expect_token_refresh(&self) { pub async fn expect_token_refresh(&self) {
let access_token = format!("test_access_{}", Uuid::new_v4()); let access_token = format!("test_access_{}", Uuid::new_v4());
let refresh_token = format!("test_refresh_{}", Uuid::new_v4()); let refresh_token = format!("test_refresh_{}", Uuid::new_v4());
@ -345,7 +324,6 @@ impl MockZitadel {
.await; .await;
} }
/// Expect token introspection
pub async fn expect_introspect(&self, token: &str, active: bool) { pub async fn expect_introspect(&self, token: &str, active: bool) {
let response = if active { let response = if active {
let now = SystemTime::now() let now = SystemTime::now()
@ -382,13 +360,12 @@ impl MockZitadel {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/oauth/v2/introspect")) .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)) .respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server) .mount(&self.server)
.await; .await;
} }
/// Set up default introspection that always returns active
pub async fn expect_any_introspect_active(&self) { pub async fn expect_any_introspect_active(&self) {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -415,7 +392,6 @@ impl MockZitadel {
.await; .await;
} }
/// Expect userinfo request
pub async fn expect_userinfo(&self, token: &str, user: &TestUser) { pub async fn expect_userinfo(&self, token: &str, user: &TestUser) {
let response = UserInfoResponse { let response = UserInfoResponse {
sub: user.id.clone(), sub: user.id.clone(),
@ -428,16 +404,12 @@ impl MockZitadel {
Mock::given(method("GET")) Mock::given(method("GET"))
.and(path("/oidc/v1/userinfo")) .and(path("/oidc/v1/userinfo"))
.and(header( .and(header("authorization", format!("Bearer {token}").as_str()))
"authorization",
format!("Bearer {}", token).as_str(),
))
.respond_with(ResponseTemplate::new(200).set_body_json(&response)) .respond_with(ResponseTemplate::new(200).set_body_json(&response))
.mount(&self.server) .mount(&self.server)
.await; .await;
} }
/// Set up default userinfo endpoint
pub async fn expect_any_userinfo(&self) { pub async fn expect_any_userinfo(&self) {
let response = UserInfoResponse { let response = UserInfoResponse {
sub: Uuid::new_v4().to_string(), sub: Uuid::new_v4().to_string(),
@ -455,7 +427,6 @@ impl MockZitadel {
.await; .await;
} }
/// Expect token revocation
pub async fn expect_revoke(&self) { pub async fn expect_revoke(&self) {
Mock::given(method("POST")) Mock::given(method("POST"))
.and(path("/oauth/v2/revoke")) .and(path("/oauth/v2/revoke"))
@ -464,7 +435,6 @@ impl MockZitadel {
.await; .await;
} }
/// Expect an authentication error
pub async fn expect_auth_error(&self, error: &str, description: &str) { pub async fn expect_auth_error(&self, error: &str, description: &str) {
let response = ErrorResponse { let response = ErrorResponse {
error: error.to_string(), error: error.to_string(),
@ -478,19 +448,16 @@ impl MockZitadel {
.await; .await;
} }
/// Expect invalid credentials error
pub async fn expect_invalid_credentials(&self) { pub async fn expect_invalid_credentials(&self) {
self.expect_auth_error("invalid_grant", "Invalid username or password") self.expect_auth_error("invalid_grant", "Invalid username or password")
.await; .await;
} }
/// Expect client authentication error
pub async fn expect_invalid_client(&self) { pub async fn expect_invalid_client(&self) {
self.expect_auth_error("invalid_client", "Client authentication failed") self.expect_auth_error("invalid_client", "Client authentication failed")
.await; .await;
} }
/// Create a mock ID token (not cryptographically valid, for testing only)
fn create_mock_id_token(&self, user: &TestUser) -> String { fn create_mock_id_token(&self, user: &TestUser) -> String {
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -513,10 +480,10 @@ impl MockZitadel {
); );
let signature = base64_url_encode("mock-signature"); 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 { pub fn generate_token(&self, user: &TestUser) -> String {
let access_token = format!("test_access_{}", Uuid::new_v4()); let access_token = format!("test_access_{}", Uuid::new_v4());
let now = SystemTime::now() let now = SystemTime::now()
@ -543,34 +510,32 @@ impl MockZitadel {
access_token access_token
} }
/// Invalidate a token
pub fn invalidate_token(&self, token: &str) { pub fn invalidate_token(&self, token: &str) {
if let Some(info) = self.tokens.lock().unwrap().get_mut(token) { if let Some(info) = self.tokens.lock().unwrap().get_mut(token) {
info.active = false; info.active = false;
} }
} }
/// Get the server URL #[must_use]
pub fn url(&self) -> String { pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port) format!("http://127.0.0.1:{}", self.port)
} }
/// Get the issuer URL (same as server URL) #[must_use]
pub fn issuer(&self) -> String { pub fn issuer(&self) -> String {
self.issuer.clone() self.issuer.clone()
} }
/// Get the port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Get the OIDC discovery URL #[must_use]
pub fn discovery_url(&self) -> String { pub fn discovery_url(&self) -> String {
format!("{}/.well-known/openid-configuration", self.url()) format!("{}/.well-known/openid-configuration", self.url())
} }
/// Verify all expectations were met
pub fn verify(&self) -> Result<()> { pub fn verify(&self) -> Result<()> {
let store = self.expectations.lock().unwrap(); let store = self.expectations.lock().unwrap();
for (_, exp) in store.iter() { for (_, exp) in store.iter() {
@ -579,7 +544,6 @@ impl MockZitadel {
Ok(()) Ok(())
} }
/// Reset all mocks
pub async fn reset(&self) { pub async fn reset(&self) {
self.server.reset().await; self.server.reset().await;
self.users.lock().unwrap().clear(); self.users.lock().unwrap().clear();
@ -589,13 +553,11 @@ impl MockZitadel {
self.setup_jwks_endpoint().await; self.setup_jwks_endpoint().await;
} }
/// Get received requests for inspection
pub async fn received_requests(&self) -> Vec<wiremock::Request> { pub async fn received_requests(&self) -> Vec<wiremock::Request> {
self.server.received_requests().await.unwrap_or_default() self.server.received_requests().await.unwrap_or_default()
} }
} }
/// Simple base64 URL encoding (for mock tokens)
fn base64_url_encode(input: &str) -> String { fn base64_url_encode(input: &str) -> String {
use std::io::Write; use std::io::Write;
@ -611,11 +573,10 @@ fn base64_url_encode(input: &str) -> String {
.replace('=', "") .replace('=', "")
} }
/// Create a base64 encoder
fn base64_encoder(output: &mut Vec<u8>) -> impl std::io::Write + '_ { fn base64_encoder(output: &mut Vec<u8>) -> impl std::io::Write + '_ {
struct Base64Writer<'a>(&'a mut Vec<u8>); 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> { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
const ALPHABET: &[u8; 64] = const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@ -687,7 +648,7 @@ mod tests {
assert!(json.contains("access_token")); assert!(json.contains("access_token"));
assert!(json.contains("Bearer")); assert!(json.contains("Bearer"));
assert!(json.contains("refresh_token")); assert!(json.contains("refresh_token"));
assert!(!json.contains("id_token")); // Should be skipped when None assert!(!json.contains("id_token"));
} }
#[test] #[test]
@ -698,8 +659,8 @@ mod tests {
client_id: Some("client".to_string()), client_id: Some("client".to_string()),
username: Some("user@test.com".to_string()), username: Some("user@test.com".to_string()),
token_type: Some("Bearer".to_string()), token_type: Some("Bearer".to_string()),
exp: Some(1234567890), exp: Some(1_234_567_890),
iat: Some(1234567800), iat: Some(1_234_567_800),
sub: Some("user-id".to_string()), sub: Some("user-id".to_string()),
aud: Some("audience".to_string()), aud: Some("audience".to_string()),
iss: Some("issuer".to_string()), iss: Some("issuer".to_string()),
@ -726,7 +687,6 @@ mod tests {
let json = serde_json::to_string(&response).unwrap(); let json = serde_json::to_string(&response).unwrap();
assert!(json.contains(r#""active":false"#)); assert!(json.contains(r#""active":false"#));
// Optional fields should be omitted
assert!(!json.contains("scope")); 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::collections::HashSet;
use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::atomic::{AtomicU16, Ordering};
@ -29,6 +26,7 @@ impl PortAllocator {
} }
} }
#[must_use]
pub fn allocate_range(count: usize) -> Vec<u16> { pub fn allocate_range(count: usize) -> Vec<u16> {
(0..count).map(|_| Self::allocate()).collect() (0..count).map(|_| Self::allocate()).collect()
} }
@ -71,8 +69,6 @@ impl TestPorts {
impl Drop for TestPorts { impl Drop for TestPorts {
fn drop(&mut self) { fn drop(&mut self) {
// Only release dynamically allocated ports (>= 15000)
// Fixed ports from existing stack should not be released
if self.postgres >= 15000 { if self.postgres >= 15000 {
PortAllocator::release(self.postgres); 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 anyhow::{Context, Result};
use log::{info, warn}; use log::{info, warn};
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
/// Default debugging port for CDP
pub const DEFAULT_DEBUG_PORT: u16 = 9222; pub const DEFAULT_DEBUG_PORT: u16 = 9222;
/// Browser service that manages a browser instance with CDP enabled
pub struct BrowserService { pub struct BrowserService {
port: u16, port: u16,
process: Option<Child>, process: Option<Child>,
@ -19,33 +13,28 @@ pub struct BrowserService {
} }
impl BrowserService { impl BrowserService {
/// Start a browser with remote debugging enabled
pub async fn start(port: u16) -> Result<Self> { pub async fn start(port: u16) -> Result<Self> {
// First, kill any existing browser on this port
let _ = std::process::Command::new("pkill") let _ = std::process::Command::new("pkill")
.args(["-9", "-f", &format!("--remote-debugging-port={}", port)]) .args(["-9", "-f", &format!("--remote-debugging-port={port}")])
.output(); .output();
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
let binary_path = Self::detect_browser_binary()?; let binary_path = Self::detect_browser_binary()?;
let user_data_dir = format!("/tmp/browser-cdp-{}-{}", std::process::id(), port); 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); let _ = std::fs::remove_dir_all(&user_data_dir);
std::fs::create_dir_all(&user_data_dir)?; std::fs::create_dir_all(&user_data_dir)?;
info!("Starting browser with CDP on port {}", port); info!("Starting browser with CDP on port {port}");
println!("🌐 Starting browser: {}", binary_path); println!("🌐 Starting browser: {binary_path}");
info!(" Binary: {}", binary_path); info!(" Binary: {binary_path}");
info!(" User data: {}", user_data_dir); 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 headless = std::env::var("HEADLESS").is_ok();
let mut cmd = Command::new(&binary_path); let mut cmd = Command::new(&binary_path);
cmd.arg(format!("--remote-debugging-port={}", port)) cmd.arg(format!("--remote-debugging-port={port}"))
.arg(format!("--user-data-dir={}", user_data_dir)) .arg(format!("--user-data-dir={user_data_dir}"))
.arg("--no-sandbox") .arg("--no-sandbox")
.arg("--disable-dev-shm-usage") .arg("--disable-dev-shm-usage")
.arg("--disable-extensions") .arg("--disable-extensions")
@ -56,7 +45,6 @@ impl BrowserService {
.arg("--metrics-recording-only") .arg("--metrics-recording-only")
.arg("--no-first-run") .arg("--no-first-run")
.arg("--safebrowsing-disable-auto-update") .arg("--safebrowsing-disable-auto-update")
// SSL/TLS certificate bypass flags
.arg("--ignore-certificate-errors") .arg("--ignore-certificate-errors")
.arg("--ignore-certificate-errors-spki-list") .arg("--ignore-certificate-errors-spki-list")
.arg("--ignore-ssl-errors") .arg("--ignore-ssl-errors")
@ -64,27 +52,24 @@ impl BrowserService {
.arg("--allow-running-insecure-content") .arg("--allow-running-insecure-content")
.arg("--disable-web-security") .arg("--disable-web-security")
.arg("--reduce-security-for-testing") .arg("--reduce-security-for-testing")
// Window position and size to make it visible
.arg("--window-position=100,100") .arg("--window-position=100,100")
.arg("--window-size=1280,800") .arg("--window-size=1280,800")
.arg("--start-maximized"); .arg("--start-maximized");
// Headless flags BEFORE the URL
if headless { if headless {
cmd.arg("--headless=new"); cmd.arg("--headless=new");
cmd.arg("--disable-gpu"); cmd.arg("--disable-gpu");
} }
// URL goes last
cmd.arg("about:blank"); cmd.arg("about:blank");
cmd.stdout(Stdio::null()).stderr(Stdio::null()); cmd.stdout(Stdio::null()).stderr(Stdio::null());
let process = cmd let process = cmd
.spawn() .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 { let service = Self {
port, port,
@ -93,12 +78,11 @@ impl BrowserService {
user_data_dir, user_data_dir,
}; };
// Wait for CDP to be ready - be patient!
for i in 0..100 { for i in 0..100 {
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
if service.is_ready().await { if service.is_ready().await {
info!("Browser CDP ready on port {}", port); info!("Browser CDP ready on port {port}");
println!(" ✓ Browser CDP ready on port {}", port); println!(" ✓ Browser CDP ready on port {port}");
return Ok(service); return Ok(service);
} }
if i % 20 == 0 && i > 0 { 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"); println!(" ⚠ Browser may not be fully ready");
Ok(service) Ok(service)
} }
/// Check if CDP is ready by fetching the version endpoint
async fn is_ready(&self) -> bool { async fn is_ready(&self) -> bool {
let url = format!("http://127.0.0.1:{}/json/version", self.port); let url = format!("http://127.0.0.1:{}/json/version", self.port);
match reqwest::get(&url).await { 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> { fn detect_browser_binary() -> Result<String> {
// Check for BROWSER_BINARY env var first
if let Ok(path) = std::env::var("BROWSER_BINARY") { if let Ok(path) = std::env::var("BROWSER_BINARY") {
if std::path::Path::new(&path).exists() { 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); return Ok(path);
} }
} }
// Prefer Brave first
let brave_paths = [ let brave_paths = [
"/opt/brave.com/brave-nightly/brave", "/opt/brave.com/brave-nightly/brave",
"/opt/brave.com/brave/brave", "/opt/brave.com/brave/brave",
@ -140,12 +120,11 @@ impl BrowserService {
]; ];
for path in brave_paths { for path in brave_paths {
if std::path::Path::new(path).exists() { if std::path::Path::new(path).exists() {
info!("Detected Brave binary at: {}", path); info!("Detected Brave binary at: {path}");
return Ok(path.to_string()); return Ok(path.to_string());
} }
} }
// Chrome second
let chrome_paths = [ let chrome_paths = [
"/opt/google/chrome/chrome", "/opt/google/chrome/chrome",
"/opt/google/chrome/google-chrome", "/opt/google/chrome/google-chrome",
@ -154,12 +133,11 @@ impl BrowserService {
]; ];
for path in chrome_paths { for path in chrome_paths {
if std::path::Path::new(path).exists() { if std::path::Path::new(path).exists() {
info!("Detected Chrome binary at: {}", path); info!("Detected Chrome binary at: {path}");
return Ok(path.to_string()); return Ok(path.to_string());
} }
} }
// Chromium last
let chromium_paths = [ let chromium_paths = [
"/usr/bin/chromium-browser", "/usr/bin/chromium-browser",
"/usr/bin/chromium", "/usr/bin/chromium",
@ -167,7 +145,7 @@ impl BrowserService {
]; ];
for path in chromium_paths { for path in chromium_paths {
if std::path::Path::new(path).exists() { if std::path::Path::new(path).exists() {
info!("Detected Chromium binary at: {}", path); info!("Detected Chromium binary at: {path}");
return Ok(path.to_string()); return Ok(path.to_string());
} }
} }
@ -175,22 +153,22 @@ impl BrowserService {
anyhow::bail!("No supported browser found. Install Brave, Chrome, or Chromium.") 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 { pub fn ws_url(&self) -> String {
format!("ws://127.0.0.1:{}", self.port) format!("ws://127.0.0.1:{}", self.port)
} }
/// Get the HTTP URL for CDP endpoints #[must_use]
pub fn http_url(&self) -> String { pub fn http_url(&self) -> String {
format!("http://127.0.0.1:{}", self.port) format!("http://127.0.0.1:{}", self.port)
} }
/// Get the debugging port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Stop the browser #[allow(clippy::unused_async)]
pub async fn stop(&mut self) -> Result<()> { pub async fn stop(&mut self) -> Result<()> {
if let Some(mut process) = self.process.take() { if let Some(mut process) = self.process.take() {
info!("Stopping browser"); info!("Stopping browser");
@ -198,7 +176,6 @@ impl BrowserService {
process.wait().ok(); process.wait().ok();
} }
// Clean up user data directory
if std::path::Path::new(&self.user_data_dir).exists() { if std::path::Path::new(&self.user_data_dir).exists() {
std::fs::remove_dir_all(&self.user_data_dir).ok(); std::fs::remove_dir_all(&self.user_data_dir).ok();
} }
@ -206,7 +183,6 @@ impl BrowserService {
Ok(()) Ok(())
} }
/// Cleanup resources
pub fn cleanup(&mut self) { pub fn cleanup(&mut self) {
if let Some(mut process) = self.process.take() { if let Some(mut process) = self.process.take() {
process.kill().ok(); process.kill().ok();
@ -231,9 +207,7 @@ mod tests {
#[test] #[test]
fn test_detect_browser() { fn test_detect_browser() {
// Should not fail - will find at least one browser or return error
let result = BrowserService::detect_browser_binary(); let result = BrowserService::detect_browser_binary();
// Test passes if we found a browser
if let Ok(path) = result { if let Ok(path) = result {
assert!(!path.is_empty()); 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 super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal}; use nix::sys::signal::{kill, Signal};
@ -14,7 +8,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
/// MinIO service for S3-compatible storage in test environments
pub struct MinioService { pub struct MinioService {
api_port: u16, api_port: u16,
console_port: u16, console_port: u16,
@ -26,24 +19,22 @@ pub struct MinioService {
} }
impl MinioService { impl MinioService {
/// Default access key for tests
pub const DEFAULT_ACCESS_KEY: &'static str = "minioadmin"; pub const DEFAULT_ACCESS_KEY: &'static str = "minioadmin";
/// Default secret key for tests
pub const DEFAULT_SECRET_KEY: &'static str = "minioadmin"; pub const DEFAULT_SECRET_KEY: &'static str = "minioadmin";
/// Find MinIO binary - checks botserver-stack first, then system paths
fn find_minio_binary() -> Result<PathBuf> { fn find_minio_binary() -> Result<PathBuf> {
// First, check BOTSERVER_STACK_PATH env var
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") { if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio"); let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio");
if minio_path.exists() { 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); return Ok(minio_path);
} }
} }
// Check relative paths from current directory
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let relative_paths = [ let relative_paths = [
"../botserver/botserver-stack/bin/drive/minio", "../botserver/botserver-stack/bin/drive/minio",
@ -54,12 +45,11 @@ impl MinioService {
for rel_path in &relative_paths { for rel_path in &relative_paths {
let minio_path = cwd.join(rel_path); let minio_path = cwd.join(rel_path);
if minio_path.exists() { 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); return Ok(minio_path);
} }
} }
// Check system paths
let system_paths = [ let system_paths = [
"/usr/local/bin/minio", "/usr/local/bin/minio",
"/usr/bin/minio", "/usr/bin/minio",
@ -70,29 +60,26 @@ impl MinioService {
for path in &system_paths { for path in &system_paths {
let minio_path = PathBuf::from(path); let minio_path = PathBuf::from(path);
if minio_path.exists() { 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); return Ok(minio_path);
} }
} }
// Last resort: try to find via which
if let Ok(minio_path) = which::which("minio") { 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); return Ok(minio_path);
} }
anyhow::bail!("MinIO not found. Install MinIO or set BOTSERVER_STACK_PATH env var") 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> { pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> {
let bin_path = Self::find_minio_binary()?; 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"); let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
// Allocate a console port (api_port + 1000 or find available)
let console_port = api_port + 1000; let console_port = api_port + 1000;
let mut service = Self { let mut service = Self {
@ -105,13 +92,12 @@ impl MinioService {
secret_key: Self::DEFAULT_SECRET_KEY.to_string(), secret_key: Self::DEFAULT_SECRET_KEY.to_string(),
}; };
service.start_server().await?; service.start_server()?;
service.wait_ready().await?; service.wait_ready().await?;
Ok(service) Ok(service)
} }
/// Start MinIO with custom credentials
pub async fn start_with_credentials( pub async fn start_with_credentials(
api_port: u16, api_port: u16,
data_dir: &str, data_dir: &str,
@ -119,7 +105,7 @@ impl MinioService {
secret_key: &str, secret_key: &str,
) -> Result<Self> { ) -> Result<Self> {
let bin_path = Self::find_minio_binary()?; 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"); let data_path = PathBuf::from(data_dir).join("minio");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
@ -136,14 +122,13 @@ impl MinioService {
secret_key: secret_key.to_string(), secret_key: secret_key.to_string(),
}; };
service.start_server().await?; service.start_server()?;
service.wait_ready().await?; service.wait_ready().await?;
Ok(service) Ok(service)
} }
/// Start the MinIO server process fn start_server(&mut self) -> Result<()> {
async fn start_server(&mut self) -> Result<()> {
log::info!( log::info!(
"Starting MinIO on port {} (console: {})", "Starting MinIO on port {} (console: {})",
self.api_port, self.api_port,
@ -170,7 +155,6 @@ impl MinioService {
Ok(()) Ok(())
} }
/// Wait for MinIO to be ready
async fn wait_ready(&self) -> Result<()> { async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for MinIO to be ready..."); log::info!("Waiting for MinIO to be ready...");
@ -180,7 +164,6 @@ impl MinioService {
.await .await
.context("MinIO failed to start in time")?; .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); let health_url = format!("http://127.0.0.1:{}/minio/health/live", self.api_port);
for _ in 0..30 { for _ in 0..30 {
if let Ok(resp) = reqwest::get(&health_url).await { if let Ok(resp) = reqwest::get(&health_url).await {
@ -191,17 +174,13 @@ impl MinioService {
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
} }
// Even if health check fails, TCP is up so proceed
Ok(()) Ok(())
} }
/// Create a new bucket
pub async fn create_bucket(&self, name: &str) -> Result<()> { 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() { if let Ok(mc) = Self::find_mc_binary() {
// Configure mc alias
let alias_name = format!("test{}", self.api_port); let alias_name = format!("test{}", self.api_port);
let _ = Command::new(&mc) let _ = Command::new(&mc)
.args([ .args([
@ -215,24 +194,19 @@ impl MinioService {
.output(); .output();
let output = Command::new(&mc) let output = Command::new(&mc)
.args([ .args(["mb", "--ignore-existing", &format!("{alias_name}/{name}")])
"mb",
"--ignore-existing",
&format!("{}/{}", alias_name, name),
])
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("already") { if !stderr.contains("already") {
anyhow::bail!("Failed to create bucket: {}", stderr); anyhow::bail!("Failed to create bucket: {stderr}");
} }
} }
return Ok(()); return Ok(());
} }
// Fallback: use HTTP PUT request
let url = format!("{}/{}", self.endpoint(), name); let url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let resp = client let resp = client
@ -248,7 +222,6 @@ impl MinioService {
Ok(()) Ok(())
} }
/// Put an object into a bucket
pub async fn put_object(&self, bucket: &str, key: &str, data: &[u8]) -> Result<()> { pub async fn put_object(&self, bucket: &str, key: &str, data: &[u8]) -> Result<()> {
log::debug!("Putting object '{}/{}' ({} bytes)", bucket, key, data.len()); log::debug!("Putting object '{}/{}' ({} bytes)", bucket, key, data.len());
@ -268,9 +241,8 @@ impl MinioService {
Ok(()) Ok(())
} }
/// Get an object from a bucket
pub async fn get_object(&self, bucket: &str, key: &str) -> Result<Vec<u8>> { 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 url = format!("{}/{}/{}", self.endpoint(), bucket, key);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -287,9 +259,8 @@ impl MinioService {
Ok(resp.bytes().await?.to_vec()) Ok(resp.bytes().await?.to_vec())
} }
/// Delete an object from a bucket
pub async fn delete_object(&self, bucket: &str, key: &str) -> Result<()> { 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 url = format!("{}/{}/{}", self.endpoint(), bucket, key);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -306,13 +277,12 @@ impl MinioService {
Ok(()) Ok(())
} }
/// List objects in a bucket
pub async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> Result<Vec<String>> { 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); let mut url = format!("{}/{}", self.endpoint(), bucket);
if let Some(p) = prefix { if let Some(p) = prefix {
url = format!("{}?prefix={}", url, p); url = format!("{url}?prefix={p}");
} }
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -326,11 +296,9 @@ impl MinioService {
anyhow::bail!("Failed to list objects: {}", resp.status()); anyhow::bail!("Failed to list objects: {}", resp.status());
} }
// Parse XML response (simplified)
let body = resp.text().await?; let body = resp.text().await?;
let mut objects = Vec::new(); let mut objects = Vec::new();
// Simple XML parsing for <Key> elements
for line in body.lines() { for line in body.lines() {
if let Some(start) = line.find("<Key>") { if let Some(start) = line.find("<Key>") {
if let Some(end) = line.find("</Key>") { if let Some(end) = line.find("</Key>") {
@ -343,7 +311,6 @@ impl MinioService {
Ok(objects) Ok(objects)
} }
/// Check if a bucket exists
pub async fn bucket_exists(&self, name: &str) -> Result<bool> { pub async fn bucket_exists(&self, name: &str) -> Result<bool> {
let url = format!("{}/{}", self.endpoint(), name); let url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -356,9 +323,8 @@ impl MinioService {
Ok(resp.status().is_success()) Ok(resp.status().is_success())
} }
/// Delete a bucket
pub async fn delete_bucket(&self, name: &str) -> Result<()> { 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 url = format!("{}/{}", self.endpoint(), name);
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -375,32 +341,32 @@ impl MinioService {
Ok(()) Ok(())
} }
/// Get the S3 endpoint URL #[must_use]
pub fn endpoint(&self) -> String { pub fn endpoint(&self) -> String {
format!("http://127.0.0.1:{}", self.api_port) format!("http://127.0.0.1:{}", self.api_port)
} }
/// Get the console URL #[must_use]
pub fn console_url(&self) -> String { pub fn console_url(&self) -> String {
format!("http://127.0.0.1:{}", self.console_port) format!("http://127.0.0.1:{}", self.console_port)
} }
/// Get the API port #[must_use]
pub fn api_port(&self) -> u16 { pub const fn api_port(&self) -> u16 {
self.api_port self.api_port
} }
/// Get the console port #[must_use]
pub fn console_port(&self) -> u16 { pub const fn console_port(&self) -> u16 {
self.console_port self.console_port
} }
/// Get credentials as (access_key, secret_key) #[must_use]
pub fn credentials(&self) -> (String, String) { pub fn credentials(&self) -> (String, String) {
(self.access_key.clone(), self.secret_key.clone()) (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> { pub fn s3_config(&self) -> HashMap<String, String> {
let mut config = HashMap::new(); let mut config = HashMap::new();
config.insert("endpoint_url".to_string(), self.endpoint()); config.insert("endpoint_url".to_string(), self.endpoint());
@ -411,7 +377,6 @@ impl MinioService {
config config
} }
/// Find the MinIO client (mc) binary
fn find_mc_binary() -> Result<PathBuf> { fn find_mc_binary() -> Result<PathBuf> {
let common_paths = ["/usr/local/bin/mc", "/usr/bin/mc", "/opt/homebrew/bin/mc"]; 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") which::which("mc").context("mc binary not found")
} }
/// Stop the MinIO server
pub async fn stop(&mut self) -> Result<()> { pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process { if let Some(ref mut child) = self.process {
log::info!("Stopping MinIO..."); log::info!("Stopping MinIO...");
@ -452,7 +416,6 @@ impl MinioService {
Ok(()) Ok(())
} }
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> { pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() { if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?; 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 browser_service;
mod minio; mod minio;
@ -18,13 +14,10 @@ use std::path::Path;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
/// Default timeout for service health checks
pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(30); 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); 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<()> pub async fn wait_for<F, Fut>(timeout: Duration, interval: Duration, mut check: F) -> Result<()>
where where
F: FnMut() -> Fut, F: FnMut() -> Fut,
@ -40,12 +33,10 @@ where
anyhow::bail!("Timeout waiting for condition") 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 { pub async fn check_tcp_port(host: &str, port: u16) -> bool {
tokio::net::TcpStream::connect((host, port)).await.is_ok() tokio::net::TcpStream::connect((host, port)).await.is_ok()
} }
/// Create a directory if it doesn't exist
pub fn ensure_dir(path: &Path) -> Result<()> { pub fn ensure_dir(path: &Path) -> Result<()> {
if !path.exists() { if !path.exists() {
std::fs::create_dir_all(path)?; std::fs::create_dir_all(path)?;
@ -53,23 +44,17 @@ pub fn ensure_dir(path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Service trait for common operations
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Service: Send + Sync { pub trait Service: Send + Sync {
/// Start the service
async fn start(&mut self) -> Result<()>; async fn start(&mut self) -> Result<()>;
/// Stop the service gracefully
async fn stop(&mut self) -> Result<()>; async fn stop(&mut self) -> Result<()>;
/// Check if the service is healthy
async fn health_check(&self) -> Result<bool>; async fn health_check(&self) -> Result<bool>;
/// Get the service connection URL
fn connection_url(&self) -> String; fn connection_url(&self) -> String;
} }
/// Service status
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceStatus { pub enum ServiceStatus {
Stopped, 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 super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal}; use nix::sys::signal::{kill, Signal};
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
/// PostgreSQL service for test environments
pub struct PostgresService { pub struct PostgresService {
port: u16, port: u16,
data_dir: PathBuf, data_dir: PathBuf,
@ -26,28 +20,25 @@ pub struct PostgresService {
} }
impl PostgresService { impl PostgresService {
/// Default database name for tests
pub const DEFAULT_DATABASE: &'static str = "bottest"; pub const DEFAULT_DATABASE: &'static str = "bottest";
/// Default username for tests
pub const DEFAULT_USERNAME: &'static str = "bottest"; pub const DEFAULT_USERNAME: &'static str = "bottest";
/// Default password for tests
pub const DEFAULT_PASSWORD: &'static str = "bottest"; 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>)> { 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") { if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
let bin_dir = PathBuf::from(&stack_path).join("bin/tables/bin"); let bin_dir = PathBuf::from(&stack_path).join("bin/tables/bin");
let lib_dir = PathBuf::from(&stack_path).join("bin/tables/lib"); let lib_dir = PathBuf::from(&stack_path).join("bin/tables/lib");
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() { 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))); return Ok((bin_dir, Some(lib_dir)));
} }
} }
// Check relative paths from current directory
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let relative_paths = [ let relative_paths = [
"../botserver/botserver-stack/bin/tables/bin", "../botserver/botserver-stack/bin/tables/bin",
@ -59,7 +50,10 @@ impl PostgresService {
let bin_dir = cwd.join(rel_path); let bin_dir = cwd.join(rel_path);
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() { if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
let lib_dir = bin_dir.parent().unwrap().join("lib"); 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(( return Ok((
bin_dir, bin_dir,
if lib_dir.exists() { if lib_dir.exists() {
@ -71,7 +65,6 @@ impl PostgresService {
} }
} }
// Check system PostgreSQL paths
let system_paths = [ let system_paths = [
"/usr/lib/postgresql/17/bin", "/usr/lib/postgresql/17/bin",
"/usr/lib/postgresql/16/bin", "/usr/lib/postgresql/16/bin",
@ -88,15 +81,14 @@ impl PostgresService {
for path in &system_paths { for path in &system_paths {
let bin_dir = PathBuf::from(path); let bin_dir = PathBuf::from(path);
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() { 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)); return Ok((bin_dir, None));
} }
} }
// Last resort: try to find via which
if let Ok(initdb_path) = which::which("initdb") { if let Ok(initdb_path) = which::which("initdb") {
if let Some(bin_dir) = initdb_path.parent() { 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)); 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> { pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
let (bin_dir, lib_dir) = Self::find_postgres_installation()?; let (bin_dir, lib_dir) = Self::find_postgres_installation()?;
@ -127,29 +118,23 @@ impl PostgresService {
service.connection_string = service.build_connection_string(); service.connection_string = service.build_connection_string();
// Initialize database cluster if needed
if !data_path.join("PG_VERSION").exists() { if !data_path.join("PG_VERSION").exists() {
service.init_db().await?; service.init_db()?;
} }
// Start PostgreSQL service.start_server()?;
service.start_server().await?;
// Wait for it to be ready
service.wait_ready().await?; service.wait_ready().await?;
// Create test database and user service.setup_test_database()?;
service.setup_test_database().await?;
Ok(service) Ok(service)
} }
/// Get binary path
fn get_binary(&self, name: &str) -> PathBuf { fn get_binary(&self, name: &str) -> PathBuf {
self.bin_dir.join(name) self.bin_dir.join(name)
} }
/// Build command with LD_LIBRARY_PATH if needed
fn build_command(&self, binary_name: &str) -> Command { fn build_command(&self, binary_name: &str) -> Command {
let binary = self.get_binary(binary_name); let binary = self.get_binary(binary_name);
let mut cmd = Command::new(&binary); let mut cmd = Command::new(&binary);
@ -159,11 +144,10 @@ impl PostgresService {
cmd cmd
} }
/// Initialize the database cluster fn init_db(&self) -> Result<()> {
async fn init_db(&self) -> Result<()> {
log::info!( log::info!(
"Initializing PostgreSQL data directory at {:?}", "Initializing PostgreSQL data directory at {}",
self.data_dir self.data_dir.display()
); );
let output = self let output = self
@ -184,27 +168,24 @@ impl PostgresService {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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()?; self.configure_for_testing()?;
Ok(()) Ok(())
} }
/// Configure PostgreSQL for fast testing (reduced durability)
fn configure_for_testing(&self) -> Result<()> { fn configure_for_testing(&self) -> Result<()> {
let config_path = self.data_dir.join("postgresql.conf"); let config_path = self.data_dir.join("postgresql.conf");
// Use absolute path for unix_socket_directories
let abs_data_dir = self let abs_data_dir = self
.data_dir .data_dir
.canonicalize() .canonicalize()
.unwrap_or_else(|_| self.data_dir.clone()); .unwrap_or_else(|_| self.data_dir.clone());
let config = format!( let config = format!(
r#" r"
# Test configuration - optimized for speed, not durability # Test configuration - optimized for speed, not durability
listen_addresses = '127.0.0.1' listen_addresses = '127.0.0.1'
port = {} port = {}
@ -222,7 +203,7 @@ logging_collector = off
log_statement = 'none' log_statement = 'none'
log_duration = off log_duration = off
unix_socket_directories = '{}' unix_socket_directories = '{}'
"#, ",
self.port, self.port,
abs_data_dir.to_str().unwrap() abs_data_dir.to_str().unwrap()
); );
@ -231,17 +212,15 @@ unix_socket_directories = '{}'
Ok(()) Ok(())
} }
/// Start the PostgreSQL server process fn start_server(&mut self) -> Result<()> {
async fn start_server(&mut self) -> Result<()> {
log::info!("Starting PostgreSQL on port {}", self.port); log::info!("Starting PostgreSQL on port {}", self.port);
// Create log file for debugging
let log_path = self.data_dir.join("postgres.log"); let log_path = self.data_dir.join("postgres.log");
let log_file = std::fs::File::create(&log_path) 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()?; 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 mut cmd = self.build_command("postgres");
let child = cmd let child = cmd
@ -255,7 +234,6 @@ unix_socket_directories = '{}'
Ok(()) Ok(())
} }
/// Wait for PostgreSQL to be ready to accept connections
async fn wait_ready(&self) -> Result<()> { async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for PostgreSQL to be ready..."); log::info!("Waiting for PostgreSQL to be ready...");
@ -264,18 +242,16 @@ unix_socket_directories = '{}'
}) })
.await; .await;
if result.is_err() { if let Err(e) = result {
// Read log file to show error
let log_path = self.data_dir.join("postgres.log"); let log_path = self.data_dir.join("postgres.log");
if log_path.exists() { if log_path.exists() {
if let Ok(log_content) = std::fs::read_to_string(&log_path) { 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 { for _ in 0..30 {
let status = self let status = self
.build_command("pg_isready") .build_command("pg_isready")
@ -291,11 +267,9 @@ unix_socket_directories = '{}'
Ok(()) Ok(())
} }
/// Create the test database and user fn setup_test_database(&self) -> Result<()> {
async fn setup_test_database(&self) -> Result<()> {
log::info!("Setting up test database '{}'", self.database_name); log::info!("Setting up test database '{}'", self.database_name);
// Create user
let _ = self let _ = self
.build_command("psql") .build_command("psql")
.args([ .args([
@ -313,7 +287,6 @@ unix_socket_directories = '{}'
]) ])
.output(); .output();
// Create database
let _ = self let _ = self
.build_command("psql") .build_command("psql")
.args([ .args([
@ -334,11 +307,9 @@ unix_socket_directories = '{}'
Ok(()) Ok(())
} }
/// Run database migrations pub fn run_migrations(&self) -> Result<()> {
pub async fn run_migrations(&self) -> Result<()> {
log::info!("Running database migrations..."); log::info!("Running database migrations...");
// Try to run migrations using diesel CLI if available
if let Ok(diesel) = which::which("diesel") { if let Ok(diesel) = which::which("diesel") {
let status = Command::new(diesel) let status = Command::new(diesel)
.args([ .args([
@ -354,13 +325,11 @@ unix_socket_directories = '{}'
} }
} }
// Fallback: run migrations programmatically via botlib if available
log::warn!("diesel CLI not available, skipping migrations"); log::warn!("diesel CLI not available, skipping migrations");
Ok(()) Ok(())
} }
/// Create a new database with the given name pub fn create_database(&self, name: &str) -> Result<()> {
pub async fn create_database(&self, name: &str) -> Result<()> {
let output = self let output = self
.build_command("psql") .build_command("psql")
.args([ .args([
@ -371,22 +340,21 @@ unix_socket_directories = '{}'
"-U", "-U",
&self.username, &self.username,
"-c", "-c",
&format!("CREATE DATABASE {}", name), &format!("CREATE DATABASE {name}"),
]) ])
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("already exists") { if !stderr.contains("already exists") {
anyhow::bail!("Failed to create database: {}", stderr); anyhow::bail!("Failed to create database: {stderr}");
} }
} }
Ok(()) Ok(())
} }
/// Execute raw SQL pub fn execute(&self, sql: &str) -> Result<()> {
pub async fn execute(&self, sql: &str) -> Result<()> {
let output = self let output = self
.build_command("psql") .build_command("psql")
.args([ .args([
@ -405,14 +373,13 @@ unix_socket_directories = '{}'
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("SQL execution failed: {}", stderr); anyhow::bail!("SQL execution failed: {stderr}");
} }
Ok(()) Ok(())
} }
/// Execute SQL and return results as JSON pub fn query(&self, sql: &str) -> Result<String> {
pub async fn query(&self, sql: &str) -> Result<String> {
let output = self let output = self
.build_command("psql") .build_command("psql")
.args([ .args([
@ -424,8 +391,8 @@ unix_socket_directories = '{}'
&self.username, &self.username,
"-d", "-d",
&self.database_name, &self.database_name,
"-t", // tuples only "-t",
"-A", // unaligned "-A",
"-c", "-c",
sql, sql,
]) ])
@ -433,23 +400,22 @@ unix_socket_directories = '{}'
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} }
/// Get the connection string #[must_use]
pub fn connection_string(&self) -> String { pub fn connection_string(&self) -> String {
self.connection_string.clone() self.connection_string.clone()
} }
/// Get the port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Build the connection string
fn build_connection_string(&self) -> String { fn build_connection_string(&self) -> String {
format!( format!(
"postgres://{}:{}@127.0.0.1:{}/{}", "postgres://{}:{}@127.0.0.1:{}/{}",
@ -457,16 +423,13 @@ unix_socket_directories = '{}'
) )
} }
/// Stop the PostgreSQL server
pub async fn stop(&mut self) -> Result<()> { pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process { if let Some(ref mut child) = self.process {
log::info!("Stopping PostgreSQL..."); log::info!("Stopping PostgreSQL...");
// Try graceful shutdown first
let pid = Pid::from_raw(child.id() as i32); let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM); let _ = kill(pid, Signal::SIGTERM);
// Wait for process to exit
for _ in 0..50 { for _ in 0..50 {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => { Ok(Some(_)) => {
@ -478,7 +441,6 @@ unix_socket_directories = '{}'
} }
} }
// Force kill if still running
let _ = kill(pid, Signal::SIGKILL); let _ = kill(pid, Signal::SIGKILL);
let _ = child.wait(); let _ = child.wait();
self.process = None; self.process = None;
@ -487,7 +449,6 @@ unix_socket_directories = '{}'
Ok(()) Ok(())
} }
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> { pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() { if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?; 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 pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM); let _ = kill(pid, Signal::SIGTERM);
// Give it a moment to shut down gracefully
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
// Force kill if needed
let _ = kill(pid, Signal::SIGKILL); let _ = kill(pid, Signal::SIGKILL);
let _ = child.wait(); 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 super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use nix::sys::signal::{kill, Signal}; use nix::sys::signal::{kill, Signal};
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
/// Redis service for test environments
pub struct RedisService { pub struct RedisService {
port: u16, port: u16,
data_dir: PathBuf, data_dir: PathBuf,
@ -21,7 +15,6 @@ pub struct RedisService {
} }
impl RedisService { impl RedisService {
/// Start a new Redis instance on the specified port
pub async fn start(port: u16, data_dir: &str) -> Result<Self> { pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
let data_path = PathBuf::from(data_dir).join("redis"); let data_path = PathBuf::from(data_dir).join("redis");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
@ -39,7 +32,6 @@ impl RedisService {
Ok(service) Ok(service)
} }
/// Start Redis with password authentication
pub async fn start_with_password(port: u16, data_dir: &str, password: &str) -> Result<Self> { pub async fn start_with_password(port: u16, data_dir: &str, password: &str) -> Result<Self> {
let data_path = PathBuf::from(data_dir).join("redis"); let data_path = PathBuf::from(data_dir).join("redis");
ensure_dir(&data_path)?; ensure_dir(&data_path)?;
@ -57,7 +49,7 @@ impl RedisService {
Ok(service) Ok(service)
} }
/// Start the Redis server process #[allow(clippy::unused_async)]
async fn start_server(&mut self) -> Result<()> { async fn start_server(&mut self) -> Result<()> {
log::info!("Starting Redis on port {}", self.port); log::info!("Starting Redis on port {}", self.port);
@ -72,12 +64,10 @@ impl RedisService {
self.data_dir.to_str().unwrap().to_string(), self.data_dir.to_str().unwrap().to_string(),
"--daemonize".to_string(), "--daemonize".to_string(),
"no".to_string(), "no".to_string(),
// Disable persistence for faster testing
"--save".to_string(), "--save".to_string(),
"".to_string(), String::new(),
"--appendonly".to_string(), "--appendonly".to_string(),
"no".to_string(), "no".to_string(),
// Reduce memory usage
"--maxmemory".to_string(), "--maxmemory".to_string(),
"64mb".to_string(), "64mb".to_string(),
"--maxmemory-policy".to_string(), "--maxmemory-policy".to_string(),
@ -100,7 +90,6 @@ impl RedisService {
Ok(()) Ok(())
} }
/// Wait for Redis to be ready
async fn wait_ready(&self) -> Result<()> { async fn wait_ready(&self) -> Result<()> {
log::info!("Waiting for Redis to be ready..."); log::info!("Waiting for Redis to be ready...");
@ -110,7 +99,6 @@ impl RedisService {
.await .await
.context("Redis failed to start in time")?; .context("Redis failed to start in time")?;
// Additional check using redis-cli PING
if let Ok(redis_cli) = Self::find_cli_binary() { if let Ok(redis_cli) = Self::find_cli_binary() {
for _ in 0..30 { for _ in 0..30 {
let mut cmd = Command::new(&redis_cli); let mut cmd = Command::new(&redis_cli);
@ -137,7 +125,7 @@ impl RedisService {
Ok(()) Ok(())
} }
/// Execute a Redis command and return the result #[allow(clippy::unused_async)]
pub async fn execute(&self, args: &[&str]) -> Result<String> { pub async fn execute(&self, args: &[&str]) -> Result<String> {
let redis_cli = Self::find_cli_binary()?; let redis_cli = Self::find_cli_binary()?;
@ -154,26 +142,23 @@ impl RedisService {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); 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()) 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<()> { pub async fn set(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["SET", key, value]).await?; self.execute(&["SET", key, value]).await?;
Ok(()) Ok(())
} }
/// Set a key-value pair with expiration (seconds)
pub async fn setex(&self, key: &str, seconds: u64, value: &str) -> Result<()> { pub async fn setex(&self, key: &str, seconds: u64, value: &str) -> Result<()> {
self.execute(&["SETEX", key, &seconds.to_string(), value]) self.execute(&["SETEX", key, &seconds.to_string(), value])
.await?; .await?;
Ok(()) Ok(())
} }
/// Get a value by key
pub async fn get(&self, key: &str) -> Result<Option<String>> { pub async fn get(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["GET", key]).await?; let result = self.execute(&["GET", key]).await?;
if result.is_empty() || result == "(nil)" { if result.is_empty() || result == "(nil)" {
@ -183,58 +168,49 @@ impl RedisService {
} }
} }
/// Delete a key
pub async fn del(&self, key: &str) -> Result<()> { pub async fn del(&self, key: &str) -> Result<()> {
self.execute(&["DEL", key]).await?; self.execute(&["DEL", key]).await?;
Ok(()) Ok(())
} }
/// Check if a key exists
pub async fn exists(&self, key: &str) -> Result<bool> { pub async fn exists(&self, key: &str) -> Result<bool> {
let result = self.execute(&["EXISTS", key]).await?; let result = self.execute(&["EXISTS", key]).await?;
Ok(result == "1" || result == "(integer) 1") Ok(result == "1" || result == "(integer) 1")
} }
/// Get all keys matching a pattern
pub async fn keys(&self, pattern: &str) -> Result<Vec<String>> { pub async fn keys(&self, pattern: &str) -> Result<Vec<String>> {
let result = self.execute(&["KEYS", pattern]).await?; let result = self.execute(&["KEYS", pattern]).await?;
if result.is_empty() || result == "(empty list or set)" { if result.is_empty() || result == "(empty list or set)" {
Ok(Vec::new()) Ok(Vec::new())
} else { } 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<()> { pub async fn flushall(&self) -> Result<()> {
self.execute(&["FLUSHALL"]).await?; self.execute(&["FLUSHALL"]).await?;
Ok(()) Ok(())
} }
/// Publish a message to a channel
pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> { pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> {
let result = self.execute(&["PUBLISH", channel, message]).await?; 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) Ok(count)
} }
/// Push to a list (left)
pub async fn lpush(&self, key: &str, value: &str) -> Result<()> { pub async fn lpush(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["LPUSH", key, value]).await?; self.execute(&["LPUSH", key, value]).await?;
Ok(()) Ok(())
} }
/// Push to a list (right)
pub async fn rpush(&self, key: &str, value: &str) -> Result<()> { pub async fn rpush(&self, key: &str, value: &str) -> Result<()> {
self.execute(&["RPUSH", key, value]).await?; self.execute(&["RPUSH", key, value]).await?;
Ok(()) Ok(())
} }
/// Pop from a list (left)
pub async fn lpop(&self, key: &str) -> Result<Option<String>> { pub async fn lpop(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["LPOP", key]).await?; let result = self.execute(&["LPOP", key]).await?;
if result.is_empty() || result == "(nil)" { 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>> { pub async fn rpop(&self, key: &str) -> Result<Option<String>> {
let result = self.execute(&["RPOP", key]).await?; let result = self.execute(&["RPOP", key]).await?;
if result.is_empty() || result == "(nil)" { if result.is_empty() || result == "(nil)" {
@ -254,23 +229,17 @@ impl RedisService {
} }
} }
/// Get list length
pub async fn llen(&self, key: &str) -> Result<i64> { pub async fn llen(&self, key: &str) -> Result<i64> {
let result = self.execute(&["LLEN", key]).await?; let result = self.execute(&["LLEN", key]).await?;
let len = result let len = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(len) Ok(len)
} }
/// Set hash field
pub async fn hset(&self, key: &str, field: &str, value: &str) -> Result<()> { pub async fn hset(&self, key: &str, field: &str, value: &str) -> Result<()> {
self.execute(&["HSET", key, field, value]).await?; self.execute(&["HSET", key, field, value]).await?;
Ok(()) Ok(())
} }
/// Get hash field
pub async fn hget(&self, key: &str, field: &str) -> Result<Option<String>> { pub async fn hget(&self, key: &str, field: &str) -> Result<Option<String>> {
let result = self.execute(&["HGET", key, field]).await?; let result = self.execute(&["HGET", key, field]).await?;
if result.is_empty() || result == "(nil)" { 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)>> { pub async fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>> {
let result = self.execute(&["HGETALL", key]).await?; let result = self.execute(&["HGETALL", key]).await?;
if result.is_empty() || result == "(empty list or set)" { if result.is_empty() || result == "(empty list or set)" {
@ -299,27 +267,19 @@ impl RedisService {
Ok(pairs) Ok(pairs)
} }
/// Increment a value
pub async fn incr(&self, key: &str) -> Result<i64> { pub async fn incr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["INCR", key]).await?; let result = self.execute(&["INCR", key]).await?;
let val = result let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(val) Ok(val)
} }
/// Decrement a value
pub async fn decr(&self, key: &str) -> Result<i64> { pub async fn decr(&self, key: &str) -> Result<i64> {
let result = self.execute(&["DECR", key]).await?; let result = self.execute(&["DECR", key]).await?;
let val = result let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
.replace("(integer) ", "")
.parse::<i64>()
.unwrap_or(0);
Ok(val) Ok(val)
} }
/// Get the connection string #[must_use]
pub fn connection_string(&self) -> String { pub fn connection_string(&self) -> String {
match &self.password { match &self.password {
Some(pw) => format!("redis://:{}@127.0.0.1:{}", pw, self.port), 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 { pub fn url(&self) -> String {
self.connection_string() self.connection_string()
} }
/// Get the port #[must_use]
pub fn port(&self) -> u16 { pub const fn port(&self) -> u16 {
self.port self.port
} }
/// Get host and port tuple #[must_use]
pub fn host_port(&self) -> (&str, u16) { pub const fn host_port(&self) -> (&str, u16) {
("127.0.0.1", self.port) ("127.0.0.1", self.port)
} }
/// Find the Redis server binary
fn find_binary() -> Result<PathBuf> { fn find_binary() -> Result<PathBuf> {
let common_paths = [ let common_paths = [
"/usr/bin/redis-server", "/usr/bin/redis-server",
@ -362,7 +321,6 @@ impl RedisService {
.context("redis-server binary not found in PATH or common locations") .context("redis-server binary not found in PATH or common locations")
} }
/// Find the Redis CLI binary
fn find_cli_binary() -> Result<PathBuf> { fn find_cli_binary() -> Result<PathBuf> {
let common_paths = [ let common_paths = [
"/usr/bin/redis-cli", "/usr/bin/redis-cli",
@ -381,12 +339,10 @@ impl RedisService {
which::which("redis-cli").context("redis-cli binary not found") which::which("redis-cli").context("redis-cli binary not found")
} }
/// Stop the Redis server
pub async fn stop(&mut self) -> Result<()> { pub async fn stop(&mut self) -> Result<()> {
if let Some(ref mut child) = self.process { if let Some(ref mut child) = self.process {
log::info!("Stopping Redis..."); log::info!("Stopping Redis...");
// Try graceful shutdown via SHUTDOWN command first
if let Ok(redis_cli) = Self::find_cli_binary() { if let Ok(redis_cli) = Self::find_cli_binary() {
let mut cmd = Command::new(&redis_cli); let mut cmd = Command::new(&redis_cli);
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]); cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]);
@ -401,7 +357,6 @@ impl RedisService {
let _ = cmd.output(); let _ = cmd.output();
} }
// Wait for process to exit
for _ in 0..30 { for _ in 0..30 {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => { Ok(Some(_)) => {
@ -413,7 +368,6 @@ impl RedisService {
} }
} }
// Force kill if still running
let pid = Pid::from_raw(child.id() as i32); let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM); let _ = kill(pid, Signal::SIGTERM);
@ -436,7 +390,6 @@ impl RedisService {
Ok(()) Ok(())
} }
/// Clean up data directory
pub fn cleanup(&self) -> Result<()> { pub fn cleanup(&self) -> Result<()> {
if self.data_dir.exists() { if self.data_dir.exists() {
std::fs::remove_dir_all(&self.data_dir)?; std::fs::remove_dir_all(&self.data_dir)?;
@ -448,7 +401,6 @@ impl RedisService {
impl Drop for RedisService { impl Drop for RedisService {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(ref mut child) = self.process { if let Some(ref mut child) = self.process {
// Try graceful shutdown
if let Ok(redis_cli) = Self::find_cli_binary() { if let Ok(redis_cli) = Self::find_cli_binary() {
let mut cmd = Command::new(&redis_cli); let mut cmd = Command::new(&redis_cli);
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]); 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)); std::thread::sleep(Duration::from_millis(200));
} }
// Force kill if needed
let pid = Pid::from_raw(child.id() as i32); let pid = Pid::from_raw(child.id() as i32);
let _ = kill(pid, Signal::SIGTERM); 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 browser;
pub mod pages; pub mod pages;
@ -11,24 +6,15 @@ pub use browser::{Browser, BrowserConfig, BrowserType};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
/// Configuration for E2E tests
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct E2EConfig { pub struct E2EConfig {
/// Browser type to use
pub browser: BrowserType, pub browser: BrowserType,
/// Whether to run headless
pub headless: bool, pub headless: bool,
/// Default timeout for operations
pub timeout: Duration, pub timeout: Duration,
/// Window width
pub window_width: u32, pub window_width: u32,
/// Window height
pub window_height: u32, pub window_height: u32,
/// WebDriver URL
pub webdriver_url: String, pub webdriver_url: String,
/// Whether to capture screenshots on failure
pub screenshot_on_failure: bool, pub screenshot_on_failure: bool,
/// Directory to save screenshots
pub screenshot_dir: String, pub screenshot_dir: String,
} }
@ -48,7 +34,7 @@ impl Default for E2EConfig {
} }
impl E2EConfig { impl E2EConfig {
/// Create a BrowserConfig from this E2EConfig #[must_use]
pub fn to_browser_config(&self) -> BrowserConfig { pub fn to_browser_config(&self) -> BrowserConfig {
BrowserConfig::default() BrowserConfig::default()
.with_browser(self.browser) .with_browser(self.browser)
@ -59,7 +45,6 @@ impl E2EConfig {
} }
} }
/// Result of an E2E test
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct E2ETestResult { pub struct E2ETestResult {
pub name: String, pub name: String,
@ -70,7 +55,6 @@ pub struct E2ETestResult {
pub error: Option<String>, pub error: Option<String>,
} }
/// A step in an E2E test
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStep { pub struct TestStep {
pub name: String, pub name: String,
@ -79,72 +63,68 @@ pub struct TestStep {
pub error: Option<String>, pub error: Option<String>,
} }
/// Element locator strategies
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Locator { pub enum Locator {
/// CSS selector
Css(String), Css(String),
/// XPath expression
XPath(String), XPath(String),
/// Element ID
Id(String), Id(String),
/// Element name attribute
Name(String), Name(String),
/// Link text
LinkText(String), LinkText(String),
/// Partial link text
PartialLinkText(String), PartialLinkText(String),
/// Tag name
TagName(String), TagName(String),
/// Class name
ClassName(String), ClassName(String),
} }
impl Locator { impl Locator {
#[must_use]
pub fn css(selector: &str) -> Self { pub fn css(selector: &str) -> Self {
Self::Css(selector.to_string()) Self::Css(selector.to_string())
} }
#[must_use]
pub fn xpath(expr: &str) -> Self { pub fn xpath(expr: &str) -> Self {
Self::XPath(expr.to_string()) Self::XPath(expr.to_string())
} }
#[must_use]
pub fn id(id: &str) -> Self { pub fn id(id: &str) -> Self {
Self::Id(id.to_string()) Self::Id(id.to_string())
} }
#[must_use]
pub fn name(name: &str) -> Self { pub fn name(name: &str) -> Self {
Self::Name(name.to_string()) Self::Name(name.to_string())
} }
#[must_use]
pub fn link_text(text: &str) -> Self { pub fn link_text(text: &str) -> Self {
Self::LinkText(text.to_string()) Self::LinkText(text.to_string())
} }
#[must_use]
pub fn class(name: &str) -> Self { pub fn class(name: &str) -> Self {
Self::ClassName(name.to_string()) 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 { pub fn to_css_selector(&self) -> String {
match self { match self {
Locator::Css(s) => s.clone(), Self::Css(s) => s.clone(),
Locator::XPath(_) => { Self::XPath(_) => {
// XPath not directly supported in CSS - log warning and return generic
log::warn!("XPath locators not directly supported in CDP, use CSS selectors"); log::warn!("XPath locators not directly supported in CDP, use CSS selectors");
"*".to_string() "*".to_string()
} }
Locator::Id(s) => format!("#{}", s), Self::Id(s) => format!("#{s}"),
Locator::Name(s) => format!("[name='{}']", s), Self::Name(s) => format!("[name='{s}']"),
Locator::LinkText(s) => format!("a:contains('{}')", s), Self::LinkText(s) => format!("a:contains('{s}')"),
Locator::PartialLinkText(s) => format!("a[href*='{}']", s), Self::PartialLinkText(s) => format!("a[href*='{s}']"),
Locator::TagName(s) => s.clone(), Self::TagName(s) => s.clone(),
Locator::ClassName(s) => format!(".{}", s), Self::ClassName(s) => format!(".{s}"),
} }
} }
} }
/// Keyboard keys for special key presses
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum Key { pub enum Key {
Enter, Enter,
@ -178,7 +158,6 @@ pub enum Key {
Meta, Meta,
} }
/// Mouse button
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum MouseButton { pub enum MouseButton {
Left, Left,
@ -186,33 +165,22 @@ pub enum MouseButton {
Middle, Middle,
} }
/// Wait condition for elements
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WaitCondition { pub enum WaitCondition {
/// Element is present in DOM
Present, Present,
/// Element is visible
Visible, Visible,
/// Element is clickable
Clickable, Clickable,
/// Element is not present
NotPresent, NotPresent,
/// Element is not visible
NotVisible, NotVisible,
/// Element contains text
ContainsText(String), ContainsText(String),
/// Element has attribute value
HasAttribute(String, String), HasAttribute(String, String),
/// Custom JavaScript condition
Script(String), Script(String),
} }
/// Action chain for complex interactions
pub struct ActionChain { pub struct ActionChain {
actions: Vec<Action>, actions: Vec<Action>,
} }
/// Individual action in a chain
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Action { pub enum Action {
Click(Locator), Click(Locator),
@ -230,86 +198,86 @@ pub enum Action {
} }
impl ActionChain { impl ActionChain {
/// Create a new action chain #[must_use]
pub fn new() -> Self { pub const fn new() -> Self {
Self { Self {
actions: Vec::new(), actions: Vec::new(),
} }
} }
/// Add a click action #[must_use]
pub fn click(mut self, locator: Locator) -> Self { pub fn click(mut self, locator: Locator) -> Self {
self.actions.push(Action::Click(locator)); self.actions.push(Action::Click(locator));
self self
} }
/// Add a double click action #[must_use]
pub fn double_click(mut self, locator: Locator) -> Self { pub fn double_click(mut self, locator: Locator) -> Self {
self.actions.push(Action::DoubleClick(locator)); self.actions.push(Action::DoubleClick(locator));
self self
} }
/// Add a right click action #[must_use]
pub fn right_click(mut self, locator: Locator) -> Self { pub fn right_click(mut self, locator: Locator) -> Self {
self.actions.push(Action::RightClick(locator)); self.actions.push(Action::RightClick(locator));
self self
} }
/// Move to an element #[must_use]
pub fn move_to(mut self, locator: Locator) -> Self { pub fn move_to(mut self, locator: Locator) -> Self {
self.actions.push(Action::MoveTo(locator)); self.actions.push(Action::MoveTo(locator));
self self
} }
/// Move by offset #[must_use]
pub fn move_by(mut self, x: i32, y: i32) -> Self { pub fn move_by(mut self, x: i32, y: i32) -> Self {
self.actions.push(Action::MoveByOffset(x, y)); self.actions.push(Action::MoveByOffset(x, y));
self self
} }
/// Press a key down #[must_use]
pub fn key_down(mut self, key: Key) -> Self { pub fn key_down(mut self, key: Key) -> Self {
self.actions.push(Action::KeyDown(key)); self.actions.push(Action::KeyDown(key));
self self
} }
/// Release a key #[must_use]
pub fn key_up(mut self, key: Key) -> Self { pub fn key_up(mut self, key: Key) -> Self {
self.actions.push(Action::KeyUp(key)); self.actions.push(Action::KeyUp(key));
self self
} }
/// Send keys (type text) #[must_use]
pub fn send_keys(mut self, text: &str) -> Self { pub fn send_keys(mut self, text: &str) -> Self {
self.actions.push(Action::SendKeys(text.to_string())); self.actions.push(Action::SendKeys(text.to_string()));
self self
} }
/// Pause for a duration #[must_use]
pub fn pause(mut self, duration: Duration) -> Self { pub fn pause(mut self, duration: Duration) -> Self {
self.actions.push(Action::Pause(duration)); self.actions.push(Action::Pause(duration));
self self
} }
/// Drag and drop #[must_use]
pub fn drag_and_drop(mut self, source: Locator, target: Locator) -> Self { pub fn drag_and_drop(mut self, source: Locator, target: Locator) -> Self {
self.actions.push(Action::DragAndDrop(source, target)); self.actions.push(Action::DragAndDrop(source, target));
self self
} }
/// Scroll to element #[must_use]
pub fn scroll_to(mut self, locator: Locator) -> Self { pub fn scroll_to(mut self, locator: Locator) -> Self {
self.actions.push(Action::ScrollTo(locator)); self.actions.push(Action::ScrollTo(locator));
self self
} }
/// Scroll by amount #[must_use]
pub fn scroll_by(mut self, x: i32, y: i32) -> Self { pub fn scroll_by(mut self, x: i32, y: i32) -> Self {
self.actions.push(Action::ScrollByAmount(x, y)); self.actions.push(Action::ScrollByAmount(x, y));
self self
} }
/// Get the actions #[must_use]
pub fn actions(&self) -> &[Action] { pub fn actions(&self) -> &[Action] {
&self.actions &self.actions
} }
@ -321,7 +289,6 @@ impl Default for ActionChain {
} }
} }
/// Cookie data
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie { pub struct Cookie {
pub name: String, pub name: String,
@ -335,6 +302,7 @@ pub struct Cookie {
} }
impl Cookie { impl Cookie {
#[must_use]
pub fn new(name: &str, value: &str) -> Self { pub fn new(name: &str, value: &str) -> Self {
Self { Self {
name: name.to_string(), name: name.to_string(),
@ -348,22 +316,26 @@ impl Cookie {
} }
} }
#[must_use]
pub fn with_domain(mut self, domain: &str) -> Self { pub fn with_domain(mut self, domain: &str) -> Self {
self.domain = Some(domain.to_string()); self.domain = Some(domain.to_string());
self self
} }
#[must_use]
pub fn with_path(mut self, path: &str) -> Self { pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string()); self.path = Some(path.to_string());
self self
} }
pub fn secure(mut self) -> Self { #[must_use]
pub const fn secure(mut self) -> Self {
self.secure = Some(true); self.secure = Some(true);
self self
} }
pub fn http_only(mut self) -> Self { #[must_use]
pub const fn http_only(mut self) -> Self {
self.http_only = Some(true); self.http_only = Some(true);
self 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 anyhow::Result;
use std::time::Duration; use std::time::Duration;
@ -9,99 +5,83 @@ use std::time::Duration;
use super::browser::{Browser, Element}; use super::browser::{Browser, Element};
use super::Locator; use super::Locator;
/// Base trait for all page objects
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Page { pub trait Page {
/// Get the expected URL pattern for this page
fn url_pattern(&self) -> &str; fn url_pattern(&self) -> &str;
/// Check if we're on this page
async fn is_current(&self, browser: &Browser) -> Result<bool> { async fn is_current(&self, browser: &Browser) -> Result<bool> {
let url = browser.current_url().await?; let url = browser.current_url().await?;
Ok(url.contains(self.url_pattern())) Ok(url.contains(self.url_pattern()))
} }
/// Wait for the page to be fully loaded
async fn wait_for_load(&self, browser: &Browser) -> Result<()>; async fn wait_for_load(&self, browser: &Browser) -> Result<()>;
} }
// =============================================================================
// Login Page
// =============================================================================
/// Login page object
pub struct LoginPage { pub struct LoginPage {
pub base_url: String, pub base_url: String,
} }
impl LoginPage { impl LoginPage {
/// Create a new login page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to the login page
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/login", self.base_url)).await browser.goto(&format!("{}/login", self.base_url)).await
} }
/// Email input locator #[must_use]
pub fn email_input() -> Locator { pub fn email_input() -> Locator {
Locator::css("#email, input[name='email'], input[type='email']") Locator::css("#email, input[name='email'], input[type='email']")
} }
/// Password input locator #[must_use]
pub fn password_input() -> Locator { pub fn password_input() -> Locator {
Locator::css("#password, input[name='password'], input[type='password']") Locator::css("#password, input[name='password'], input[type='password']")
} }
/// Login button locator #[must_use]
pub fn login_button() -> Locator { pub fn login_button() -> Locator {
Locator::css( Locator::css(
"#login-button, button[type='submit'], input[type='submit'], .login-btn, .btn-login", "#login-button, button[type='submit'], input[type='submit'], .login-btn, .btn-login",
) )
} }
/// Error message locator #[must_use]
pub fn error_message() -> Locator { pub fn error_message() -> Locator {
Locator::css(".error, .error-message, .alert-error, .alert-danger, [role='alert']") Locator::css(".error, .error-message, .alert-error, .alert-danger, [role='alert']")
} }
/// Enter email
pub async fn enter_email(&self, browser: &Browser, email: &str) -> Result<()> { pub async fn enter_email(&self, browser: &Browser, email: &str) -> Result<()> {
browser.fill(Self::email_input(), email).await browser.fill(Self::email_input(), email).await
} }
/// Enter password
pub async fn enter_password(&self, browser: &Browser, password: &str) -> Result<()> { pub async fn enter_password(&self, browser: &Browser, password: &str) -> Result<()> {
browser.fill(Self::password_input(), password).await browser.fill(Self::password_input(), password).await
} }
/// Click login button
pub async fn click_login(&self, browser: &Browser) -> Result<()> { pub async fn click_login(&self, browser: &Browser) -> Result<()> {
browser.click(Self::login_button()).await browser.click(Self::login_button()).await
} }
/// Perform full login
pub async fn login(&self, browser: &Browser, email: &str, password: &str) -> Result<()> { pub async fn login(&self, browser: &Browser, email: &str, password: &str) -> Result<()> {
self.navigate(browser).await?; self.navigate(browser).await?;
self.wait_for_load(browser).await?; self.wait_for_load(browser).await?;
self.enter_email(browser, email).await?; self.enter_email(browser, email).await?;
self.enter_password(browser, password).await?; self.enter_password(browser, password).await?;
self.click_login(browser).await?; self.click_login(browser).await?;
// Wait for navigation
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
Ok(()) Ok(())
} }
/// Check if error message is displayed
pub async fn has_error(&self, browser: &Browser) -> bool { pub async fn has_error(&self, browser: &Browser) -> bool {
browser.exists(Self::error_message()).await browser.exists(Self::error_message()).await
} }
/// Get error message text
pub async fn get_error_message(&self, browser: &Browser) -> Result<String> { pub async fn get_error_message(&self, browser: &Browser) -> Result<String> {
browser.text(Self::error_message()).await browser.text(Self::error_message()).await
} }
@ -109,7 +89,7 @@ impl LoginPage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for LoginPage { impl Page for LoginPage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/login" "/login"
} }
@ -120,65 +100,55 @@ impl Page for LoginPage {
} }
} }
// =============================================================================
// Dashboard Page
// =============================================================================
/// Dashboard home page object
pub struct DashboardPage { pub struct DashboardPage {
pub base_url: String, pub base_url: String,
} }
impl DashboardPage { impl DashboardPage {
/// Create a new dashboard page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to the dashboard
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/dashboard", self.base_url)).await browser.goto(&format!("{}/dashboard", self.base_url)).await
} }
/// Stats cards container locator #[must_use]
/// Stats cards locator
pub fn stats_cards() -> Locator { pub fn stats_cards() -> Locator {
Locator::css(".stats-card, .dashboard-stat, .metric-card") Locator::css(".stats-card, .dashboard-stat, .metric-card")
} }
/// Navigation menu locator #[must_use]
pub fn nav_menu() -> Locator { pub fn nav_menu() -> Locator {
Locator::css("nav, .nav, .sidebar, .navigation") Locator::css("nav, .nav, .sidebar, .navigation")
} }
/// User profile button locator #[must_use]
pub fn user_profile() -> Locator { pub fn user_profile() -> Locator {
Locator::css(".user-profile, .user-menu, .profile-dropdown, .avatar") Locator::css(".user-profile, .user-menu, .profile-dropdown, .avatar")
} }
/// Logout button locator #[must_use]
pub fn logout_button() -> Locator { pub fn logout_button() -> Locator {
Locator::css(".logout, .logout-btn, #logout, a[href*='logout'], button:contains('Logout')") 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>> { pub async fn get_nav_items(&self, browser: &Browser) -> Result<Vec<Element>> {
browser browser
.find_all(Locator::css("nav a, .nav-item, .menu-item")) .find_all(Locator::css("nav a, .nav-item, .menu-item"))
.await .await
} }
/// Click a navigation item by text
pub async fn navigate_to(&self, browser: &Browser, menu_text: &str) -> Result<()> { 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 browser.click(locator).await
} }
/// Click logout
pub async fn logout(&self, browser: &Browser) -> Result<()> { pub async fn logout(&self, browser: &Browser) -> Result<()> {
// First try to open user menu if needed
if browser.exists(Self::user_profile()).await { if browser.exists(Self::user_profile()).await {
let _ = browser.click(Self::user_profile()).await; let _ = browser.click(Self::user_profile()).await;
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
@ -189,7 +159,7 @@ impl DashboardPage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for DashboardPage { impl Page for DashboardPage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/dashboard" "/dashboard"
} }
@ -199,18 +169,14 @@ impl Page for DashboardPage {
} }
} }
// =============================================================================
// Chat Page
// =============================================================================
/// Chat interface page object
pub struct ChatPage { pub struct ChatPage {
pub base_url: String, pub base_url: String,
pub bot_name: String, pub bot_name: String,
} }
impl ChatPage { impl ChatPage {
/// Create a new chat page object #[must_use]
pub fn new(base_url: &str, bot_name: &str) -> Self { pub fn new(base_url: &str, bot_name: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), 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<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser browser
.goto(&format!("{}/chat/{}", self.base_url, self.bot_name)) .goto(&format!("{}/chat/{}", self.base_url, self.bot_name))
.await .await
} }
/// Chat input locator #[must_use]
/// Chat input field locator
pub fn chat_input() -> Locator { pub fn chat_input() -> Locator {
Locator::css( Locator::css(
"#chat-input, .chat-input, input[name='message'], textarea[name='message'], .message-input", "#chat-input, .chat-input, input[name='message'], textarea[name='message'], .message-input",
) )
} }
/// Send button locator #[must_use]
pub fn send_button() -> Locator { pub fn send_button() -> Locator {
Locator::css("#send, .send-btn, button[type='submit'], .send-message") Locator::css("#send, .send-btn, button[type='submit'], .send-message")
} }
/// Message list container locator #[must_use]
pub fn message_list() -> Locator { pub fn message_list() -> Locator {
Locator::css(".messages, .message-list, .chat-messages, #messages") Locator::css(".messages, .message-list, .chat-messages, #messages")
} }
/// Bot message locator #[must_use]
pub fn bot_message() -> Locator { pub fn bot_message() -> Locator {
Locator::css(".bot-message, .message-bot, .assistant-message, [data-role='bot']") Locator::css(".bot-message, .message-bot, .assistant-message, [data-role='bot']")
} }
/// User message locator #[must_use]
pub fn user_message() -> Locator { pub fn user_message() -> Locator {
Locator::css(".user-message, .message-user, [data-role='user']") Locator::css(".user-message, .message-user, [data-role='user']")
} }
/// Typing indicator locator #[must_use]
pub fn typing_indicator() -> Locator { pub fn typing_indicator() -> Locator {
Locator::css(".typing, .typing-indicator, .is-typing, [data-typing]") Locator::css(".typing, .typing-indicator, .is-typing, [data-typing]")
} }
/// File upload button locator #[must_use]
pub fn file_upload_button() -> Locator { pub fn file_upload_button() -> Locator {
Locator::css(".upload-btn, .file-upload, input[type='file'], .attach-file") Locator::css(".upload-btn, .file-upload, input[type='file'], .attach-file")
} }
/// Quick reply buttons locator #[must_use]
pub fn quick_reply_buttons() -> Locator { pub fn quick_reply_buttons() -> Locator {
Locator::css(".quick-replies, .quick-reply, .suggested-reply") Locator::css(".quick-replies, .quick-reply, .suggested-reply")
} }
/// Send a message
pub async fn send_message(&self, browser: &Browser, message: &str) -> Result<()> { pub async fn send_message(&self, browser: &Browser, message: &str) -> Result<()> {
browser.fill(Self::chat_input(), message).await?; browser.fill(Self::chat_input(), message).await?;
browser.click(Self::send_button()).await?; browser.click(Self::send_button()).await?;
Ok(()) Ok(())
} }
/// Wait for bot response
pub async fn wait_for_response(&self, browser: &Browser, timeout: Duration) -> Result<()> { pub async fn wait_for_response(&self, browser: &Browser, timeout: Duration) -> Result<()> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// First wait for typing indicator to appear
while start.elapsed() < timeout { while start.elapsed() < timeout {
if browser.exists(Self::typing_indicator()).await { if browser.exists(Self::typing_indicator()).await {
break; break;
@ -287,7 +248,6 @@ impl ChatPage {
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
// Then wait for typing indicator to disappear
while start.elapsed() < timeout { while start.elapsed() < timeout {
if !browser.exists(Self::typing_indicator()).await { if !browser.exists(Self::typing_indicator()).await {
return Ok(()); return Ok(());
@ -298,7 +258,6 @@ impl ChatPage {
anyhow::bail!("Timeout waiting for bot response") anyhow::bail!("Timeout waiting for bot response")
} }
/// Get all bot messages
pub async fn get_bot_messages(&self, browser: &Browser) -> Result<Vec<String>> { pub async fn get_bot_messages(&self, browser: &Browser) -> Result<Vec<String>> {
let elements = browser.find_all(Self::bot_message()).await?; let elements = browser.find_all(Self::bot_message()).await?;
let mut messages = Vec::new(); let mut messages = Vec::new();
@ -310,7 +269,6 @@ impl ChatPage {
Ok(messages) Ok(messages)
} }
/// Get all user messages
pub async fn get_user_messages(&self, browser: &Browser) -> Result<Vec<String>> { pub async fn get_user_messages(&self, browser: &Browser) -> Result<Vec<String>> {
let elements = browser.find_all(Self::user_message()).await?; let elements = browser.find_all(Self::user_message()).await?;
let mut messages = Vec::new(); let mut messages = Vec::new();
@ -322,7 +280,6 @@ impl ChatPage {
Ok(messages) Ok(messages)
} }
/// Get the last bot message
pub async fn get_last_bot_message(&self, browser: &Browser) -> Result<String> { pub async fn get_last_bot_message(&self, browser: &Browser) -> Result<String> {
let messages = self.get_bot_messages(browser).await?; let messages = self.get_bot_messages(browser).await?;
messages messages
@ -331,16 +288,13 @@ impl ChatPage {
.ok_or_else(|| anyhow::anyhow!("No bot messages found")) .ok_or_else(|| anyhow::anyhow!("No bot messages found"))
} }
/// Check if typing indicator is visible
pub async fn is_typing(&self, browser: &Browser) -> bool { pub async fn is_typing(&self, browser: &Browser) -> bool {
browser.exists(Self::typing_indicator()).await 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<()> { pub async fn click_quick_reply(&self, browser: &Browser, text: &str) -> Result<()> {
let locator = Locator::xpath(&format!( let locator = Locator::xpath(&format!(
"//*[contains(@class, 'quick-reply') and contains(text(), '{}')]", "//*[contains(@class, 'quick-reply') and contains(text(), '{text}')]"
text
)); ));
browser.click(locator).await browser.click(locator).await
} }
@ -348,7 +302,7 @@ impl ChatPage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for ChatPage { impl Page for ChatPage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/chat/" "/chat/"
} }
@ -359,61 +313,53 @@ impl Page for ChatPage {
} }
} }
// =============================================================================
// Queue Panel Page
// =============================================================================
/// Queue management panel page object
pub struct QueuePage { pub struct QueuePage {
pub base_url: String, pub base_url: String,
} }
impl QueuePage { impl QueuePage {
/// Create a new queue page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to the queue panel
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/queue", self.base_url)).await browser.goto(&format!("{}/queue", self.base_url)).await
} }
/// Queue panel container locator #[must_use]
pub fn queue_panel() -> Locator { pub fn queue_panel() -> Locator {
Locator::css(".queue-panel, .queue-container, #queue-panel") Locator::css(".queue-panel, .queue-container, #queue-panel")
} }
/// Queue count display locator #[must_use]
pub fn queue_count() -> Locator { pub fn queue_count() -> Locator {
Locator::css(".queue-count, .waiting-count, #queue-count") Locator::css(".queue-count, .waiting-count, #queue-count")
} }
/// Queue entry locator #[must_use]
pub fn queue_entry() -> Locator { pub fn queue_entry() -> Locator {
Locator::css(".queue-entry, .queue-item, .waiting-customer") Locator::css(".queue-entry, .queue-item, .waiting-customer")
} }
/// Take next button locator #[must_use]
pub fn take_next_button() -> Locator { pub fn take_next_button() -> Locator {
Locator::css(".take-next, #take-next, button:contains('Take Next')") Locator::css(".take-next, #take-next, button:contains('Take Next')")
} }
/// Get queue count
pub async fn get_queue_count(&self, browser: &Browser) -> Result<u32> { pub async fn get_queue_count(&self, browser: &Browser) -> Result<u32> {
let text = browser.text(Self::queue_count()).await?; let text = browser.text(Self::queue_count()).await?;
text.parse::<u32>() 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>> { pub async fn get_queue_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::queue_entry()).await browser.find_all(Self::queue_entry()).await
} }
/// Click take next button
pub async fn take_next(&self, browser: &Browser) -> Result<()> { pub async fn take_next(&self, browser: &Browser) -> Result<()> {
browser.click(Self::take_next_button()).await browser.click(Self::take_next_button()).await
} }
@ -421,7 +367,7 @@ impl QueuePage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for QueuePage { impl Page for QueuePage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/queue" "/queue"
} }
@ -431,69 +377,61 @@ impl Page for QueuePage {
} }
} }
// =============================================================================
// Bot Management Page
// =============================================================================
/// Bot management page object
pub struct BotManagementPage { pub struct BotManagementPage {
pub base_url: String, pub base_url: String,
} }
impl BotManagementPage { impl BotManagementPage {
/// Create a new bot management page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to bot management
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/admin/bots", self.base_url)).await browser.goto(&format!("{}/admin/bots", self.base_url)).await
} }
/// Bot list container locator #[must_use]
pub fn bot_list() -> Locator { pub fn bot_list() -> Locator {
Locator::css(".bot-list, .bots-container, #bots") Locator::css(".bot-list, .bots-container, #bots")
} }
/// Bot item locator #[must_use]
pub fn bot_item() -> Locator { pub fn bot_item() -> Locator {
Locator::css(".bot-item, .bot-card, .bot-entry") Locator::css(".bot-item, .bot-card, .bot-entry")
} }
/// Create bot button locator #[must_use]
pub fn create_bot_button() -> Locator { pub fn create_bot_button() -> Locator {
Locator::css(".create-bot, .new-bot, #create-bot, button:contains('Create')") Locator::css(".create-bot, .new-bot, #create-bot, button:contains('Create')")
} }
/// Bot name input locator #[must_use]
pub fn bot_name_input() -> Locator { pub fn bot_name_input() -> Locator {
Locator::css("#bot-name, input[name='name'], .bot-name-input") Locator::css("#bot-name, input[name='name'], .bot-name-input")
} }
/// Bot description input locator #[must_use]
pub fn bot_description_input() -> Locator { pub fn bot_description_input() -> Locator {
Locator::css("#bot-description, textarea[name='description'], .bot-description-input") Locator::css("#bot-description, textarea[name='description'], .bot-description-input")
} }
/// Save button locator #[must_use]
pub fn save_button() -> Locator { pub fn save_button() -> Locator {
Locator::css(".save-btn, button[type='submit'], #save, button:contains('Save')") 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>> { pub async fn get_bots(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::bot_item()).await browser.find_all(Self::bot_item()).await
} }
/// Click create bot button
pub async fn click_create_bot(&self, browser: &Browser) -> Result<()> { pub async fn click_create_bot(&self, browser: &Browser) -> Result<()> {
browser.click(Self::create_bot_button()).await browser.click(Self::create_bot_button()).await
} }
/// Create a new bot
pub async fn create_bot(&self, browser: &Browser, name: &str, description: &str) -> Result<()> { pub async fn create_bot(&self, browser: &Browser, name: &str, description: &str) -> Result<()> {
self.click_create_bot(browser).await?; self.click_create_bot(browser).await?;
tokio::time::sleep(Duration::from_millis(300)).await; tokio::time::sleep(Duration::from_millis(300)).await;
@ -505,12 +443,9 @@ impl BotManagementPage {
Ok(()) 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<()> { pub async fn edit_bot(&self, browser: &Browser, bot_name: &str) -> Result<()> {
let locator = Locator::xpath(&format!( let locator = Locator::xpath(&format!(
"//*[contains(@class, 'bot-item') and contains(., '{}')]//button[contains(@class, 'edit')]", "//*[contains(@class, 'bot-item') and contains(., '{bot_name}')]//button[contains(@class, 'edit')]"
bot_name
)); ));
browser.click(locator).await browser.click(locator).await
} }
@ -518,7 +453,7 @@ impl BotManagementPage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for BotManagementPage { impl Page for BotManagementPage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/admin/bots" "/admin/bots"
} }
@ -528,60 +463,52 @@ impl Page for BotManagementPage {
} }
} }
// =============================================================================
// Knowledge Base Page
// =============================================================================
/// Knowledge base management page object
pub struct KnowledgeBasePage { pub struct KnowledgeBasePage {
pub base_url: String, pub base_url: String,
} }
impl KnowledgeBasePage { impl KnowledgeBasePage {
/// Create a new knowledge base page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to knowledge base
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser.goto(&format!("{}/admin/kb", self.base_url)).await browser.goto(&format!("{}/admin/kb", self.base_url)).await
} }
/// KB entries list locator #[must_use]
/// KB list container locator
pub fn kb_list() -> Locator { pub fn kb_list() -> Locator {
Locator::css(".kb-list, .knowledge-base-list, #kb-list") Locator::css(".kb-list, .knowledge-base-list, #kb-list")
} }
/// KB entry locator #[must_use]
pub fn kb_entry() -> Locator { pub fn kb_entry() -> Locator {
Locator::css(".kb-entry, .kb-item, .knowledge-entry") Locator::css(".kb-entry, .kb-item, .knowledge-entry")
} }
/// Upload button locator #[must_use]
pub fn upload_button() -> Locator { pub fn upload_button() -> Locator {
Locator::css(".upload-btn, #upload, button:contains('Upload')") Locator::css(".upload-btn, #upload, button:contains('Upload')")
} }
/// File input locator #[must_use]
pub fn file_input() -> Locator { pub fn file_input() -> Locator {
Locator::css("input[type='file']") Locator::css("input[type='file']")
} }
/// Search input locator #[must_use]
pub fn search_input() -> Locator { pub fn search_input() -> Locator {
Locator::css(".search-input, #search, input[placeholder*='search']") Locator::css(".search-input, #search, input[placeholder*='search']")
} }
/// Get all KB entries
pub async fn get_entries(&self, browser: &Browser) -> Result<Vec<Element>> { pub async fn get_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::kb_entry()).await browser.find_all(Self::kb_entry()).await
} }
/// Search the knowledge base
pub async fn search(&self, browser: &Browser, query: &str) -> Result<()> { pub async fn search(&self, browser: &Browser, query: &str) -> Result<()> {
browser.fill(Self::search_input(), query).await browser.fill(Self::search_input(), query).await
} }
@ -589,7 +516,7 @@ impl KnowledgeBasePage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for KnowledgeBasePage { impl Page for KnowledgeBasePage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/admin/kb" "/admin/kb"
} }
@ -599,46 +526,40 @@ impl Page for KnowledgeBasePage {
} }
} }
// =============================================================================
// Analytics Page
// =============================================================================
/// Analytics dashboard page object
pub struct AnalyticsPage { pub struct AnalyticsPage {
pub base_url: String, pub base_url: String,
} }
impl AnalyticsPage { impl AnalyticsPage {
/// Create a new analytics page object #[must_use]
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { Self {
base_url: base_url.to_string(), base_url: base_url.to_string(),
} }
} }
/// Navigate to analytics
pub async fn navigate(&self, browser: &Browser) -> Result<()> { pub async fn navigate(&self, browser: &Browser) -> Result<()> {
browser browser
.goto(&format!("{}/admin/analytics", self.base_url)) .goto(&format!("{}/admin/analytics", self.base_url))
.await .await
} }
/// Charts container locator #[must_use]
pub fn charts_container() -> Locator { pub fn charts_container() -> Locator {
Locator::css(".charts, .analytics-charts, #charts") Locator::css(".charts, .analytics-charts, #charts")
} }
/// Date range picker locator #[must_use]
pub fn date_range_picker() -> Locator { pub fn date_range_picker() -> Locator {
Locator::css(".date-range, .date-picker, #date-range") Locator::css(".date-range, .date-picker, #date-range")
} }
/// Metric card locator #[must_use]
pub fn metric_card() -> Locator { pub fn metric_card() -> Locator {
Locator::css(".metric-card, .analytics-metric, .stat-card") Locator::css(".metric-card, .analytics-metric, .stat-card")
} }
/// Get all metric cards
pub async fn get_metrics(&self, browser: &Browser) -> Result<Vec<Element>> { pub async fn get_metrics(&self, browser: &Browser) -> Result<Vec<Element>> {
browser.find_all(Self::metric_card()).await browser.find_all(Self::metric_card()).await
} }
@ -646,7 +567,7 @@ impl AnalyticsPage {
#[async_trait::async_trait] #[async_trait::async_trait]
impl Page for AnalyticsPage { impl Page for AnalyticsPage {
fn url_pattern(&self) -> &str { fn url_pattern(&self) -> &'static str {
"/admin/analytics" "/admin/analytics"
} }
@ -656,9 +577,6 @@ impl Page for AnalyticsPage {
} }
} }
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View file

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

View file

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

View file

@ -759,8 +759,6 @@ async fn test_with_fixtures() {
return; 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 { let ctx = match E2ETestContext::setup().await {
Ok(ctx) => ctx, Ok(ctx) => ctx,
Err(e) => { Err(e) => {
@ -773,10 +771,12 @@ async fn test_with_fixtures() {
let bot = bot_with_kb("e2e-test-bot"); let bot = bot_with_kb("e2e-test-bot");
let customer = customer("+15551234567"); 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 { match ctx.ctx.insert_user(&user).await {
Ok(_) => println!("Inserted test user: {}", user.email), 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 { match ctx.ctx.insert_bot(&bot).await {
@ -799,9 +799,6 @@ async fn test_mock_services_available() {
return; 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 { let ctx = match E2ETestContext::setup().await {
Ok(ctx) => ctx, Ok(ctx) => ctx,
Err(e) => { 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() { if ctx.ctx.mock_llm().is_some() {
println!("✓ MockLLM is available"); println!("✓ MockLLM is available");
} else { } else {
@ -823,23 +819,17 @@ async fn test_mock_services_available() {
eprintln!("MockZitadel not 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 { if ctx.ctx.use_existing_stack {
println!("Using existing stack - PostgreSQL is external (not managed by harness)"); 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 { match ctx.ctx.db_pool().await {
Ok(_pool) => println!("✓ Connected to existing PostgreSQL"), Ok(_pool) => println!("✓ Connected to existing PostgreSQL"),
Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e), Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e),
} }
} else { } else if ctx.ctx.postgres().is_some() {
// Fresh stack mode - harness starts PostgreSQL
if ctx.ctx.postgres().is_some() {
println!("✓ PostgreSQL is managed by harness"); println!("✓ PostgreSQL is managed by harness");
} else { } else {
eprintln!("PostgreSQL should be started in fresh stack mode"); eprintln!("PostgreSQL should be started in fresh stack mode");
} }
}
ctx.close().await; ctx.close().await;
} }

View file

@ -16,7 +16,6 @@ pub struct E2ETestContext {
browser_service: Option<BrowserService>, browser_service: Option<BrowserService>,
} }
/// Check if a service is running at the given URL
async fn is_service_running(url: &str) -> bool { async fn is_service_running(url: &str) -> bool {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(2)) .timeout(std::time::Duration::from_secs(2))
@ -24,8 +23,7 @@ async fn is_service_running(url: &str) -> bool {
.build() .build()
.unwrap_or_default(); .unwrap_or_default();
// Try health endpoint first, then root if let Ok(resp) = client.get(format!("{url}/health")).send().await {
if let Ok(resp) = client.get(&format!("{}/health", url)).send().await {
if resp.status().is_success() { if resp.status().is_success() {
return true; return true;
} }
@ -38,15 +36,6 @@ async fn is_service_running(url: &str) -> bool {
impl E2ETestContext { impl E2ETestContext {
pub async fn setup() -> anyhow::Result<Self> { 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 = let botserver_url =
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string()); std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
let botui_url = let botui_url =
@ -55,20 +44,16 @@ impl E2ETestContext {
let botserver_running = is_service_running(&botserver_url).await; let botserver_running = is_service_running(&botserver_url).await;
let botui_running = is_service_running(&botui_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?; let ctx = TestHarness::with_existing_stack().await?;
// Check if botserver is running, if not start it with main stack
let server = if botserver_running { let server = if botserver_running {
println!("🔗 Using existing BotServer at {}", botserver_url); println!("🔗 Using existing BotServer at {}", botserver_url);
BotServerInstance::existing(&botserver_url) BotServerInstance::existing(&botserver_url)
} else { } else {
// Auto-start botserver with main stack (includes LLM)
println!("🚀 Auto-starting BotServer with main stack..."); println!("🚀 Auto-starting BotServer with main stack...");
BotServerInstance::start_with_main_stack().await? BotServerInstance::start_with_main_stack().await?
}; };
// Ensure botui is running (required for chat UI)
let ui = if botui_running { let ui = if botui_running {
println!("🔗 Using existing BotUI at {}", botui_url); println!("🔗 Using existing BotUI at {}", botui_url);
Some(BotUIInstance::existing(&botui_url)) Some(BotUIInstance::existing(&botui_url))
@ -100,15 +85,6 @@ impl E2ETestContext {
} }
pub async fn setup_with_browser() -> anyhow::Result<Self> { 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 = let botserver_url =
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string()); std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
let botui_url = let botui_url =
@ -117,20 +93,16 @@ impl E2ETestContext {
let botserver_running = is_service_running(&botserver_url).await; let botserver_running = is_service_running(&botserver_url).await;
let botui_running = is_service_running(&botui_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?; let ctx = TestHarness::with_existing_stack().await?;
// Check if botserver is running, if not start it with main stack
let server = if botserver_running { let server = if botserver_running {
println!("🔗 Using existing BotServer at {}", botserver_url); println!("🔗 Using existing BotServer at {}", botserver_url);
BotServerInstance::existing(&botserver_url) BotServerInstance::existing(&botserver_url)
} else { } else {
// Auto-start botserver with main stack (includes LLM)
println!("🚀 Auto-starting BotServer with main stack..."); println!("🚀 Auto-starting BotServer with main stack...");
BotServerInstance::start_with_main_stack().await? BotServerInstance::start_with_main_stack().await?
}; };
// Ensure botui is running (required for chat UI)
let ui = if botui_running { let ui = if botui_running {
println!("🔗 Using existing BotUI at {}", botui_url); println!("🔗 Using existing BotUI at {}", botui_url);
Some(BotUIInstance::existing(&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 { let browser_service = match BrowserService::start(DEFAULT_DEBUG_PORT).await {
Ok(bs) => { Ok(bs) => {
log::info!("Browser started with CDP on port {}", DEFAULT_DEBUG_PORT); 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 { pub fn base_url(&self) -> &str {
if let Some(ref ui) = self.ui { if let Some(ref ui) = self.ui {
&ui.url &ui.url
@ -201,7 +171,6 @@ impl E2ETestContext {
} }
} }
/// Get the botserver API URL
pub fn api_url(&self) -> &str { pub fn api_url(&self) -> &str {
&self.server.url &self.server.url
} }
@ -212,7 +181,7 @@ impl E2ETestContext {
pub async fn close(mut self) { pub async fn close(mut self) {
if let Some(browser) = self.browser { if let Some(browser) = self.browser {
let _ = browser.close().await; let _ = browser.close();
} }
if let Some(mut bs) = self.browser_service.take() { if let Some(mut bs) = self.browser_service.take() {
let _ = bs.stop().await; let _ = bs.stop().await;
@ -221,19 +190,16 @@ impl E2ETestContext {
} }
pub fn browser_config() -> BrowserConfig { 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 headless = std::env::var("HEADLESS").is_ok();
let debug_port = std::env::var("CDP_PORT") let debug_port = std::env::var("CDP_PORT")
.ok() .ok()
.and_then(|p| p.parse().ok()) .and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_DEBUG_PORT); .unwrap_or(DEFAULT_DEBUG_PORT);
// Use CDP directly - no chromedriver needed!
BrowserConfig::default() BrowserConfig::default()
.with_browser(BrowserType::Chrome) .with_browser(BrowserType::Chrome)
.with_debug_port(debug_port) .with_debug_port(debug_port)
.headless(headless) // false by default = show browser .headless(headless)
.with_timeout(Duration::from_secs(30)) .with_timeout(Duration::from_secs(30))
.with_window_size(1920, 1080) .with_window_size(1920, 1080)
} }
@ -301,7 +267,6 @@ async fn test_harness_starts_server() {
return; return;
} }
// This test explicitly starts a new server - only run with FRESH_STACK=1
if std::env::var("FRESH_STACK").is_err() { if std::env::var("FRESH_STACK").is_err() {
eprintln!("Skipping: test_harness_starts_server requires FRESH_STACK=1 (uses existing stack by default)"); eprintln!("Skipping: test_harness_starts_server requires FRESH_STACK=1 (uses existing stack by default)");
return; return;
@ -335,7 +300,6 @@ async fn test_harness_starts_server() {
#[tokio::test] #[tokio::test]
async fn test_full_harness_has_all_services() { 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() { 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)"); eprintln!("Skipping: test_full_harness_has_all_services requires FRESH_STACK=1 (uses existing stack by default)");
return; 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.postgres().is_some(), "PostgreSQL should be available");
assert!(ctx.mock_llm().is_some(), "MockLLM should be available"); assert!(ctx.mock_llm().is_some(), "MockLLM should be available");
assert!( assert!(
@ -357,17 +320,12 @@ async fn test_full_harness_has_all_services() {
"MockZitadel should be available" "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.exists());
assert!(ctx.data_dir.to_str().unwrap().contains("bottest-")); assert!(ctx.data_dir.to_str().unwrap().contains("bottest-"));
} }
#[tokio::test] #[tokio::test]
async fn test_e2e_cleanup() { 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 { let mut ctx = match TestHarness::full().await {
Ok(ctx) => ctx, Ok(ctx) => ctx,
Err(e) => { Err(e) => {
@ -384,7 +342,6 @@ async fn test_e2e_cleanup() {
assert!(!data_dir.exists()); assert!(!data_dir.exists());
} }
/// Test that checks the existing running stack is accessible
#[tokio::test] #[tokio::test]
async fn test_existing_stack_connection() { async fn test_existing_stack_connection() {
if !should_run_e2e_tests() { if !should_run_e2e_tests() {
@ -392,10 +349,8 @@ async fn test_existing_stack_connection() {
return; return;
} }
// Use existing stack by default
match E2ETestContext::setup().await { match E2ETestContext::setup().await {
Ok(ctx) => { Ok(ctx) => {
// Check botserver is accessible
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.build() .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::prelude::*;
use bottest::web::{Browser, Locator}; use bottest::web::{Browser, Locator};
@ -13,14 +5,9 @@ use std::time::Duration;
use super::{check_webdriver_available, should_run_e2e_tests, E2ETestContext}; 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<()> { pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// Check health endpoint
let health_url = format!("{}/health", ctx.base_url()); let health_url = format!("{}/health", ctx.base_url());
let health_resp = client.get(&health_url).send().await?; let health_resp = client.get(&health_url).send().await?;
assert!( assert!(
@ -31,7 +18,6 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
println!("✓ Platform health check passed"); println!("✓ Platform health check passed");
// Verify API is responsive
let api_url = format!("{}/api/v1", ctx.base_url()); let api_url = format!("{}/api/v1", ctx.base_url());
let api_resp = client.get(&api_url).send().await?; let api_resp = client.get(&api_url).send().await?;
assert!( assert!(
@ -45,19 +31,13 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
Ok(()) 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<()> { pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// Check if server is actually running
assert!(ctx.server.is_running(), "BotServer process is not running"); assert!(ctx.server.is_running(), "BotServer process is not running");
println!("✓ BotServer process running"); println!("✓ BotServer process running");
// Verify server info endpoint
let info_url = format!("{}/api/v1/server/info", ctx.base_url()); let info_url = format!("{}/api/v1/server/info", ctx.base_url());
match client.get(&info_url).send().await { match client.get(&info_url).send().await {
Ok(resp) => { Ok(resp) => {
@ -88,25 +68,17 @@ pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()
Ok(()) 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<()> { pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
let login_url = format!("{}/login", ctx.base_url()); let login_url = format!("{}/login", ctx.base_url());
// Navigate to login page
browser.goto(&login_url).await?; browser.goto(&login_url).await?;
println!("✓ Navigated to login page: {}", login_url); println!("✓ Navigated to login page: {}", login_url);
// Wait for login form to be visible
browser browser
.wait_for(Locator::css("input[type='email']")) .wait_for(Locator::css("input[type='email']"))
.await?; .await?;
println!("✓ Login form loaded"); println!("✓ Login form loaded");
// Fill in test credentials
let test_email = "test@example.com"; let test_email = "test@example.com";
let test_password = "TestPassword123!"; let test_password = "TestPassword123!";
@ -120,17 +92,13 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
.await?; .await?;
println!("✓ Entered password"); println!("✓ Entered password");
// Submit login form
browser.click(Locator::css("button[type='submit']")).await?; browser.click(Locator::css("button[type='submit']")).await?;
println!("✓ Clicked login button"); println!("✓ Clicked login button");
// Wait a bit for redirect
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
// Get current URL
let current_url = browser.current_url().await?; let current_url = browser.current_url().await?;
// Check we're not on login page anymore
assert!( assert!(
!current_url.contains("/login"), !current_url.contains("/login"),
"Still on login page after login attempt. URL: {}", "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); println!("✓ Redirected from login page to: {}", current_url);
// Verify we can see dashboard or chat area
browser browser
.wait_for(Locator::css( .wait_for(Locator::css(
"[data-testid='chat-area'], [data-testid='dashboard'], main", "[data-testid='chat-area'], [data-testid='dashboard'], main",
@ -150,18 +117,11 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
Ok(()) 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<()> { 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()); let chat_url = format!("{}/chat", ctx.base_url());
browser.goto(&chat_url).await?; browser.goto(&chat_url).await?;
println!("✓ Navigated to chat page"); println!("✓ Navigated to chat page");
// Wait for chat interface to load
browser browser
.wait_for(Locator::css( .wait_for(Locator::css(
"[data-testid='message-input'], textarea.chat-input, input.message", "[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?; .await?;
println!("✓ Chat interface loaded"); println!("✓ Chat interface loaded");
// Send test message
let test_message = "Hello, I need help"; let test_message = "Hello, I need help";
browser browser
.fill( .fill(
@ -179,7 +138,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?; .await?;
println!("✓ Typed message: {}", test_message); println!("✓ Typed message: {}", test_message);
// Click send button
let send_result = browser let send_result = browser
.click(Locator::css( .click(Locator::css(
"button[data-testid='send-button'], button.send-btn", "button[data-testid='send-button'], button.send-btn",
@ -187,7 +145,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await; .await;
if send_result.is_err() { if send_result.is_err() {
// Try pressing Enter as alternative - find the input and send Enter key
let input = browser let input = browser
.find(Locator::css("textarea.chat-input, input.message")) .find(Locator::css("textarea.chat-input, input.message"))
.await?; .await?;
@ -197,7 +154,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
println!("✓ Clicked send button"); println!("✓ Clicked send button");
} }
// Wait for message to appear in chat history
browser browser
.wait_for(Locator::css( .wait_for(Locator::css(
"[data-testid='message-item'], .message-bubble, [class*='message']", "[data-testid='message-item'], .message-bubble, [class*='message']",
@ -205,7 +161,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?; .await?;
println!("✓ Message appeared in chat"); println!("✓ Message appeared in chat");
// Wait for bot response
browser browser
.wait_for(Locator::css( .wait_for(Locator::css(
"[data-testid='bot-response'], .bot-message, [class*='bot']", "[data-testid='bot-response'], .bot-message, [class*='bot']",
@ -213,7 +168,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
.await?; .await?;
println!("✓ Received bot response"); println!("✓ Received bot response");
// Get response text
let response_text = browser let response_text = browser
.text(Locator::css( .text(Locator::css(
"[data-testid='bot-response'], .bot-message, [class*='bot']", "[data-testid='bot-response'], .bot-message, [class*='bot']",
@ -231,13 +185,7 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
Ok(()) 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<()> { pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
// Find and click logout button
let logout_selectors = vec![ let logout_selectors = vec![
"button[data-testid='logout-btn']", "button[data-testid='logout-btn']",
"button.logout", "button.logout",
@ -260,7 +208,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
browser.goto(&logout_url).await?; browser.goto(&logout_url).await?;
} }
// Wait for redirect to login
tokio::time::sleep(Duration::from_secs(2)).await; tokio::time::sleep(Duration::from_secs(2)).await;
let current_url = browser.current_url().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); println!("✓ Redirected to login page after logout: {}", current_url);
// Verify we cannot access protected routes
let chat_url = format!("{}/chat", ctx.base_url()); let chat_url = format!("{}/chat", ctx.base_url());
browser.goto(&chat_url).await?; browser.goto(&chat_url).await?;
@ -289,14 +235,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
Ok(()) 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] #[tokio::test]
async fn test_complete_platform_flow_login_chat_logout() { async fn test_complete_platform_flow_login_chat_logout() {
if !should_run_e2e_tests() { 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"); println!("\n=== Starting Complete Platform Flow Test ===\n");
// Setup context
let ctx = match E2ETestContext::setup_with_browser().await { let ctx = match E2ETestContext::setup_with_browser().await {
Ok(ctx) => ctx, Ok(ctx) => ctx,
Err(e) => { Err(e) => {
@ -327,7 +264,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
let browser = ctx.browser.as_ref().unwrap(); let browser = ctx.browser.as_ref().unwrap();
// Test each phase
println!("\n--- Phase 1: Platform Loading ---"); println!("\n--- Phase 1: Platform Loading ---");
if let Err(e) = verify_platform_loading(&ctx).await { if let Err(e) = verify_platform_loading(&ctx).await {
eprintln!("Platform loading test failed: {}", e); 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 ---"); println!("\n--- Phase 4: Chat Interaction ---");
if let Err(e) = test_chat_interaction(browser, &ctx).await { if let Err(e) = test_chat_interaction(browser, &ctx).await {
eprintln!("Chat interaction test failed: {}", e); eprintln!("Chat interaction test failed: {}", e);
// Don't return here - try to logout anyway
} }
println!("\n--- Phase 5: User Logout ---"); println!("\n--- Phase 5: User Logout ---");
@ -363,7 +298,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
ctx.close().await; ctx.close().await;
} }
/// Simpler test for basic platform loading without browser
#[tokio::test] #[tokio::test]
async fn test_platform_loading_http_only() { async fn test_platform_loading_http_only() {
if !should_run_e2e_tests() { if !should_run_e2e_tests() {
@ -391,7 +325,6 @@ async fn test_platform_loading_http_only() {
ctx.close().await; ctx.close().await;
} }
/// Test BotServer startup and health
#[tokio::test] #[tokio::test]
async fn test_botserver_startup() { async fn test_botserver_startup() {
if !should_run_e2e_tests() { 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() { if !ctx.server.is_running() {
eprintln!("Skipping: BotServer not running (BOTSERVER_BIN not set or binary not found)"); eprintln!("Skipping: BotServer not running (BOTSERVER_BIN not set or binary not found)");
ctx.close().await; 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 { macro_rules! skip_if_no_server {
($base_url:expr) => { ($base_url:expr) => {
if $base_url.is_none() { if $base_url.is_none() {
@ -658,7 +639,7 @@ async fn test_mock_llm_assertions() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let _ = client let _ = client
.post(&format!("{}/v1/chat/completions", mock_llm.url())) .post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({ .json(&serde_json::json!({
"model": "gpt-4", "model": "gpt-4",
"messages": [{"role": "user", "content": "test"}] "messages": [{"role": "user", "content": "test"}]
@ -685,7 +666,7 @@ async fn test_mock_llm_error_simulation() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let response = client let response = client
.post(&format!("{}/v1/chat/completions", mock_llm.url())) .post(format!("{}/v1/chat/completions", mock_llm.url()))
.json(&serde_json::json!({ .json(&serde_json::json!({
"model": "gpt-4", "model": "gpt-4",
"messages": [{"role": "user", "content": "test"}] "messages": [{"role": "user", "content": "test"}]

View file

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

View file

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

View file

@ -29,16 +29,6 @@ pub fn should_run_integration_tests() -> bool {
true 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] #[tokio::test]
async fn test_harness_database_only() { async fn test_harness_database_only() {
if !should_run_integration_tests() { 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] #[test]
fn test_unit_module_loads() { fn test_unit_module_loads() {
// If this compiles and runs, the test infrastructure is working let module_name = module_path!();
assert!(true); 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);
}