Compare commits
14 commits
706391b272
...
68542cd8ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68542cd8ff | ||
|
|
9e9b789d46 | ||
| 74e761de0d | |||
| 51458e391d | |||
| 4bd21d91f5 | |||
| 20176595b9 | |||
| 899b433529 | |||
| 05446c6716 | |||
| 1232b2fc65 | |||
| b38574c588 | |||
| 56334dd7b1 | |||
| 3d0a9a843d | |||
| 8593b861b5 | |||
| 7c6c48be3a |
40 changed files with 1508 additions and 3704 deletions
79
Cargo.toml
79
Cargo.toml
|
|
@ -5,82 +5,58 @@ edition = "2021"
|
|||
description = "Comprehensive test suite for General Bots - Unit, Integration, and E2E testing"
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://github.com/GeneralBots/BotServer"
|
||||
readme = "README.md"
|
||||
keywords = ["testing", "bot", "integration-testing", "e2e", "general-bots"]
|
||||
categories = ["development-tools::testing"]
|
||||
|
||||
[dependencies]
|
||||
# The server we're testing - include drive and cache for required deps
|
||||
botserver = { path = "../botserver", default-features = false, features = ["chat", "llm", "automation", "tasks", "directory", "drive", "cache"] }
|
||||
botlib = { path = "../botlib", features = ["database"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.41", features = ["full", "test-util", "macros"] }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
tokio = { workspace = true, features = ["full", "test-util", "macros"] }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Database
|
||||
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
||||
diesel_migrations = "2.1.0"
|
||||
diesel = { workspace = true, features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
|
||||
|
||||
# HTTP mocking and testing
|
||||
wiremock = "0.6"
|
||||
cookie = "0.18"
|
||||
mockito = "1.7"
|
||||
reqwest = { version = "0.12", features = ["json", "cookies", "blocking"] }
|
||||
wiremock = { workspace = true }
|
||||
cookie = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "cookies", "blocking", "rustls-tls"] }
|
||||
|
||||
# Web/E2E testing - using Chrome DevTools Protocol directly (no chromedriver)
|
||||
chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-features = false }
|
||||
|
||||
# Web framework for test server
|
||||
axum = { version = "0.7.5", features = ["ws", "multipart", "macros"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
hyper = { version = "0.14", features = ["full"] }
|
||||
chromiumoxide = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1.11", features = ["serde", "v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.9"
|
||||
tempfile = "3"
|
||||
which = "7"
|
||||
regex = "1.11"
|
||||
base64 = "0.22"
|
||||
url = "2.5"
|
||||
dirs = "5.0"
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
which = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
||||
# Process management for services
|
||||
nix = { version = "0.29", features = ["signal", "process"] }
|
||||
nix = { workspace = true }
|
||||
|
||||
# Archive extraction
|
||||
zip = "2.2"
|
||||
zip = { workspace = true }
|
||||
|
||||
# Logging and tracing
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["fmt", "env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# Test framework enhancements
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
# Async testing
|
||||
tokio-test = "0.4"
|
||||
|
||||
# Rhai for BASIC function testing
|
||||
rhai = { git = "https://github.com/therealprof/rhai.git", branch = "features/use-web-time", features = ["sync"] }
|
||||
|
||||
# Configuration
|
||||
dotenvy = "0.15"
|
||||
rhai = { workspace = true, features = ["sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.40", features = ["json", "yaml"] }
|
||||
insta = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
|
@ -105,3 +81,6 @@ required-features = ["e2e"]
|
|||
[[bin]]
|
||||
name = "bottest"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
216
PROMPT.md
216
PROMPT.md
|
|
@ -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
310
README.md
Normal 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.
|
||||
|
|
@ -38,7 +38,7 @@ impl ConversationBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn on_channel(mut self, channel: Channel) -> Self {
|
||||
pub const fn on_channel(mut self, channel: Channel) -> Self {
|
||||
self.channel = channel;
|
||||
self
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ impl ConversationBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.config.response_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
|
@ -58,12 +58,12 @@ impl ConversationBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn without_recording(mut self) -> Self {
|
||||
pub const fn without_recording(mut self) -> Self {
|
||||
self.config.record = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_real_llm(mut self) -> Self {
|
||||
pub const fn with_real_llm(mut self) -> Self {
|
||||
self.config.use_mock_llm = false;
|
||||
self
|
||||
}
|
||||
|
|
@ -131,13 +131,13 @@ impl ConversationTest {
|
|||
ConversationBuilder::new(bot_name).build()
|
||||
}
|
||||
|
||||
pub async fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
|
||||
pub fn with_context(ctx: &TestContext, bot_name: &str) -> Result<Self> {
|
||||
let mut conv = ConversationBuilder::new(bot_name).build();
|
||||
conv.llm_url = Some(ctx.llm_url());
|
||||
Ok(conv)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
pub const fn id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
|
|
@ -145,15 +145,15 @@ impl ConversationTest {
|
|||
&self.bot_name
|
||||
}
|
||||
|
||||
pub fn customer(&self) -> &Customer {
|
||||
pub const fn customer(&self) -> &Customer {
|
||||
&self.customer
|
||||
}
|
||||
|
||||
pub fn channel(&self) -> Channel {
|
||||
pub const fn channel(&self) -> Channel {
|
||||
self.channel
|
||||
}
|
||||
|
||||
pub fn state(&self) -> ConversationState {
|
||||
pub const fn state(&self) -> ConversationState {
|
||||
self.state
|
||||
}
|
||||
|
||||
|
|
@ -165,15 +165,15 @@ impl ConversationTest {
|
|||
&self.sent_messages
|
||||
}
|
||||
|
||||
pub fn last_response(&self) -> Option<&BotResponse> {
|
||||
pub const fn last_response(&self) -> Option<&BotResponse> {
|
||||
self.last_response.as_ref()
|
||||
}
|
||||
|
||||
pub fn last_latency(&self) -> Option<Duration> {
|
||||
pub const fn last_latency(&self) -> Option<Duration> {
|
||||
self.last_latency
|
||||
}
|
||||
|
||||
pub fn record(&self) -> &ConversationRecord {
|
||||
pub const fn record(&self) -> &ConversationRecord {
|
||||
&self.record
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ impl ConversationTest {
|
|||
self.record.messages.push(RecordedMessage {
|
||||
timestamp: Utc::now(),
|
||||
direction: MessageDirection::Outgoing,
|
||||
content: response.content.clone(),
|
||||
content: response.content,
|
||||
latency_ms: Some(latency.as_millis() as u64),
|
||||
});
|
||||
}
|
||||
|
|
@ -242,7 +242,7 @@ impl ConversationTest {
|
|||
|
||||
BotResponse {
|
||||
id: Uuid::new_v4(),
|
||||
content: format!("Response to: {}", user_message),
|
||||
content: format!("Response to: {user_message}"),
|
||||
content_type: ResponseContentType::Text,
|
||||
metadata: self.build_response_metadata(),
|
||||
latency_ms: start.elapsed().as_millis() as u64,
|
||||
|
|
@ -269,7 +269,7 @@ impl ConversationTest {
|
|||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/v1/chat/completions", llm_url))
|
||||
.post(format!("{llm_url}/v1/chat/completions"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
|
|
@ -306,13 +306,13 @@ impl ConversationTest {
|
|||
metadata
|
||||
}
|
||||
|
||||
pub async fn assert_response_contains(&mut self, text: &str) -> &mut Self {
|
||||
pub fn assert_response_contains(&mut self, text: &str) -> &mut Self {
|
||||
let result = if let Some(ref response) = self.last_response {
|
||||
if response.content.contains(text) {
|
||||
AssertionResult::pass(&format!("Response contains '{}'", text))
|
||||
AssertionResult::pass(&format!("Response contains '{text}'"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
&format!("Response should contain '{}'", text),
|
||||
&format!("Response should contain '{text}'"),
|
||||
text,
|
||||
&response.content,
|
||||
)
|
||||
|
|
@ -325,10 +325,10 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_equals(&mut self, text: &str) -> &mut Self {
|
||||
pub fn assert_response_equals(&mut self, text: &str) -> &mut Self {
|
||||
let result = if let Some(ref response) = self.last_response {
|
||||
if response.content == text {
|
||||
AssertionResult::pass(&format!("Response equals '{}'", text))
|
||||
AssertionResult::pass(&format!("Response equals '{text}'"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"Response should equal expected text",
|
||||
|
|
@ -344,22 +344,22 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
|
||||
pub fn assert_response_matches(&mut self, pattern: &str) -> &mut Self {
|
||||
let result = if let Some(ref response) = self.last_response {
|
||||
match regex::Regex::new(pattern) {
|
||||
Ok(re) => {
|
||||
if re.is_match(&response.content) {
|
||||
AssertionResult::pass(&format!("Response matches pattern '{}'", pattern))
|
||||
AssertionResult::pass(&format!("Response matches pattern '{pattern}'"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
&format!("Response should match pattern '{}'", pattern),
|
||||
&format!("Response should match pattern '{pattern}'"),
|
||||
pattern,
|
||||
&response.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => AssertionResult::fail(
|
||||
&format!("Invalid regex pattern: {}", e),
|
||||
&format!("Invalid regex pattern: {e}"),
|
||||
pattern,
|
||||
"<invalid pattern>",
|
||||
),
|
||||
|
|
@ -372,16 +372,16 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
|
||||
pub fn assert_response_not_contains(&mut self, text: &str) -> &mut Self {
|
||||
let result = if let Some(ref response) = self.last_response {
|
||||
if !response.content.contains(text) {
|
||||
AssertionResult::pass(&format!("Response does not contain '{}'", text))
|
||||
} else {
|
||||
if response.content.contains(text) {
|
||||
AssertionResult::fail(
|
||||
&format!("Response should not contain '{}'", text),
|
||||
&format!("not containing '{}'", text),
|
||||
&format!("Response should not contain '{text}'"),
|
||||
&format!("not containing '{text}'"),
|
||||
&response.content,
|
||||
)
|
||||
} else {
|
||||
AssertionResult::pass(&format!("Response does not contain '{text}'"))
|
||||
}
|
||||
} else {
|
||||
AssertionResult::pass("No response (nothing to contain)")
|
||||
|
|
@ -391,17 +391,13 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_transferred_to_human(&mut self) -> &mut Self {
|
||||
pub fn assert_transferred_to_human(&mut self) -> &mut Self {
|
||||
let is_transferred = self.state == ConversationState::Transferred
|
||||
|| self
|
||||
.last_response
|
||||
.as_ref()
|
||||
.map(|r| {
|
||||
r.content.to_lowercase().contains("transfer")
|
||||
|| r.content.to_lowercase().contains("human")
|
||||
|| r.content.to_lowercase().contains("agent")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|| self.last_response.as_ref().is_some_and(|r| {
|
||||
r.content.to_lowercase().contains("transfer")
|
||||
|| r.content.to_lowercase().contains("human")
|
||||
|| r.content.to_lowercase().contains("agent")
|
||||
});
|
||||
|
||||
let result = if is_transferred {
|
||||
self.state = ConversationState::Transferred;
|
||||
|
|
@ -418,15 +414,15 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
|
||||
pub fn assert_queue_position(&mut self, expected: usize) -> &mut Self {
|
||||
let actual = self
|
||||
.context
|
||||
.get("queue_position")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(0) as usize;
|
||||
|
||||
let result = if actual == expected {
|
||||
AssertionResult::pass(&format!("Queue position is {}", expected))
|
||||
AssertionResult::pass(&format!("Queue position is {expected}"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"Queue position mismatch",
|
||||
|
|
@ -439,21 +435,21 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
|
||||
pub fn assert_response_within(&mut self, max_duration: Duration) -> &mut Self {
|
||||
let result = if let Some(latency) = self.last_latency {
|
||||
if latency <= max_duration {
|
||||
AssertionResult::pass(&format!("Response within {:?}", max_duration))
|
||||
AssertionResult::pass(&format!("Response within {max_duration:?}"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"Response too slow",
|
||||
&format!("{:?}", max_duration),
|
||||
&format!("{:?}", latency),
|
||||
&format!("{max_duration:?}"),
|
||||
&format!("{latency:?}"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"No latency recorded",
|
||||
&format!("{:?}", max_duration),
|
||||
&format!("{max_duration:?}"),
|
||||
"<no latency>",
|
||||
)
|
||||
};
|
||||
|
|
@ -462,11 +458,11 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_count(&mut self, expected: usize) -> &mut Self {
|
||||
pub fn assert_response_count(&mut self, expected: usize) -> &mut Self {
|
||||
let actual = self.responses.len();
|
||||
|
||||
let result = if actual == expected {
|
||||
AssertionResult::pass(&format!("Response count is {}", expected))
|
||||
AssertionResult::pass(&format!("Response count is {expected}"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"Response count mismatch",
|
||||
|
|
@ -479,21 +475,21 @@ impl ConversationTest {
|
|||
self
|
||||
}
|
||||
|
||||
pub async fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
|
||||
pub fn assert_response_type(&mut self, expected: ResponseContentType) -> &mut Self {
|
||||
let result = if let Some(ref response) = self.last_response {
|
||||
if response.content_type == expected {
|
||||
AssertionResult::pass(&format!("Response type is {:?}", expected))
|
||||
AssertionResult::pass(&format!("Response type is {expected:?}"))
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"Response type mismatch",
|
||||
&format!("{:?}", expected),
|
||||
&format!("{expected:?}"),
|
||||
&format!("{:?}", response.content_type),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AssertionResult::fail(
|
||||
"No response to check",
|
||||
&format!("{:?}", expected),
|
||||
&format!("{expected:?}"),
|
||||
"<no response>",
|
||||
)
|
||||
};
|
||||
|
|
@ -511,13 +507,13 @@ impl ConversationTest {
|
|||
self.context.get(key)
|
||||
}
|
||||
|
||||
pub async fn end(&mut self) -> &mut Self {
|
||||
pub fn end(&mut self) -> &mut Self {
|
||||
self.state = ConversationState::Ended;
|
||||
self.record.ended_at = Some(Utc::now());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn all_passed(&self) -> bool {
|
||||
pub const fn all_passed(&self) -> bool {
|
||||
self.record.passed
|
||||
}
|
||||
|
||||
|
|
@ -584,7 +580,7 @@ mod tests {
|
|||
async fn test_assert_response_contains() {
|
||||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("test").await;
|
||||
conv.assert_response_contains("Response").await;
|
||||
conv.assert_response_contains("Response");
|
||||
|
||||
assert!(conv.all_passed());
|
||||
}
|
||||
|
|
@ -593,7 +589,7 @@ mod tests {
|
|||
async fn test_assert_response_not_contains() {
|
||||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("test").await;
|
||||
conv.assert_response_not_contains("nonexistent").await;
|
||||
conv.assert_response_not_contains("nonexistent");
|
||||
|
||||
assert!(conv.all_passed());
|
||||
}
|
||||
|
|
@ -633,7 +629,7 @@ mod tests {
|
|||
async fn test_end_conversation() {
|
||||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("bye").await;
|
||||
conv.end().await;
|
||||
conv.end();
|
||||
|
||||
assert_eq!(conv.state(), ConversationState::Ended);
|
||||
assert!(conv.record().ended_at.is_some());
|
||||
|
|
@ -643,7 +639,7 @@ mod tests {
|
|||
async fn test_failed_assertions() {
|
||||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("test").await;
|
||||
conv.assert_response_equals("this will not match").await;
|
||||
conv.assert_response_equals("this will not match");
|
||||
|
||||
assert!(!conv.all_passed());
|
||||
assert_eq!(conv.failed_assertions().len(), 1);
|
||||
|
|
@ -673,13 +669,13 @@ mod tests {
|
|||
let mut conv = ConversationTest::new("support-bot");
|
||||
|
||||
conv.user_says("Hi").await;
|
||||
conv.assert_response_contains("Response").await;
|
||||
conv.assert_response_contains("Response");
|
||||
|
||||
conv.user_says("I need help").await;
|
||||
conv.assert_response_contains("Response").await;
|
||||
conv.assert_response_contains("Response");
|
||||
|
||||
conv.user_says("Thanks, bye").await;
|
||||
conv.end().await;
|
||||
conv.end();
|
||||
|
||||
assert_eq!(conv.sent_messages().len(), 3);
|
||||
assert_eq!(conv.responses().len(), 3);
|
||||
|
|
@ -690,7 +686,7 @@ mod tests {
|
|||
async fn test_response_time_assertion() {
|
||||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("quick test").await;
|
||||
conv.assert_response_within(Duration::from_secs(5)).await;
|
||||
conv.assert_response_within(Duration::from_secs(5));
|
||||
|
||||
assert!(conv.all_passed());
|
||||
}
|
||||
|
|
@ -700,7 +696,7 @@ mod tests {
|
|||
let mut conv = ConversationTest::new("test-bot");
|
||||
conv.user_says("one").await;
|
||||
conv.user_says("two").await;
|
||||
conv.assert_response_count(2).await;
|
||||
conv.assert_response_count(2);
|
||||
|
||||
assert!(conv.all_passed());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
//! Bot conversation testing module
|
||||
//!
|
||||
//! Provides tools for simulating and testing bot conversations
|
||||
//! including message exchanges, flow validation, and response assertions.
|
||||
|
||||
mod conversation;
|
||||
mod runner;
|
||||
|
|
@ -12,7 +8,6 @@ use std::collections::HashMap;
|
|||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Response from the bot
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BotResponse {
|
||||
pub id: Uuid,
|
||||
|
|
@ -22,10 +17,11 @@ pub struct BotResponse {
|
|||
pub latency_ms: u64,
|
||||
}
|
||||
|
||||
/// Type of response content
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum ResponseContentType {
|
||||
#[default]
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
|
|
@ -37,13 +33,7 @@ pub enum ResponseContentType {
|
|||
Contact,
|
||||
}
|
||||
|
||||
impl Default for ResponseContentType {
|
||||
fn default() -> Self {
|
||||
Self::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// Assertion result for conversation tests
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssertionResult {
|
||||
pub passed: bool,
|
||||
|
|
@ -53,6 +43,7 @@ pub struct AssertionResult {
|
|||
}
|
||||
|
||||
impl AssertionResult {
|
||||
#[must_use]
|
||||
pub fn pass(message: &str) -> Self {
|
||||
Self {
|
||||
passed: true,
|
||||
|
|
@ -62,6 +53,7 @@ impl AssertionResult {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fail(message: &str, expected: &str, actual: &str) -> Self {
|
||||
Self {
|
||||
passed: false,
|
||||
|
|
@ -72,16 +64,11 @@ impl AssertionResult {
|
|||
}
|
||||
}
|
||||
|
||||
/// Configuration for conversation tests
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationConfig {
|
||||
/// Maximum time to wait for a response
|
||||
pub response_timeout: Duration,
|
||||
/// Whether to record the conversation for later analysis
|
||||
pub record: bool,
|
||||
/// Whether to use the mock LLM
|
||||
pub use_mock_llm: bool,
|
||||
/// Custom variables to inject into the conversation
|
||||
pub variables: HashMap<String, String>,
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +83,6 @@ impl Default for ConversationConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Recorded conversation for analysis
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationRecord {
|
||||
pub id: Uuid,
|
||||
|
|
@ -108,7 +94,6 @@ pub struct ConversationRecord {
|
|||
pub passed: bool,
|
||||
}
|
||||
|
||||
/// Recorded message in a conversation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordedMessage {
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
|
|
@ -117,7 +102,6 @@ pub struct RecordedMessage {
|
|||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Recorded assertion
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AssertionRecord {
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
|
|
@ -126,28 +110,18 @@ pub struct AssertionRecord {
|
|||
pub message: String,
|
||||
}
|
||||
|
||||
/// State of a conversation flow
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Default)]
|
||||
pub enum ConversationState {
|
||||
/// Initial state, conversation not started
|
||||
#[default]
|
||||
Initial,
|
||||
/// Waiting for user input
|
||||
WaitingForUser,
|
||||
/// Waiting for bot response
|
||||
WaitingForBot,
|
||||
/// Conversation transferred to human
|
||||
Transferred,
|
||||
/// Conversation ended normally
|
||||
Ended,
|
||||
/// Conversation ended with error
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Default for ConversationState {
|
||||
fn default() -> Self {
|
||||
Self::Initial
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Bot runner for executing tests
|
||||
//!
|
||||
//! Provides a test runner that can execute BASIC scripts and simulate
|
||||
//! bot behavior for integration testing.
|
||||
|
||||
use super::{BotResponse, ConversationState, ResponseContentType};
|
||||
use crate::fixtures::{Bot, Channel, Customer, Session};
|
||||
use crate::harness::TestContext;
|
||||
|
|
@ -13,20 +8,13 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Configuration for the bot runner
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BotRunnerConfig {
|
||||
/// Working directory for the bot
|
||||
pub working_dir: PathBuf,
|
||||
/// Maximum execution time for a single request
|
||||
pub timeout: Duration,
|
||||
/// Whether to use mock services
|
||||
pub use_mocks: bool,
|
||||
/// Environment variables to set
|
||||
pub env_vars: HashMap<String, String>,
|
||||
/// Whether to capture logs
|
||||
pub capture_logs: bool,
|
||||
/// Log level
|
||||
pub log_level: LogLevel,
|
||||
}
|
||||
|
||||
|
|
@ -43,23 +31,18 @@ impl Default for BotRunnerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Log level for bot runner
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Default)]
|
||||
pub enum LogLevel {
|
||||
Trace,
|
||||
Debug,
|
||||
#[default]
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Default for LogLevel {
|
||||
fn default() -> Self {
|
||||
Self::Info
|
||||
}
|
||||
}
|
||||
|
||||
/// Bot runner for executing bot scripts and simulating conversations
|
||||
pub struct BotRunner {
|
||||
config: BotRunnerConfig,
|
||||
bot: Option<Bot>,
|
||||
|
|
@ -68,7 +51,6 @@ pub struct BotRunner {
|
|||
metrics: Arc<Mutex<RunnerMetrics>>,
|
||||
}
|
||||
|
||||
/// Internal session state
|
||||
struct SessionState {
|
||||
session: Session,
|
||||
customer: Customer,
|
||||
|
|
@ -79,7 +61,6 @@ struct SessionState {
|
|||
started_at: Instant,
|
||||
}
|
||||
|
||||
/// Metrics collected by the runner
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RunnerMetrics {
|
||||
pub total_requests: u64,
|
||||
|
|
@ -93,8 +74,7 @@ pub struct RunnerMetrics {
|
|||
}
|
||||
|
||||
impl RunnerMetrics {
|
||||
/// Get average latency in milliseconds
|
||||
pub fn avg_latency_ms(&self) -> u64 {
|
||||
pub const fn avg_latency_ms(&self) -> u64 {
|
||||
if self.total_requests > 0 {
|
||||
self.total_latency_ms / self.total_requests
|
||||
} else {
|
||||
|
|
@ -102,7 +82,6 @@ impl RunnerMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get success rate as percentage
|
||||
pub fn success_rate(&self) -> f64 {
|
||||
if self.total_requests > 0 {
|
||||
(self.successful_requests as f64 / self.total_requests as f64) * 100.0
|
||||
|
|
@ -112,7 +91,6 @@ impl RunnerMetrics {
|
|||
}
|
||||
}
|
||||
|
||||
/// Result of a bot execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionResult {
|
||||
pub session_id: Uuid,
|
||||
|
|
@ -123,7 +101,6 @@ pub struct ExecutionResult {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A log entry captured during execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
|
|
@ -133,12 +110,10 @@ pub struct LogEntry {
|
|||
}
|
||||
|
||||
impl BotRunner {
|
||||
/// Create a new bot runner with default configuration
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(BotRunnerConfig::default())
|
||||
}
|
||||
|
||||
/// Create a new bot runner with custom configuration
|
||||
pub fn with_config(config: BotRunnerConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -149,19 +124,16 @@ impl BotRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a bot runner with a test context
|
||||
pub fn with_context(_ctx: &TestContext, config: BotRunnerConfig) -> Self {
|
||||
Self::with_config(config)
|
||||
}
|
||||
|
||||
/// Set the bot to run
|
||||
pub fn set_bot(&mut self, bot: Bot) -> &mut Self {
|
||||
self.bot = Some(bot);
|
||||
self
|
||||
}
|
||||
|
||||
/// Load a BASIC script
|
||||
pub fn load_script(&mut self, name: &str, content: &str) -> &mut Self {
|
||||
pub fn load_script(&self, name: &str, content: &str) -> &Self {
|
||||
self.script_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
|
|
@ -169,10 +141,9 @@ impl BotRunner {
|
|||
self
|
||||
}
|
||||
|
||||
/// Load a script from a file
|
||||
pub fn load_script_file(&mut self, name: &str, path: &PathBuf) -> Result<&mut Self> {
|
||||
pub fn load_script_file(&self, name: &str, path: &PathBuf) -> Result<&Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read script file: {:?}", path))?;
|
||||
.with_context(|| format!("Failed to read script file: {}", path.display()))?;
|
||||
self.script_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
|
|
@ -180,10 +151,9 @@ impl BotRunner {
|
|||
Ok(self)
|
||||
}
|
||||
|
||||
/// Start a new session
|
||||
pub fn start_session(&mut self, customer: Customer) -> Result<Uuid> {
|
||||
pub fn start_session(&self, customer: Customer) -> Result<Uuid> {
|
||||
let session_id = Uuid::new_v4();
|
||||
let bot_id = self.bot.as_ref().map(|b| b.id).unwrap_or_else(Uuid::new_v4);
|
||||
let bot_id = self.bot.as_ref().map_or_else(Uuid::new_v4, |b| b.id);
|
||||
|
||||
let session = Session {
|
||||
id: session_id,
|
||||
|
|
@ -208,62 +178,53 @@ impl BotRunner {
|
|||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// End a session
|
||||
pub fn end_session(&mut self, session_id: Uuid) -> Result<()> {
|
||||
pub fn end_session(&self, session_id: Uuid) -> Result<()> {
|
||||
self.sessions.lock().unwrap().remove(&session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process a message in a session
|
||||
pub async fn process_message(
|
||||
&mut self,
|
||||
&self,
|
||||
session_id: Uuid,
|
||||
message: &str,
|
||||
) -> Result<ExecutionResult> {
|
||||
let start = Instant::now();
|
||||
let mut logs = Vec::new();
|
||||
|
||||
// Update metrics
|
||||
{
|
||||
let mut metrics = self.metrics.lock().unwrap();
|
||||
metrics.total_requests += 1;
|
||||
}
|
||||
|
||||
// Get session state
|
||||
let state = {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
sessions.get(&session_id).cloned()
|
||||
};
|
||||
|
||||
let state = match state {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Ok(ExecutionResult {
|
||||
session_id,
|
||||
response: None,
|
||||
state: ConversationState::Error,
|
||||
execution_time: start.elapsed(),
|
||||
logs,
|
||||
error: Some("Session not found".to_string()),
|
||||
});
|
||||
}
|
||||
let Some(state) = state else {
|
||||
return Ok(ExecutionResult {
|
||||
session_id,
|
||||
response: None,
|
||||
state: ConversationState::Error,
|
||||
execution_time: start.elapsed(),
|
||||
logs,
|
||||
error: Some("Session not found".to_string()),
|
||||
});
|
||||
};
|
||||
|
||||
if self.config.capture_logs {
|
||||
logs.push(LogEntry {
|
||||
timestamp: chrono::Utc::now(),
|
||||
level: LogLevel::Debug,
|
||||
message: format!("Processing message: {}", message),
|
||||
message: format!("Processing message: {message}"),
|
||||
context: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Execute bot logic (placeholder - would call actual bot runtime)
|
||||
let response = self.execute_bot_logic(session_id, message, &state).await;
|
||||
|
||||
let execution_time = start.elapsed();
|
||||
|
||||
// Update metrics
|
||||
{
|
||||
let mut metrics = self.metrics.lock().unwrap();
|
||||
let latency_ms = execution_time.as_millis() as u64;
|
||||
|
|
@ -283,7 +244,6 @@ impl BotRunner {
|
|||
}
|
||||
}
|
||||
|
||||
// Update session state
|
||||
{
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
if let Some(session_state) = sessions.get_mut(&session_id) {
|
||||
|
|
@ -312,75 +272,158 @@ impl BotRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/// Execute bot logic (placeholder for actual implementation)
|
||||
async fn execute_bot_logic(
|
||||
&self,
|
||||
_session_id: Uuid,
|
||||
session_id: Uuid,
|
||||
message: &str,
|
||||
_state: &SessionState,
|
||||
state: &SessionState,
|
||||
) -> Result<BotResponse> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Load the bot's BASIC script
|
||||
// 2. Execute it with the message as input
|
||||
// 3. Return the bot's response
|
||||
let start = Instant::now();
|
||||
|
||||
let bot = self.bot.as_ref().context("No bot configured")?;
|
||||
|
||||
let script_path = self
|
||||
.config
|
||||
.working_dir
|
||||
.join(&bot.name)
|
||||
.join("dialog")
|
||||
.join("start.bas");
|
||||
|
||||
let script_content = if script_path.exists() {
|
||||
tokio::fs::read_to_string(&script_path)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
let cache = self.script_cache.lock().unwrap();
|
||||
cache.get("default").cloned().unwrap_or_default()
|
||||
};
|
||||
|
||||
let response_content = if script_content.is_empty() {
|
||||
format!("Received: {message}")
|
||||
} else {
|
||||
Self::evaluate_basic_script(&script_content, message, &state.context)
|
||||
.unwrap_or_else(|e| format!("Error: {e}"))
|
||||
};
|
||||
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
|
||||
{
|
||||
let mut metrics = self.metrics.lock().unwrap();
|
||||
metrics.total_requests += 1;
|
||||
metrics.successful_requests += 1;
|
||||
metrics.total_latency_ms += latency;
|
||||
}
|
||||
|
||||
// For now, return a mock response
|
||||
Ok(BotResponse {
|
||||
id: Uuid::new_v4(),
|
||||
content: format!("Echo: {}", message),
|
||||
content: response_content,
|
||||
content_type: ResponseContentType::Text,
|
||||
metadata: HashMap::new(),
|
||||
latency_ms: 50,
|
||||
metadata: HashMap::from([
|
||||
(
|
||||
"session_id".to_string(),
|
||||
serde_json::Value::String(session_id.to_string()),
|
||||
),
|
||||
(
|
||||
"bot_name".to_string(),
|
||||
serde_json::Value::String(bot.name.clone()),
|
||||
),
|
||||
]),
|
||||
latency_ms: latency,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a BASIC script directly
|
||||
pub async fn execute_script(
|
||||
&mut self,
|
||||
script_name: &str,
|
||||
fn evaluate_basic_script(
|
||||
script: &str,
|
||||
input: &str,
|
||||
) -> Result<ExecutionResult> {
|
||||
context: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<String> {
|
||||
let mut output = String::new();
|
||||
let mut variables: HashMap<String, String> = HashMap::new();
|
||||
|
||||
variables.insert("INPUT".to_string(), input.to_string());
|
||||
for (key, value) in context {
|
||||
variables.insert(key.to_uppercase(), value.to_string());
|
||||
}
|
||||
|
||||
for line in script.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('\'') || line.starts_with("REM") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.to_uppercase().starts_with("TALK") {
|
||||
let content = line[4..].trim().trim_matches('"');
|
||||
let expanded = Self::expand_variables(content, &variables);
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&expanded);
|
||||
} else if line.to_uppercase().starts_with("HEAR") {
|
||||
variables.insert("LAST_INPUT".to_string(), input.to_string());
|
||||
} else if line.contains('=') && !line.to_uppercase().starts_with("IF") {
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let var_name = parts[0].trim().to_uppercase();
|
||||
let var_value = parts[1].trim().trim_matches('"').to_string();
|
||||
let expanded = Self::expand_variables(&var_value, &variables);
|
||||
variables.insert(var_name, expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output.is_empty() {
|
||||
output = format!("Processed: {input}");
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn expand_variables(text: &str, variables: &HashMap<String, String>) -> String {
|
||||
let mut result = text.to_string();
|
||||
for (key, value) in variables {
|
||||
result = result.replace(&format!("{{{key}}}"), value);
|
||||
result = result.replace(&format!("${key}"), value);
|
||||
result = result.replace(key, value);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn execute_script(&self, script_name: &str, input: &str) -> Result<ExecutionResult> {
|
||||
let session_id = Uuid::new_v4();
|
||||
let start = Instant::now();
|
||||
let mut logs = Vec::new();
|
||||
|
||||
// Get script from cache
|
||||
let script = {
|
||||
let cache = self.script_cache.lock().unwrap();
|
||||
cache.get(script_name).cloned()
|
||||
};
|
||||
|
||||
let script = match script {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Ok(ExecutionResult {
|
||||
session_id,
|
||||
response: None,
|
||||
state: ConversationState::Error,
|
||||
execution_time: start.elapsed(),
|
||||
logs,
|
||||
error: Some(format!("Script '{}' not found", script_name)),
|
||||
});
|
||||
}
|
||||
let Some(script) = script else {
|
||||
return Ok(ExecutionResult {
|
||||
session_id,
|
||||
response: None,
|
||||
state: ConversationState::Error,
|
||||
execution_time: start.elapsed(),
|
||||
logs,
|
||||
error: Some(format!("Script '{script_name}' not found")),
|
||||
});
|
||||
};
|
||||
|
||||
if self.config.capture_logs {
|
||||
logs.push(LogEntry {
|
||||
timestamp: chrono::Utc::now(),
|
||||
level: LogLevel::Debug,
|
||||
message: format!("Executing script: {}", script_name),
|
||||
message: format!("Executing script: {script_name}"),
|
||||
context: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
{
|
||||
let mut metrics = self.metrics.lock().unwrap();
|
||||
metrics.script_executions += 1;
|
||||
}
|
||||
|
||||
// Execute script (placeholder)
|
||||
let result = self.execute_script_internal(&script, input).await;
|
||||
let result = Self::execute_script_internal(&script, input);
|
||||
|
||||
let execution_time = start.elapsed();
|
||||
|
||||
|
|
@ -410,42 +453,37 @@ impl BotRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/// Internal script execution (placeholder)
|
||||
async fn execute_script_internal(&self, _script: &str, input: &str) -> Result<String> {
|
||||
// In a real implementation, this would parse and execute the BASIC script
|
||||
// For now, just echo the input
|
||||
Ok(format!("Script output for: {}", input))
|
||||
fn execute_script_internal(script: &str, input: &str) -> Result<String> {
|
||||
let context = HashMap::new();
|
||||
Self::evaluate_basic_script(script, input, &context)
|
||||
}
|
||||
|
||||
/// Get current metrics
|
||||
pub fn metrics(&self) -> RunnerMetrics {
|
||||
self.metrics.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Reset metrics
|
||||
pub fn reset_metrics(&mut self) {
|
||||
pub fn reset_metrics(&self) {
|
||||
*self.metrics.lock().unwrap() = RunnerMetrics::default();
|
||||
}
|
||||
|
||||
/// Get active session count
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
self.sessions.lock().unwrap().len()
|
||||
}
|
||||
|
||||
/// Get session info
|
||||
pub fn get_session_info(&self, session_id: Uuid) -> Option<SessionInfo> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
sessions.get(&session_id).map(|s| SessionInfo {
|
||||
let info = sessions.get(&session_id).map(|s| SessionInfo {
|
||||
session_id: s.session.id,
|
||||
customer_id: s.customer.id,
|
||||
channel: s.channel,
|
||||
message_count: s.message_count,
|
||||
state: s.conversation_state,
|
||||
duration: s.started_at.elapsed(),
|
||||
})
|
||||
});
|
||||
drop(sessions);
|
||||
info
|
||||
}
|
||||
|
||||
/// Set environment variable for bot execution
|
||||
pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self {
|
||||
self.config
|
||||
.env_vars
|
||||
|
|
@ -453,8 +491,7 @@ impl BotRunner {
|
|||
self
|
||||
}
|
||||
|
||||
/// Set timeout
|
||||
pub fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
|
||||
pub const fn set_timeout(&mut self, timeout: Duration) -> &mut Self {
|
||||
self.config.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
|
@ -466,7 +503,6 @@ impl Default for BotRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/// Information about a session
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: Uuid,
|
||||
|
|
@ -477,7 +513,6 @@ pub struct SessionInfo {
|
|||
pub duration: Duration,
|
||||
}
|
||||
|
||||
// Implement Clone for SessionState
|
||||
impl Clone for SessionState {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
|
|
@ -506,27 +541,31 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_runner_metrics_avg_latency() {
|
||||
let mut metrics = RunnerMetrics::default();
|
||||
metrics.total_requests = 10;
|
||||
metrics.total_latency_ms = 1000;
|
||||
let metrics = RunnerMetrics {
|
||||
total_requests: 10,
|
||||
total_latency_ms: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(metrics.avg_latency_ms(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runner_metrics_success_rate() {
|
||||
let mut metrics = RunnerMetrics::default();
|
||||
metrics.total_requests = 100;
|
||||
metrics.successful_requests = 95;
|
||||
let metrics = RunnerMetrics {
|
||||
total_requests: 100,
|
||||
successful_requests: 95,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(metrics.success_rate(), 95.0);
|
||||
assert!((metrics.success_rate() - 95.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runner_metrics_zero_requests() {
|
||||
let metrics = RunnerMetrics::default();
|
||||
assert_eq!(metrics.avg_latency_ms(), 0);
|
||||
assert_eq!(metrics.success_rate(), 0.0);
|
||||
assert!(metrics.success_rate().abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -537,16 +576,17 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_load_script() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
runner.load_script("test", "TALK \"Hello\"");
|
||||
|
||||
let cache = runner.script_cache.lock().unwrap();
|
||||
assert!(cache.contains_key("test"));
|
||||
drop(cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_session() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
let customer = Customer::default();
|
||||
|
||||
let session_id = runner.start_session(customer).unwrap();
|
||||
|
|
@ -557,7 +597,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_end_session() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
let customer = Customer::default();
|
||||
|
||||
let session_id = runner.start_session(customer).unwrap();
|
||||
|
|
@ -569,7 +609,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_process_message() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
let customer = Customer::default();
|
||||
|
||||
let session_id = runner.start_session(customer).unwrap();
|
||||
|
|
@ -582,7 +622,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_process_message_invalid_session() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
let invalid_session_id = Uuid::new_v4();
|
||||
|
||||
let result = runner
|
||||
|
|
@ -595,22 +635,22 @@ mod tests {
|
|||
assert_eq!(result.state, ConversationState::Error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_script() {
|
||||
let mut runner = BotRunner::new();
|
||||
#[test]
|
||||
fn test_execute_script() {
|
||||
let runner = BotRunner::new();
|
||||
runner.load_script("greeting", "TALK \"Hello\"");
|
||||
|
||||
let result = runner.execute_script("greeting", "Hi").await.unwrap();
|
||||
let result = runner.execute_script("greeting", "Hi").unwrap();
|
||||
|
||||
assert!(result.response.is_some());
|
||||
assert!(result.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_script_not_found() {
|
||||
let mut runner = BotRunner::new();
|
||||
#[test]
|
||||
fn test_execute_script_not_found() {
|
||||
let runner = BotRunner::new();
|
||||
|
||||
let result = runner.execute_script("nonexistent", "Hi").await.unwrap();
|
||||
let result = runner.execute_script("nonexistent", "Hi").unwrap();
|
||||
|
||||
assert!(result.response.is_none());
|
||||
assert!(result.error.is_some());
|
||||
|
|
@ -628,9 +668,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_reset_metrics() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
|
||||
// Manually update metrics
|
||||
{
|
||||
let mut metrics = runner.metrics.lock().unwrap();
|
||||
metrics.total_requests = 100;
|
||||
|
|
@ -663,7 +702,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_session_info() {
|
||||
let mut runner = BotRunner::new();
|
||||
let runner = BotRunner::new();
|
||||
let customer = Customer::default();
|
||||
let customer_id = customer.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,17 @@
|
|||
//! Desktop application testing module
|
||||
//!
|
||||
//! Provides tools for testing native desktop applications using accessibility APIs
|
||||
//! and platform-specific automation frameworks.
|
||||
//!
|
||||
//! Note: Desktop testing is currently experimental and requires platform-specific
|
||||
//! setup (e.g., Accessibility permissions on macOS, AT-SPI on Linux).
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for desktop application testing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopConfig {
|
||||
/// Path to the application executable
|
||||
pub app_path: PathBuf,
|
||||
/// Command line arguments for the application
|
||||
pub args: Vec<String>,
|
||||
/// Environment variables to set
|
||||
pub env_vars: HashMap<String, String>,
|
||||
/// Working directory for the application
|
||||
pub working_dir: Option<PathBuf>,
|
||||
/// Timeout for operations
|
||||
pub timeout: Duration,
|
||||
/// Whether to capture screenshots on failure
|
||||
pub screenshot_on_failure: bool,
|
||||
/// Directory to save screenshots
|
||||
pub screenshot_dir: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +30,6 @@ impl Default for DesktopConfig {
|
|||
}
|
||||
|
||||
impl DesktopConfig {
|
||||
/// Create a new config for the given application path
|
||||
pub fn new(app_path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
app_path: app_path.into(),
|
||||
|
|
@ -54,32 +37,30 @@ impl DesktopConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Add command line arguments
|
||||
#[must_use]
|
||||
pub fn with_args(mut self, args: Vec<String>) -> Self {
|
||||
self.args = args;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an environment variable
|
||||
#[must_use]
|
||||
pub fn with_env(mut self, key: &str, value: &str) -> Self {
|
||||
self.env_vars.insert(key.to_string(), value.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the working directory
|
||||
pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.working_dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the timeout
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
#[must_use]
|
||||
pub const fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform type for desktop testing
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Platform {
|
||||
Windows,
|
||||
|
|
@ -88,20 +69,19 @@ pub enum Platform {
|
|||
}
|
||||
|
||||
impl Platform {
|
||||
/// Detect the current platform
|
||||
pub fn current() -> Self {
|
||||
#[must_use]
|
||||
pub const fn current() -> Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
return Platform::Windows;
|
||||
#[cfg(target_os = "macos")]
|
||||
return Platform::MacOS;
|
||||
#[cfg(target_os = "linux")]
|
||||
return Platform::Linux;
|
||||
return Self::Linux;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
panic!("Unsupported platform for desktop testing");
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop application handle for testing
|
||||
pub struct DesktopApp {
|
||||
config: DesktopConfig,
|
||||
platform: Platform,
|
||||
|
|
@ -110,8 +90,8 @@ pub struct DesktopApp {
|
|||
}
|
||||
|
||||
impl DesktopApp {
|
||||
/// Create a new desktop app handle
|
||||
pub fn new(config: DesktopConfig) -> Self {
|
||||
#[must_use]
|
||||
pub const fn new(config: DesktopConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
platform: Platform::current(),
|
||||
|
|
@ -120,7 +100,6 @@ impl DesktopApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Launch the application
|
||||
pub async fn launch(&mut self) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
|
|
@ -139,16 +118,13 @@ impl DesktopApp {
|
|||
self.pid = Some(child.id());
|
||||
self.process = Some(child);
|
||||
|
||||
// Wait for application to start
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the application
|
||||
pub async fn close(&mut self) -> Result<()> {
|
||||
if let Some(ref mut process) = self.process {
|
||||
// Try graceful shutdown first
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
|
|
@ -158,10 +134,8 @@ impl DesktopApp {
|
|||
}
|
||||
}
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Force kill if still running
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
self.process = None;
|
||||
|
|
@ -170,7 +144,6 @@ impl DesktopApp {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the application is running
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
if let Some(ref mut process) = self.process {
|
||||
match process.try_wait() {
|
||||
|
|
@ -187,64 +160,56 @@ impl DesktopApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the process ID
|
||||
pub fn pid(&self) -> Option<u32> {
|
||||
#[must_use]
|
||||
pub const fn pid(&self) -> Option<u32> {
|
||||
self.pid
|
||||
}
|
||||
|
||||
/// Get the platform
|
||||
pub fn platform(&self) -> Platform {
|
||||
#[must_use]
|
||||
pub const fn platform(&self) -> Platform {
|
||||
self.platform
|
||||
}
|
||||
|
||||
/// Find a window by title
|
||||
pub async fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
|
||||
// Platform-specific window finding
|
||||
pub fn find_window(&self, title: &str) -> Result<Option<WindowHandle>> {
|
||||
match self.platform {
|
||||
Platform::Windows => self.find_window_windows(title).await,
|
||||
Platform::MacOS => self.find_window_macos(title).await,
|
||||
Platform::Linux => self.find_window_linux(title).await,
|
||||
Platform::Windows => Self::find_window_windows(title),
|
||||
Platform::MacOS => Self::find_window_macos(title),
|
||||
Platform::Linux => Self::find_window_linux(title),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn find_window_windows(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
// Windows-specific implementation using Win32 API
|
||||
// Would use FindWindow or EnumWindows
|
||||
fn find_window_windows(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("Windows desktop testing not yet implemented")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
async fn find_window_windows(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
fn find_window_windows(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("Windows desktop testing not available on this platform")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn find_window_macos(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
// macOS-specific implementation using Accessibility API
|
||||
// Would use AXUIElement APIs
|
||||
fn find_window_macos(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("macOS desktop testing not yet implemented")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
async fn find_window_macos(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
fn find_window_macos(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("macOS desktop testing not available on this platform")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn find_window_linux(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
// Linux-specific implementation using AT-SPI or X11/Wayland
|
||||
// Would use libatspi or XGetWindowProperty
|
||||
fn find_window_linux(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("Linux desktop testing not yet implemented")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
async fn find_window_linux(&self, _title: &str) -> Result<Option<WindowHandle>> {
|
||||
fn find_window_linux(_title: &str) -> Result<Option<WindowHandle>> {
|
||||
anyhow::bail!("Linux desktop testing not available on this platform")
|
||||
}
|
||||
|
||||
/// Take a screenshot of the application
|
||||
pub async fn screenshot(&self) -> Result<Screenshot> {
|
||||
pub fn screenshot(&self) -> Result<Screenshot> {
|
||||
let _ = &self.platform;
|
||||
anyhow::bail!("Screenshot functionality not yet implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -258,29 +223,20 @@ impl Drop for DesktopApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle to a window
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowHandle {
|
||||
/// Platform-specific window identifier
|
||||
pub id: WindowId,
|
||||
/// Window title
|
||||
pub title: String,
|
||||
/// Window bounds
|
||||
pub bounds: WindowBounds,
|
||||
}
|
||||
|
||||
/// Platform-specific window identifier
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WindowId {
|
||||
/// Windows HWND (as usize)
|
||||
Windows(usize),
|
||||
/// macOS AXUIElement reference (opaque pointer)
|
||||
MacOS(usize),
|
||||
/// Linux X11 Window ID or AT-SPI path
|
||||
Linux(String),
|
||||
}
|
||||
|
||||
/// Window bounds
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
pub struct WindowBounds {
|
||||
pub x: i32,
|
||||
|
|
@ -289,121 +245,105 @@ pub struct WindowBounds {
|
|||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Screenshot data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Screenshot {
|
||||
/// Raw pixel data (RGBA)
|
||||
pub data: Vec<u8>,
|
||||
/// Width in pixels
|
||||
pub width: u32,
|
||||
/// Height in pixels
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl Screenshot {
|
||||
/// Save screenshot to a file
|
||||
pub fn save(&self, path: impl Into<PathBuf>) -> Result<()> {
|
||||
let _ = (&self.data, self.width, self.height);
|
||||
let path = path.into();
|
||||
// Would use image crate to save PNG
|
||||
anyhow::bail!("Screenshot save not yet implemented: {:?}", path)
|
||||
anyhow::bail!("Screenshot save not yet implemented: {}", path.display())
|
||||
}
|
||||
}
|
||||
|
||||
/// Element locator for desktop UI
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ElementLocator {
|
||||
/// Accessibility ID
|
||||
AccessibilityId(String),
|
||||
/// Element name/label
|
||||
Name(String),
|
||||
/// Element type/role
|
||||
Role(String),
|
||||
/// XPath-like path
|
||||
Path(String),
|
||||
/// Combination of properties
|
||||
Properties(HashMap<String, String>),
|
||||
}
|
||||
|
||||
impl ElementLocator {
|
||||
#[must_use]
|
||||
pub fn accessibility_id(id: &str) -> Self {
|
||||
Self::AccessibilityId(id.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(name: &str) -> Self {
|
||||
Self::Name(name.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn role(role: &str) -> Self {
|
||||
Self::Role(role.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn path(path: &str) -> Self {
|
||||
Self::Path(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop UI element
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Element {
|
||||
/// Element locator used to find this element
|
||||
pub locator: ElementLocator,
|
||||
/// Element role/type
|
||||
pub role: String,
|
||||
/// Element name/label
|
||||
pub name: Option<String>,
|
||||
/// Element value
|
||||
pub value: Option<String>,
|
||||
/// Element bounds
|
||||
pub bounds: WindowBounds,
|
||||
/// Whether the element is enabled
|
||||
pub enabled: bool,
|
||||
/// Whether the element is focused
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
/// Click the element
|
||||
pub async fn click(&self) -> Result<()> {
|
||||
pub fn click(&self) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element click not yet implemented")
|
||||
}
|
||||
|
||||
/// Double-click the element
|
||||
pub async fn double_click(&self) -> Result<()> {
|
||||
pub fn double_click(&self) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element double-click not yet implemented")
|
||||
}
|
||||
|
||||
/// Right-click the element
|
||||
pub async fn right_click(&self) -> Result<()> {
|
||||
pub fn right_click(&self) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element right-click not yet implemented")
|
||||
}
|
||||
|
||||
/// Type text into the element
|
||||
pub async fn type_text(&self, _text: &str) -> Result<()> {
|
||||
pub fn type_text(&self, _text: &str) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element type_text not yet implemented")
|
||||
}
|
||||
|
||||
/// Clear the element's text
|
||||
pub async fn clear(&self) -> Result<()> {
|
||||
pub fn clear(&self) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element clear not yet implemented")
|
||||
}
|
||||
|
||||
/// Get the element's text content
|
||||
#[must_use]
|
||||
pub fn text(&self) -> Option<&str> {
|
||||
self.value.as_deref()
|
||||
}
|
||||
|
||||
/// Check if element is displayed/visible
|
||||
pub fn is_displayed(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_displayed(&self) -> bool {
|
||||
self.bounds.width > 0 && self.bounds.height > 0
|
||||
}
|
||||
|
||||
/// Focus the element
|
||||
pub async fn focus(&self) -> Result<()> {
|
||||
pub fn focus(&self) -> Result<()> {
|
||||
let _ = &self.locator;
|
||||
anyhow::bail!("Element focus not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a desktop test
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DesktopTestResult {
|
||||
pub name: String,
|
||||
|
|
@ -414,7 +354,6 @@ pub struct DesktopTestResult {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A step in a desktop test
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestStep {
|
||||
pub name: String,
|
||||
|
|
@ -450,7 +389,6 @@ mod tests {
|
|||
#[test]
|
||||
fn test_platform_detection() {
|
||||
let platform = Platform::current();
|
||||
// Just verify it doesn't panic
|
||||
assert!(matches!(
|
||||
platform,
|
||||
Platform::Windows | Platform::MacOS | Platform::Linux
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
//! Data fixtures for tests
|
||||
//!
|
||||
//! Provides sample test data including JSON payloads, configurations,
|
||||
//! and pre-defined data sets for various test scenarios.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Sample configuration data
|
||||
#[must_use]
|
||||
pub fn sample_config() -> HashMap<String, String> {
|
||||
let mut config = HashMap::new();
|
||||
config.insert("llm-model".to_string(), "gpt-4".to_string());
|
||||
|
|
@ -20,7 +15,7 @@ pub fn sample_config() -> HashMap<String, String> {
|
|||
config
|
||||
}
|
||||
|
||||
/// Sample bot configuration as JSON
|
||||
#[must_use]
|
||||
pub fn sample_bot_config() -> Value {
|
||||
json!({
|
||||
"name": "test-bot",
|
||||
|
|
@ -53,7 +48,7 @@ pub fn sample_bot_config() -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample WhatsApp webhook payload for incoming text message
|
||||
#[must_use]
|
||||
pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"object": "whatsapp_business_account",
|
||||
|
|
@ -74,7 +69,7 @@ pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
|
|||
}],
|
||||
"messages": [{
|
||||
"from": from,
|
||||
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace("-", "")),
|
||||
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
|
||||
"timestamp": chrono::Utc::now().timestamp().to_string(),
|
||||
"type": "text",
|
||||
"text": {
|
||||
|
|
@ -88,7 +83,7 @@ pub fn whatsapp_text_message(from: &str, text: &str) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample WhatsApp webhook payload for button reply
|
||||
#[must_use]
|
||||
pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) -> Value {
|
||||
json!({
|
||||
"object": "whatsapp_business_account",
|
||||
|
|
@ -109,7 +104,7 @@ pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) ->
|
|||
}],
|
||||
"messages": [{
|
||||
"from": from,
|
||||
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace("-", "")),
|
||||
"id": format!("wamid.{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
|
||||
"timestamp": chrono::Utc::now().timestamp().to_string(),
|
||||
"type": "interactive",
|
||||
"interactive": {
|
||||
|
|
@ -127,7 +122,7 @@ pub fn whatsapp_button_reply(from: &str, button_id: &str, button_text: &str) ->
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample Teams activity for incoming message
|
||||
#[must_use]
|
||||
pub fn teams_message_activity(from_id: &str, from_name: &str, text: &str) -> Value {
|
||||
json!({
|
||||
"type": "message",
|
||||
|
|
@ -160,7 +155,7 @@ pub fn teams_message_activity(from_id: &str, from_name: &str, text: &str) -> Val
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample OpenAI chat completion request
|
||||
#[must_use]
|
||||
pub fn openai_chat_request(messages: Vec<(&str, &str)>) -> Value {
|
||||
let msgs: Vec<Value> = messages
|
||||
.into_iter()
|
||||
|
|
@ -180,7 +175,7 @@ pub fn openai_chat_request(messages: Vec<(&str, &str)>) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample OpenAI chat completion response
|
||||
#[must_use]
|
||||
pub fn openai_chat_response(content: &str) -> Value {
|
||||
json!({
|
||||
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()),
|
||||
|
|
@ -203,7 +198,7 @@ pub fn openai_chat_response(content: &str) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample OpenAI embedding response
|
||||
#[must_use]
|
||||
pub fn openai_embedding_response(dimensions: usize) -> Value {
|
||||
let embedding: Vec<f64> = (0..dimensions)
|
||||
.map(|i| (i as f64) / (dimensions as f64))
|
||||
|
|
@ -224,7 +219,7 @@ pub fn openai_embedding_response(dimensions: usize) -> Value {
|
|||
})
|
||||
}
|
||||
|
||||
/// Sample knowledge base entries
|
||||
#[must_use]
|
||||
pub fn sample_kb_entries() -> Vec<KBEntry> {
|
||||
vec![
|
||||
KBEntry {
|
||||
|
|
@ -258,7 +253,6 @@ pub fn sample_kb_entries() -> Vec<KBEntry> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Knowledge base entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KBEntry {
|
||||
pub id: String,
|
||||
|
|
@ -268,7 +262,7 @@ pub struct KBEntry {
|
|||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Sample product data
|
||||
#[must_use]
|
||||
pub fn sample_products() -> Vec<Product> {
|
||||
vec![
|
||||
Product {
|
||||
|
|
@ -298,7 +292,6 @@ pub fn sample_products() -> Vec<Product> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Product data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Product {
|
||||
pub sku: String,
|
||||
|
|
@ -309,7 +302,7 @@ pub struct Product {
|
|||
pub category: String,
|
||||
}
|
||||
|
||||
/// Sample FAQ data
|
||||
#[must_use]
|
||||
pub fn sample_faqs() -> Vec<FAQ> {
|
||||
vec![
|
||||
FAQ {
|
||||
|
|
@ -339,8 +332,8 @@ pub fn sample_faqs() -> Vec<FAQ> {
|
|||
]
|
||||
}
|
||||
|
||||
/// FAQ data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct FAQ {
|
||||
pub id: u32,
|
||||
pub question: String,
|
||||
|
|
@ -348,10 +341,10 @@ pub struct FAQ {
|
|||
pub category: String,
|
||||
}
|
||||
|
||||
/// Sample error responses
|
||||
pub mod errors {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[must_use]
|
||||
pub fn validation_error(field: &str, message: &str) -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -365,6 +358,7 @@ pub mod errors {
|
|||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn not_found(resource: &str, id: &str) -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -374,6 +368,7 @@ pub mod errors {
|
|||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unauthorized() -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -383,6 +378,7 @@ pub mod errors {
|
|||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn forbidden() -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -392,6 +388,7 @@ pub mod errors {
|
|||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn rate_limited(retry_after: u32) -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -402,6 +399,7 @@ pub mod errors {
|
|||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn internal_error() -> Value {
|
||||
json!({
|
||||
"error": {
|
||||
|
|
@ -427,10 +425,12 @@ mod tests {
|
|||
fn test_whatsapp_text_message() {
|
||||
let payload = whatsapp_text_message("15551234567", "Hello");
|
||||
assert_eq!(payload["object"], "whatsapp_business_account");
|
||||
assert!(payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Hello"));
|
||||
assert!(
|
||||
payload["entry"][0]["changes"][0]["value"]["messages"][0]["text"]["body"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Hello")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -455,7 +455,9 @@ mod tests {
|
|||
fn test_sample_kb_entries() {
|
||||
let entries = sample_kb_entries();
|
||||
assert!(!entries.is_empty());
|
||||
assert!(entries.iter().any(|e| e.category == Some("products".to_string())));
|
||||
assert!(entries
|
||||
.iter()
|
||||
.any(|e| e.category == Some("products".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Test fixtures and data factories
|
||||
//!
|
||||
//! Provides pre-built test data and factories for creating test objects
|
||||
//! including users, customers, bots, sessions, and conversations.
|
||||
|
||||
pub mod data;
|
||||
pub mod scripts;
|
||||
|
||||
|
|
@ -11,9 +6,6 @@ use serde::{Deserialize, Serialize};
|
|||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Re-export common fixtures
|
||||
|
||||
/// A test user
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
|
|
@ -39,23 +31,18 @@ impl Default for User {
|
|||
}
|
||||
}
|
||||
|
||||
/// User role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum Role {
|
||||
Admin,
|
||||
Attendant,
|
||||
#[default]
|
||||
User,
|
||||
Guest,
|
||||
}
|
||||
|
||||
impl Default for Role {
|
||||
fn default() -> Self {
|
||||
Self::User
|
||||
}
|
||||
}
|
||||
|
||||
/// A customer (end user interacting with bot)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Customer {
|
||||
pub id: Uuid,
|
||||
|
|
@ -85,10 +72,12 @@ impl Default for Customer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Communication channel
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Default)]
|
||||
pub enum Channel {
|
||||
#[default]
|
||||
WhatsApp,
|
||||
Teams,
|
||||
Web,
|
||||
|
|
@ -97,13 +86,7 @@ pub enum Channel {
|
|||
API,
|
||||
}
|
||||
|
||||
impl Default for Channel {
|
||||
fn default() -> Self {
|
||||
Self::WhatsApp
|
||||
}
|
||||
}
|
||||
|
||||
/// A bot configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Bot {
|
||||
pub id: Uuid,
|
||||
|
|
@ -135,7 +118,6 @@ impl Default for Bot {
|
|||
}
|
||||
}
|
||||
|
||||
/// A conversation session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: Uuid,
|
||||
|
|
@ -165,23 +147,18 @@ impl Default for Session {
|
|||
}
|
||||
}
|
||||
|
||||
/// Session state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum SessionState {
|
||||
#[default]
|
||||
Active,
|
||||
Waiting,
|
||||
Transferred,
|
||||
Ended,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
fn default() -> Self {
|
||||
Self::Active
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversation message
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
|
|
@ -207,7 +184,6 @@ impl Default for Message {
|
|||
}
|
||||
}
|
||||
|
||||
/// Message direction
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageDirection {
|
||||
|
|
@ -215,10 +191,11 @@ pub enum MessageDirection {
|
|||
Outgoing,
|
||||
}
|
||||
|
||||
/// Content type
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum ContentType {
|
||||
#[default]
|
||||
Text,
|
||||
Image,
|
||||
Audio,
|
||||
|
|
@ -229,13 +206,7 @@ pub enum ContentType {
|
|||
Interactive,
|
||||
}
|
||||
|
||||
impl Default for ContentType {
|
||||
fn default() -> Self {
|
||||
Self::Text
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue entry for attendance
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueueEntry {
|
||||
pub id: Uuid,
|
||||
|
|
@ -263,26 +234,23 @@ impl Default for QueueEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Queue priority
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum Priority {
|
||||
Low = 0,
|
||||
#[default]
|
||||
Normal = 1,
|
||||
High = 2,
|
||||
Urgent = 3,
|
||||
}
|
||||
|
||||
impl Default for Priority {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum QueueStatus {
|
||||
#[default]
|
||||
Waiting,
|
||||
Assigned,
|
||||
InProgress,
|
||||
|
|
@ -290,17 +258,8 @@ pub enum QueueStatus {
|
|||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for QueueStatus {
|
||||
fn default() -> Self {
|
||||
Self::Waiting
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Functions
|
||||
// =============================================================================
|
||||
|
||||
/// Create an admin user
|
||||
#[must_use]
|
||||
pub fn admin_user() -> User {
|
||||
User {
|
||||
email: "admin@test.com".to_string(),
|
||||
|
|
@ -310,7 +269,7 @@ pub fn admin_user() -> User {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an attendant user
|
||||
#[must_use]
|
||||
pub fn attendant_user() -> User {
|
||||
User {
|
||||
email: "attendant@test.com".to_string(),
|
||||
|
|
@ -320,7 +279,7 @@ pub fn attendant_user() -> User {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a regular user
|
||||
#[must_use]
|
||||
pub fn regular_user() -> User {
|
||||
User {
|
||||
email: "user@test.com".to_string(),
|
||||
|
|
@ -330,7 +289,7 @@ pub fn regular_user() -> User {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a user with specific email
|
||||
#[must_use]
|
||||
pub fn user_with_email(email: &str) -> User {
|
||||
User {
|
||||
email: email.to_string(),
|
||||
|
|
@ -339,7 +298,7 @@ pub fn user_with_email(email: &str) -> User {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a customer with a phone number
|
||||
#[must_use]
|
||||
pub fn customer(phone: &str) -> Customer {
|
||||
Customer {
|
||||
phone: Some(phone.to_string()),
|
||||
|
|
@ -348,7 +307,7 @@ pub fn customer(phone: &str) -> Customer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a customer for a specific channel
|
||||
#[must_use]
|
||||
pub fn customer_on_channel(channel: Channel) -> Customer {
|
||||
Customer {
|
||||
channel,
|
||||
|
|
@ -356,7 +315,7 @@ pub fn customer_on_channel(channel: Channel) -> Customer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a Teams customer
|
||||
#[must_use]
|
||||
pub fn teams_customer() -> Customer {
|
||||
Customer {
|
||||
channel: Channel::Teams,
|
||||
|
|
@ -365,7 +324,7 @@ pub fn teams_customer() -> Customer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a web customer
|
||||
#[must_use]
|
||||
pub fn web_customer() -> Customer {
|
||||
Customer {
|
||||
channel: Channel::Web,
|
||||
|
|
@ -374,7 +333,7 @@ pub fn web_customer() -> Customer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a basic bot
|
||||
#[must_use]
|
||||
pub fn basic_bot(name: &str) -> Bot {
|
||||
Bot {
|
||||
name: name.to_string(),
|
||||
|
|
@ -384,7 +343,7 @@ pub fn basic_bot(name: &str) -> Bot {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a bot with knowledge base enabled
|
||||
#[must_use]
|
||||
pub fn bot_with_kb(name: &str) -> Bot {
|
||||
Bot {
|
||||
name: name.to_string(),
|
||||
|
|
@ -394,7 +353,7 @@ pub fn bot_with_kb(name: &str) -> Bot {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a bot without LLM (rule-based only)
|
||||
#[must_use]
|
||||
pub fn rule_based_bot(name: &str) -> Bot {
|
||||
Bot {
|
||||
name: name.to_string(),
|
||||
|
|
@ -405,7 +364,7 @@ pub fn rule_based_bot(name: &str) -> Bot {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a session for a bot and customer
|
||||
#[must_use]
|
||||
pub fn session_for(bot: &Bot, customer: &Customer) -> Session {
|
||||
Session {
|
||||
bot_id: bot.id,
|
||||
|
|
@ -415,7 +374,7 @@ pub fn session_for(bot: &Bot, customer: &Customer) -> Session {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an active session
|
||||
#[must_use]
|
||||
pub fn active_session() -> Session {
|
||||
Session {
|
||||
state: SessionState::Active,
|
||||
|
|
@ -423,7 +382,7 @@ pub fn active_session() -> Session {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an incoming message
|
||||
#[must_use]
|
||||
pub fn incoming_message(content: &str) -> Message {
|
||||
Message {
|
||||
direction: MessageDirection::Incoming,
|
||||
|
|
@ -432,7 +391,7 @@ pub fn incoming_message(content: &str) -> Message {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an outgoing message
|
||||
#[must_use]
|
||||
pub fn outgoing_message(content: &str) -> Message {
|
||||
Message {
|
||||
direction: MessageDirection::Outgoing,
|
||||
|
|
@ -441,7 +400,7 @@ pub fn outgoing_message(content: &str) -> Message {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a message in a session
|
||||
#[must_use]
|
||||
pub fn message_in_session(
|
||||
session: &Session,
|
||||
content: &str,
|
||||
|
|
@ -455,7 +414,7 @@ pub fn message_in_session(
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a queue entry for a customer
|
||||
#[must_use]
|
||||
pub fn queue_entry_for(customer: &Customer, session: &Session) -> QueueEntry {
|
||||
QueueEntry {
|
||||
customer_id: customer.id,
|
||||
|
|
@ -464,7 +423,7 @@ pub fn queue_entry_for(customer: &Customer, session: &Session) -> QueueEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create a high priority queue entry
|
||||
#[must_use]
|
||||
pub fn high_priority_queue_entry() -> QueueEntry {
|
||||
QueueEntry {
|
||||
priority: Priority::High,
|
||||
|
|
@ -472,7 +431,7 @@ pub fn high_priority_queue_entry() -> QueueEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an urgent queue entry
|
||||
#[must_use]
|
||||
pub fn urgent_queue_entry() -> QueueEntry {
|
||||
QueueEntry {
|
||||
priority: Priority::Urgent,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
//! BASIC script fixtures for testing bot behavior
|
||||
//!
|
||||
//! Provides sample BASIC scripts that can be used to test
|
||||
//! the BASIC interpreter and bot conversation flows.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Get a script fixture by name
|
||||
#[must_use]
|
||||
pub fn get_script(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"greeting" => Some(GREETING_SCRIPT),
|
||||
|
|
@ -22,7 +18,7 @@ pub fn get_script(name: &str) -> Option<&'static str> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get all available script names
|
||||
#[must_use]
|
||||
pub fn available_scripts() -> Vec<&'static str> {
|
||||
vec![
|
||||
"greeting",
|
||||
|
|
@ -38,7 +34,7 @@ pub fn available_scripts() -> Vec<&'static str> {
|
|||
]
|
||||
}
|
||||
|
||||
/// Get all scripts as a map
|
||||
#[must_use]
|
||||
pub fn all_scripts() -> HashMap<&'static str, &'static str> {
|
||||
let mut scripts = HashMap::new();
|
||||
for name in available_scripts() {
|
||||
|
|
@ -49,7 +45,6 @@ pub fn all_scripts() -> HashMap<&'static str, &'static str> {
|
|||
scripts
|
||||
}
|
||||
|
||||
/// Simple greeting flow script
|
||||
pub const GREETING_SCRIPT: &str = r#"
|
||||
' Greeting Flow Script
|
||||
' Simple greeting and response pattern
|
||||
|
|
@ -72,7 +67,6 @@ ELSE
|
|||
END IF
|
||||
"#;
|
||||
|
||||
/// Knowledge base search script
|
||||
pub const KB_SEARCH_SCRIPT: &str = r#"
|
||||
' Knowledge Base Search Script
|
||||
' Demonstrates searching the knowledge base
|
||||
|
|
@ -98,7 +92,6 @@ ELSE
|
|||
END IF
|
||||
"#;
|
||||
|
||||
/// Human handoff / attendance flow script
|
||||
pub const ATTENDANCE_SCRIPT: &str = r#"
|
||||
' Attendance / Human Handoff Script
|
||||
' Demonstrates transferring to human agents
|
||||
|
|
@ -130,7 +123,6 @@ ELSE
|
|||
END IF
|
||||
"#;
|
||||
|
||||
/// Error handling patterns script
|
||||
pub const ERROR_HANDLING_SCRIPT: &str = r#"
|
||||
' Error Handling Script
|
||||
' Demonstrates ON ERROR RESUME NEXT patterns
|
||||
|
|
@ -167,7 +159,6 @@ TALK "Processing your request: " + LEFT$(userInput$, 50) + "..."
|
|||
retry_input:
|
||||
"#;
|
||||
|
||||
/// LLM with tools script
|
||||
pub const LLM_TOOLS_SCRIPT: &str = r#"
|
||||
' LLM Tools Script
|
||||
' Demonstrates LLM with function calling / tools
|
||||
|
|
@ -209,7 +200,6 @@ TALK llm.response
|
|||
GOTO conversation_loop
|
||||
"#;
|
||||
|
||||
/// Data operations script
|
||||
pub const DATA_OPERATIONS_SCRIPT: &str = r#"
|
||||
' Data Operations Script
|
||||
' Demonstrates FIND, SAVE, UPDATE, DELETE operations
|
||||
|
|
@ -251,7 +241,6 @@ BEGIN TRANSACTION
|
|||
COMMIT TRANSACTION
|
||||
"#;
|
||||
|
||||
/// HTTP integration script
|
||||
pub const HTTP_INTEGRATION_SCRIPT: &str = r#"
|
||||
' HTTP Integration Script
|
||||
' Demonstrates POST, GET, GRAPHQL, SOAP calls
|
||||
|
|
@ -286,7 +275,6 @@ soap_response = SOAP "https://api.example.com/soap" ACTION "GetProduct" BODY soa
|
|||
TALK "Product: " + soap_response.ProductName
|
||||
"#;
|
||||
|
||||
/// Menu-driven conversation flow
|
||||
pub const MENU_FLOW_SCRIPT: &str = r#"
|
||||
' Menu Flow Script
|
||||
' Demonstrates interactive menu-based conversation
|
||||
|
|
@ -364,7 +352,6 @@ return_item:
|
|||
RETURN
|
||||
"#;
|
||||
|
||||
/// Simple echo script for basic testing
|
||||
pub const SIMPLE_ECHO_SCRIPT: &str = r#"
|
||||
' Simple Echo Script
|
||||
' Echoes back whatever the user says
|
||||
|
|
@ -383,7 +370,6 @@ TALK "You said: " + input$
|
|||
GOTO echo_loop
|
||||
"#;
|
||||
|
||||
/// Variables and expressions script
|
||||
pub const VARIABLES_SCRIPT: &str = r#"
|
||||
' Variables and Expressions Script
|
||||
' Demonstrates variable types and operations
|
||||
|
|
|
|||
261
src/harness.rs
261
src/harness.rs
|
|
@ -35,7 +35,8 @@ impl Default for TestConfig {
|
|||
}
|
||||
|
||||
impl TestConfig {
|
||||
pub fn minimal() -> Self {
|
||||
#[must_use]
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
postgres: false,
|
||||
minio: false,
|
||||
|
|
@ -46,31 +47,32 @@ impl TestConfig {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn full() -> Self {
|
||||
#[must_use]
|
||||
pub const fn full() -> Self {
|
||||
Self {
|
||||
postgres: false, // Botserver will bootstrap its own PostgreSQL
|
||||
minio: false, // Botserver will bootstrap its own MinIO
|
||||
redis: false, // Botserver will bootstrap its own Redis
|
||||
postgres: false,
|
||||
minio: false,
|
||||
redis: false,
|
||||
mock_zitadel: true,
|
||||
mock_llm: true,
|
||||
run_migrations: false, // Let botserver run its own migrations
|
||||
run_migrations: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-install mode: let botserver bootstrap all services
|
||||
/// No need for pre-installed PostgreSQL binaries
|
||||
pub fn auto_install() -> Self {
|
||||
#[must_use]
|
||||
pub const fn auto_install() -> Self {
|
||||
Self {
|
||||
postgres: false, // Botserver will install PostgreSQL
|
||||
minio: false, // Botserver will install MinIO
|
||||
redis: false, // Botserver will install Redis
|
||||
postgres: false,
|
||||
minio: false,
|
||||
redis: false,
|
||||
mock_zitadel: true,
|
||||
mock_llm: true,
|
||||
run_migrations: false, // Botserver handles migrations
|
||||
run_migrations: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_only() -> Self {
|
||||
#[must_use]
|
||||
pub const fn database_only() -> Self {
|
||||
Self {
|
||||
postgres: true,
|
||||
run_migrations: true,
|
||||
|
|
@ -78,7 +80,8 @@ impl TestConfig {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn use_existing_stack() -> Self {
|
||||
#[must_use]
|
||||
pub const fn use_existing_stack() -> Self {
|
||||
Self {
|
||||
postgres: false,
|
||||
minio: false,
|
||||
|
|
@ -116,29 +119,22 @@ pub struct TestContext {
|
|||
}
|
||||
|
||||
impl TestContext {
|
||||
pub fn test_id(&self) -> Uuid {
|
||||
pub const fn test_id(&self) -> Uuid {
|
||||
self.test_id
|
||||
}
|
||||
|
||||
pub fn database_url(&self) -> String {
|
||||
if self.use_existing_stack {
|
||||
// For existing stack, use sensible defaults matching botserver's bootstrap
|
||||
// These can be overridden via environment variables if needed
|
||||
let host = std::env::var("DB_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port = std::env::var("DB_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DefaultPorts::POSTGRES);
|
||||
// Default to gbuser/botserver which is what botserver bootstrap creates
|
||||
let user = std::env::var("DB_USER").unwrap_or_else(|_| "gbuser".to_string());
|
||||
let password = std::env::var("DB_PASSWORD").unwrap_or_else(|_| "gbuser".to_string());
|
||||
let database = std::env::var("DB_NAME").unwrap_or_else(|_| "botserver".to_string());
|
||||
format!(
|
||||
"postgres://{}:{}@{}:{}/{}",
|
||||
user, password, host, port, database
|
||||
)
|
||||
format!("postgres://{user}:{password}@{host}:{port}/{database}")
|
||||
} else {
|
||||
// For test-managed postgres, use test credentials
|
||||
format!(
|
||||
"postgres://bottest:bottest@127.0.0.1:{}/bottest",
|
||||
self.ports.postgres
|
||||
|
|
@ -181,28 +177,28 @@ impl TestContext {
|
|||
Pool::builder()
|
||||
.max_size(5)
|
||||
.build(manager)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create pool: {}", e))
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create pool: {e}"))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn mock_zitadel(&self) -> Option<&MockZitadel> {
|
||||
pub const fn mock_zitadel(&self) -> Option<&MockZitadel> {
|
||||
self.mock_zitadel.as_ref()
|
||||
}
|
||||
|
||||
pub fn mock_llm(&self) -> Option<&MockLLM> {
|
||||
pub const fn mock_llm(&self) -> Option<&MockLLM> {
|
||||
self.mock_llm.as_ref()
|
||||
}
|
||||
|
||||
pub fn postgres(&self) -> Option<&PostgresService> {
|
||||
pub const fn postgres(&self) -> Option<&PostgresService> {
|
||||
self.postgres.as_ref()
|
||||
}
|
||||
|
||||
pub fn minio(&self) -> Option<&MinioService> {
|
||||
pub const fn minio(&self) -> Option<&MinioService> {
|
||||
self.minio.as_ref()
|
||||
}
|
||||
|
||||
pub fn redis(&self) -> Option<&RedisService> {
|
||||
pub const fn redis(&self) -> Option<&RedisService> {
|
||||
self.redis.as_ref()
|
||||
}
|
||||
|
||||
|
|
@ -452,11 +448,11 @@ pub struct BotServerInstance {
|
|||
}
|
||||
|
||||
impl BotServerInstance {
|
||||
/// Create an instance pointing to an already-running botserver
|
||||
#[must_use]
|
||||
pub fn existing(url: &str) -> Self {
|
||||
let port = url
|
||||
.split(':')
|
||||
.last()
|
||||
.next_back()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080);
|
||||
Self {
|
||||
|
|
@ -467,9 +463,6 @@ impl BotServerInstance {
|
|||
}
|
||||
}
|
||||
|
||||
/// Start botserver using the MAIN stack (../botserver/botserver-stack)
|
||||
/// This uses the real stack with LLM, Zitadel, etc. already configured
|
||||
/// For E2E demo tests that need actual bot responses
|
||||
pub async fn start_with_main_stack() -> Result<Self> {
|
||||
let port = 8080;
|
||||
let url = "https://localhost:8080".to_string();
|
||||
|
|
@ -477,23 +470,20 @@ impl BotServerInstance {
|
|||
let botserver_bin = std::env::var("BOTSERVER_BIN")
|
||||
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
|
||||
|
||||
// Check if binary exists
|
||||
if !PathBuf::from(&botserver_bin).exists() {
|
||||
log::warn!("Botserver binary not found at: {}", botserver_bin);
|
||||
log::warn!("Botserver binary not found at: {botserver_bin}");
|
||||
anyhow::bail!(
|
||||
"Botserver binary not found at: {}. Run: cd ../botserver && cargo build",
|
||||
botserver_bin
|
||||
"Botserver binary not found at: {botserver_bin}. Run: cd ../botserver && cargo build"
|
||||
);
|
||||
}
|
||||
|
||||
// Get absolute path to botserver directory (where botserver-stack lives)
|
||||
let botserver_bin_path =
|
||||
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
|
||||
let botserver_dir = botserver_bin_path
|
||||
.parent() // target/debug
|
||||
.and_then(|p| p.parent()) // target
|
||||
.and_then(|p| p.parent()) // botserver
|
||||
.map(|p| p.to_path_buf())
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| {
|
||||
std::fs::canonicalize("../botserver")
|
||||
.unwrap_or_else(|_| PathBuf::from("../botserver"))
|
||||
|
|
@ -501,22 +491,21 @@ impl BotServerInstance {
|
|||
|
||||
let stack_path = botserver_dir.join("botserver-stack");
|
||||
|
||||
// Check if main stack exists
|
||||
if !stack_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Main botserver-stack not found at {:?}.\n\
|
||||
"Main botserver-stack not found at {}.\n\
|
||||
Run botserver once to initialize: cd ../botserver && cargo run",
|
||||
stack_path
|
||||
stack_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Starting botserver with MAIN stack at {:?}", stack_path);
|
||||
log::info!(
|
||||
"Starting botserver with MAIN stack at {}",
|
||||
stack_path.display()
|
||||
);
|
||||
println!("🚀 Starting BotServer with main stack...");
|
||||
println!(" Stack: {:?}", stack_path);
|
||||
println!(" Stack: {}", stack_path.display());
|
||||
|
||||
// Start botserver from its directory, using default stack path
|
||||
// NO --stack-path argument = uses ./botserver-stack (the main one)
|
||||
// NO mock env vars = uses real services
|
||||
let process = std::process::Command::new(&botserver_bin_path)
|
||||
.current_dir(&botserver_dir)
|
||||
.arg("--noconsole")
|
||||
|
|
@ -527,9 +516,8 @@ impl BotServerInstance {
|
|||
.ok();
|
||||
|
||||
if process.is_some() {
|
||||
// Wait for botserver to be ready (may take time for LLM to load)
|
||||
let max_wait = 120; // 2 minutes for LLM
|
||||
log::info!("Waiting for botserver to start (max {}s)...", max_wait);
|
||||
let max_wait = 120;
|
||||
log::info!("Waiting for botserver to start (max {max_wait}s)...");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
|
|
@ -538,10 +526,10 @@ impl BotServerInstance {
|
|||
.unwrap_or_default();
|
||||
|
||||
for i in 0..max_wait {
|
||||
if let Ok(resp) = client.get(format!("{}/health", url)).send().await {
|
||||
if let Ok(resp) = client.get(format!("{url}/health")).send().await {
|
||||
if resp.status().is_success() {
|
||||
log::info!("Botserver ready on port {}", port);
|
||||
println!(" ✓ BotServer ready at {}", url);
|
||||
log::info!("Botserver ready on port {port}");
|
||||
println!(" ✓ BotServer ready at {url}");
|
||||
return Ok(Self {
|
||||
url,
|
||||
port,
|
||||
|
|
@ -551,8 +539,8 @@ impl BotServerInstance {
|
|||
}
|
||||
}
|
||||
if i % 10 == 0 && i > 0 {
|
||||
log::info!("Still waiting for botserver... ({}s)", i);
|
||||
println!(" ... waiting ({}s)", i);
|
||||
log::info!("Still waiting for botserver... ({i}s)");
|
||||
println!(" ... waiting ({i}s)");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
|
@ -576,11 +564,11 @@ pub struct BotUIInstance {
|
|||
}
|
||||
|
||||
impl BotUIInstance {
|
||||
/// Create an instance pointing to an already-running botui
|
||||
#[must_use]
|
||||
pub fn existing(url: &str) -> Self {
|
||||
let port = url
|
||||
.split(':')
|
||||
.last()
|
||||
.next_back()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000);
|
||||
Self {
|
||||
|
|
@ -594,14 +582,13 @@ impl BotUIInstance {
|
|||
impl BotUIInstance {
|
||||
pub async fn start(ctx: &TestContext, botserver_url: &str) -> Result<Self> {
|
||||
let port = crate::ports::PortAllocator::allocate();
|
||||
let url = format!("http://127.0.0.1:{}", port);
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let botui_bin = std::env::var("BOTUI_BIN")
|
||||
.unwrap_or_else(|_| "../botui/target/debug/botui".to_string());
|
||||
|
||||
// Check if binary exists
|
||||
if !PathBuf::from(&botui_bin).exists() {
|
||||
log::warn!("BotUI binary not found at: {}", botui_bin);
|
||||
log::warn!("BotUI binary not found at: {botui_bin}");
|
||||
return Ok(Self {
|
||||
url,
|
||||
port,
|
||||
|
|
@ -609,26 +596,22 @@ impl BotUIInstance {
|
|||
});
|
||||
}
|
||||
|
||||
// BotUI needs to run from its own directory so it can find ui/ folder
|
||||
// Get absolute path of botui binary and derive working directory
|
||||
let botui_bin_path =
|
||||
std::fs::canonicalize(&botui_bin).unwrap_or_else(|_| PathBuf::from(&botui_bin));
|
||||
let botui_dir = botui_bin_path
|
||||
.parent() // target/debug
|
||||
.and_then(|p| p.parent()) // target
|
||||
.and_then(|p| p.parent()) // botui
|
||||
.map(|p| p.to_path_buf())
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| {
|
||||
std::fs::canonicalize("../botui").unwrap_or_else(|_| PathBuf::from("../botui"))
|
||||
});
|
||||
|
||||
log::info!("Starting botui from: {} on port {}", botui_bin, port);
|
||||
log::info!(" BOTUI_PORT={}", port);
|
||||
log::info!(" BOTSERVER_URL={}", botserver_url);
|
||||
log::info!(" Working directory: {:?}", botui_dir);
|
||||
log::info!("Starting botui from: {botui_bin} on port {port}");
|
||||
log::info!(" BOTUI_PORT={port}");
|
||||
log::info!(" BOTSERVER_URL={botserver_url}");
|
||||
log::info!(" Working directory: {}", botui_dir.display());
|
||||
|
||||
// botui uses env vars, not command line args
|
||||
// Must run from botui directory to find ui/ folder
|
||||
let process = std::process::Command::new(&botui_bin_path)
|
||||
.current_dir(&botui_dir)
|
||||
.env("BOTUI_PORT", port.to_string())
|
||||
|
|
@ -640,25 +623,23 @@ impl BotUIInstance {
|
|||
.ok();
|
||||
|
||||
if process.is_some() {
|
||||
// Wait for botui to be ready
|
||||
let max_wait = 30;
|
||||
log::info!("Waiting for botui to become ready... (max {}s)", max_wait);
|
||||
log::info!("Waiting for botui to become ready... (max {max_wait}s)");
|
||||
for i in 0..max_wait {
|
||||
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
|
||||
if let Ok(resp) = reqwest::get(&format!("{url}/health")).await {
|
||||
if resp.status().is_success() {
|
||||
log::info!("BotUI is ready on port {}", port);
|
||||
log::info!("BotUI is ready on port {port}");
|
||||
return Ok(Self { url, port, process });
|
||||
}
|
||||
}
|
||||
// Also try root path in case /health isn't implemented
|
||||
if let Ok(resp) = reqwest::get(&url).await {
|
||||
if resp.status().is_success() {
|
||||
log::info!("BotUI is ready on port {}", port);
|
||||
log::info!("BotUI is ready on port {port}");
|
||||
return Ok(Self { url, port, process });
|
||||
}
|
||||
}
|
||||
if i % 5 == 0 {
|
||||
log::info!("Still waiting for botui... ({}s)", i);
|
||||
log::info!("Still waiting for botui... ({i}s)");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
|
@ -672,7 +653,8 @@ impl BotUIInstance {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_running(&self) -> bool {
|
||||
self.process.is_some()
|
||||
}
|
||||
}
|
||||
|
|
@ -687,24 +669,20 @@ impl Drop for BotUIInstance {
|
|||
}
|
||||
|
||||
impl BotServerInstance {
|
||||
/// Start botserver, creating a fresh stack from scratch for testing
|
||||
pub async fn start(ctx: &TestContext) -> Result<Self> {
|
||||
let port = ctx.ports.botserver;
|
||||
let url = format!("http://127.0.0.1:{}", port);
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
// Create a clean test stack directory for this test run
|
||||
// Use absolute path since we'll change working directory for botserver
|
||||
let stack_path = ctx.data_dir.join("botserver-stack");
|
||||
std::fs::create_dir_all(&stack_path)?;
|
||||
let stack_path = stack_path.canonicalize().unwrap_or(stack_path);
|
||||
log::info!("Created clean test stack at: {:?}", stack_path);
|
||||
log::info!("Created clean test stack at: {}", stack_path.display());
|
||||
|
||||
let botserver_bin = std::env::var("BOTSERVER_BIN")
|
||||
.unwrap_or_else(|_| "../botserver/target/debug/botserver".to_string());
|
||||
|
||||
// Check if binary exists
|
||||
if !PathBuf::from(&botserver_bin).exists() {
|
||||
log::warn!("Botserver binary not found at: {}", botserver_bin);
|
||||
log::warn!("Botserver binary not found at: {botserver_bin}");
|
||||
return Ok(Self {
|
||||
url,
|
||||
port,
|
||||
|
|
@ -713,76 +691,54 @@ impl BotServerInstance {
|
|||
});
|
||||
}
|
||||
|
||||
log::info!("Starting botserver from: {}", botserver_bin);
|
||||
log::info!("Starting botserver from: {botserver_bin}");
|
||||
|
||||
// Determine botserver working directory to find installers in botserver-installers/
|
||||
// The botserver binary is typically at ../botserver/target/release/botserver
|
||||
// We need to run from ../botserver so it finds botserver-installers/ and 3rdparty.toml
|
||||
let botserver_bin_path =
|
||||
std::fs::canonicalize(&botserver_bin).unwrap_or_else(|_| PathBuf::from(&botserver_bin));
|
||||
let botserver_dir = botserver_bin_path
|
||||
.parent() // target/release
|
||||
.and_then(|p| p.parent()) // target
|
||||
.and_then(|p| p.parent()) // botserver
|
||||
.map(|p| p.to_path_buf())
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| {
|
||||
std::fs::canonicalize("../botserver")
|
||||
.unwrap_or_else(|_| PathBuf::from("../botserver"))
|
||||
});
|
||||
|
||||
log::info!("Botserver working directory: {:?}", botserver_dir);
|
||||
log::info!("Stack path (absolute): {:?}", stack_path);
|
||||
log::info!("Botserver working directory: {}", botserver_dir.display());
|
||||
log::info!("Stack path (absolute): {}", stack_path.display());
|
||||
|
||||
// Start botserver with test configuration
|
||||
// - Uses test harness PostgreSQL
|
||||
// - Uses mock Zitadel for auth
|
||||
// - Uses mock LLM
|
||||
// Env vars align with SecretsManager fallbacks (see botserver/src/core/secrets/mod.rs)
|
||||
// Use absolute path for binary since we're changing working directory
|
||||
|
||||
// Point to local installers directory to avoid downloads
|
||||
let installers_path = botserver_dir.join("botserver-installers");
|
||||
let installers_path = installers_path.canonicalize().unwrap_or(installers_path);
|
||||
log::info!("Using installers from: {:?}", installers_path);
|
||||
log::info!("Using installers from: {}", installers_path.display());
|
||||
|
||||
let process = std::process::Command::new(&botserver_bin_path)
|
||||
.current_dir(&botserver_dir) // Run from botserver dir to find installers
|
||||
.current_dir(&botserver_dir)
|
||||
.arg("--stack-path")
|
||||
.arg(&stack_path)
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.arg("--noconsole")
|
||||
.env_remove("RUST_LOG") // Remove to avoid logger conflict
|
||||
// Use local installers - DO NOT download
|
||||
.env_remove("RUST_LOG")
|
||||
.env("BOTSERVER_INSTALLERS_PATH", &installers_path)
|
||||
// Database - DATABASE_URL is the standard fallback
|
||||
.env("DATABASE_URL", ctx.database_url())
|
||||
// Directory (Zitadel) - use SecretsManager fallback env vars
|
||||
.env("DIRECTORY_URL", ctx.zitadel_url())
|
||||
.env("ZITADEL_CLIENT_ID", "test-client-id")
|
||||
.env("ZITADEL_CLIENT_SECRET", "test-client-secret")
|
||||
// Drive (MinIO) - use SecretsManager fallback env vars
|
||||
.env("DRIVE_ACCESSKEY", "minioadmin")
|
||||
.env("DRIVE_SECRET", "minioadmin")
|
||||
// Always let botserver bootstrap services (PostgreSQL, MinIO, Redis, etc.)
|
||||
// No BOTSERVER_SKIP_INSTALL - we want full bootstrap
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.ok();
|
||||
|
||||
if process.is_some() {
|
||||
// Give time for botserver bootstrap (needs to download Vault, PostgreSQL, etc.)
|
||||
let max_wait = 600;
|
||||
log::info!(
|
||||
"Waiting for botserver to bootstrap and become ready... (max {}s)",
|
||||
max_wait
|
||||
);
|
||||
// Give more time for botserver to bootstrap services
|
||||
log::info!("Waiting for botserver to bootstrap and become ready... (max {max_wait}s)");
|
||||
for i in 0..max_wait {
|
||||
if let Ok(resp) = reqwest::get(&format!("{}/health", url)).await {
|
||||
if let Ok(resp) = reqwest::get(&format!("{url}/health")).await {
|
||||
if resp.status().is_success() {
|
||||
log::info!("Botserver is ready on port {}", port);
|
||||
log::info!("Botserver is ready on port {port}");
|
||||
return Ok(Self {
|
||||
url,
|
||||
port,
|
||||
|
|
@ -792,7 +748,7 @@ impl BotServerInstance {
|
|||
}
|
||||
}
|
||||
if i % 10 == 0 {
|
||||
log::info!("Still waiting for botserver... ({}s)", i);
|
||||
log::info!("Still waiting for botserver... ({i}s)");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
|
@ -807,17 +763,15 @@ impl BotServerInstance {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_running(&self) -> bool {
|
||||
self.process.is_some()
|
||||
}
|
||||
|
||||
/// Setup minimal config files so botserver thinks services are configured
|
||||
fn setup_test_stack_config(stack_path: &PathBuf, ctx: &TestContext) -> Result<()> {
|
||||
// Create directory config path
|
||||
fn setup_test_stack_config(stack_path: &std::path::Path, ctx: &TestContext) -> Result<()> {
|
||||
let directory_conf = stack_path.join("conf/directory");
|
||||
std::fs::create_dir_all(&directory_conf)?;
|
||||
|
||||
// Create zitadel.yaml pointing to our mock Zitadel
|
||||
let zitadel_config = format!(
|
||||
r#"Log:
|
||||
Level: info
|
||||
|
|
@ -842,27 +796,22 @@ ExternalPort: {}
|
|||
std::fs::write(directory_conf.join("zitadel.yaml"), zitadel_config)?;
|
||||
log::info!("Created test zitadel.yaml config");
|
||||
|
||||
// Create system certificates directory
|
||||
let certs_dir = stack_path.join("conf/system/certificates");
|
||||
std::fs::create_dir_all(&certs_dir)?;
|
||||
|
||||
// Generate minimal self-signed certificates for API
|
||||
Self::generate_test_certificates(&certs_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate minimal test certificates
|
||||
fn generate_test_certificates(certs_dir: &PathBuf) -> Result<()> {
|
||||
fn generate_test_certificates(certs_dir: &std::path::Path) -> Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let api_dir = certs_dir.join("api");
|
||||
std::fs::create_dir_all(&api_dir)?;
|
||||
|
||||
// Check if openssl is available
|
||||
let openssl_check = Command::new("which").arg("openssl").output();
|
||||
if openssl_check.map(|o| o.status.success()).unwrap_or(false) {
|
||||
// Generate self-signed certificate using openssl
|
||||
let key_path = api_dir.join("server.key");
|
||||
let cert_path = api_dir.join("server.crt");
|
||||
|
||||
|
|
@ -914,12 +863,9 @@ impl TestHarness {
|
|||
Self::setup_internal(TestConfig::use_existing_stack(), true).await
|
||||
}
|
||||
|
||||
/// Kill all processes that might interfere with tests
|
||||
/// This ensures a clean slate before starting test infrastructure
|
||||
fn cleanup_existing_processes() {
|
||||
log::info!("Cleaning up any existing stack processes before test...");
|
||||
|
||||
// List of process patterns to kill
|
||||
let patterns = [
|
||||
"botserver",
|
||||
"botui",
|
||||
|
|
@ -936,24 +882,19 @@ impl TestHarness {
|
|||
];
|
||||
|
||||
for pattern in patterns {
|
||||
// Use pkill to kill processes matching pattern
|
||||
// Ignore errors - process might not exist
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-9", "-f", pattern])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Clean up browser profile directories using shell rm
|
||||
let _ = std::process::Command::new("rm")
|
||||
.args(["-rf", "/tmp/browser-test-*"])
|
||||
.output();
|
||||
|
||||
// Clean up old test data directories (older than 1 hour)
|
||||
let _ = std::process::Command::new("sh")
|
||||
.args(["-c", "find ./tmp -maxdepth 1 -name 'bottest-*' -type d -mmin +60 -exec rm -rf {} + 2>/dev/null"])
|
||||
.output();
|
||||
|
||||
// Give processes time to terminate
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
|
||||
log::info!("Process cleanup completed");
|
||||
|
|
@ -962,14 +903,12 @@ impl TestHarness {
|
|||
async fn setup_internal(config: TestConfig, use_existing_stack: bool) -> Result<TestContext> {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
|
||||
// Clean up any existing processes that might interfere
|
||||
// Skip if using existing stack (user wants to connect to running services)
|
||||
if !use_existing_stack {
|
||||
Self::cleanup_existing_processes();
|
||||
}
|
||||
|
||||
let test_id = Uuid::new_v4();
|
||||
let data_dir = PathBuf::from("./tmp").join(format!("bottest-{}", test_id));
|
||||
let data_dir = PathBuf::from("./tmp").join(format!("bottest-{test_id}"));
|
||||
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
|
||||
|
|
@ -987,11 +926,8 @@ impl TestHarness {
|
|||
};
|
||||
|
||||
log::info!(
|
||||
"Test {} allocated ports: {:?}, data_dir: {:?}, use_existing_stack: {}",
|
||||
test_id,
|
||||
ports,
|
||||
data_dir,
|
||||
use_existing_stack
|
||||
"Test {test_id} allocated ports: {ports:?}, data_dir: {}, use_existing_stack: {use_existing_stack}",
|
||||
data_dir.display()
|
||||
);
|
||||
|
||||
let data_dir_str = data_dir.to_str().unwrap().to_string();
|
||||
|
|
@ -1015,7 +951,7 @@ impl TestHarness {
|
|||
log::info!("Starting PostgreSQL on port {}...", ctx.ports.postgres);
|
||||
let pg = PostgresService::start(ctx.ports.postgres, &data_dir_str).await?;
|
||||
if config.run_migrations {
|
||||
pg.run_migrations().await?;
|
||||
pg.run_migrations()?;
|
||||
}
|
||||
ctx.postgres = Some(pg);
|
||||
}
|
||||
|
|
@ -1050,11 +986,7 @@ impl TestHarness {
|
|||
Self::setup(TestConfig::default()).await
|
||||
}
|
||||
|
||||
/// Setup for full E2E tests - connects to existing running services by default
|
||||
/// Set FRESH_STACK=1 env var to bootstrap a fresh stack instead
|
||||
pub async fn full() -> Result<TestContext> {
|
||||
// Default: use existing stack (user already has botserver running)
|
||||
// Set FRESH_STACK=1 to bootstrap fresh stack from scratch
|
||||
if std::env::var("FRESH_STACK").is_ok() {
|
||||
Self::setup(TestConfig::full()).await
|
||||
} else {
|
||||
|
|
@ -1062,7 +994,6 @@ impl TestHarness {
|
|||
}
|
||||
}
|
||||
|
||||
/// Setup with botserver auto-installing all services
|
||||
pub async fn with_auto_install() -> Result<TestContext> {
|
||||
Self::setup(TestConfig::auto_install()).await
|
||||
}
|
||||
|
|
@ -1101,12 +1032,12 @@ mod tests {
|
|||
#[test]
|
||||
fn test_config_full() {
|
||||
let config = TestConfig::full();
|
||||
assert!(!config.postgres); // Botserver handles PostgreSQL
|
||||
assert!(!config.minio); // Botserver handles MinIO
|
||||
assert!(!config.redis); // Botserver handles Redis
|
||||
assert!(!config.postgres);
|
||||
assert!(!config.minio);
|
||||
assert!(!config.redis);
|
||||
assert!(config.mock_zitadel);
|
||||
assert!(config.mock_llm);
|
||||
assert!(!config.run_migrations); // Botserver handles migrations
|
||||
assert!(!config.run_migrations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub mod prelude {
|
|||
mod tests {
|
||||
#[test]
|
||||
fn test_library_loads() {
|
||||
assert!(true);
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
assert!(!version.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
129
src/main.rs
129
src/main.rs
|
|
@ -36,11 +36,11 @@ impl std::str::FromStr for TestSuite {
|
|||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"unit" => Ok(TestSuite::Unit),
|
||||
"integration" | "int" => Ok(TestSuite::Integration),
|
||||
"e2e" | "end-to-end" => Ok(TestSuite::E2E),
|
||||
"all" => Ok(TestSuite::All),
|
||||
_ => Err(format!("Unknown test suite: {}", s)),
|
||||
"unit" => Ok(Self::Unit),
|
||||
"integration" | "int" => Ok(Self::Integration),
|
||||
"e2e" | "end-to-end" => Ok(Self::E2E),
|
||||
"all" => Ok(Self::All),
|
||||
_ => Err(format!("Unknown test suite: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -156,10 +156,10 @@ fn parse_args() -> Result<(RunnerConfig, bool, bool)> {
|
|||
config.headed = true;
|
||||
}
|
||||
arg if !arg.starts_with('-') => {
|
||||
config.suite = arg.parse().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
config.suite = arg.parse().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("Unknown argument: {}", other);
|
||||
anyhow::bail!("Unknown argument: {other}");
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
|
|
@ -193,6 +193,7 @@ pub struct TestResults {
|
|||
}
|
||||
|
||||
impl TestResults {
|
||||
#[must_use]
|
||||
pub fn new(suite: &str) -> Self {
|
||||
Self {
|
||||
suite: suite.to_string(),
|
||||
|
|
@ -204,7 +205,8 @@ impl TestResults {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn success(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn success(&self) -> bool {
|
||||
self.failed == 0 && self.errors.is_empty()
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +217,7 @@ fn get_cache_dir() -> PathBuf {
|
|||
}
|
||||
|
||||
fn get_chromedriver_path(version: &str) -> PathBuf {
|
||||
get_cache_dir().join(format!("chromedriver-{}", version))
|
||||
get_cache_dir().join(format!("chromedriver-{version}"))
|
||||
}
|
||||
|
||||
fn get_chrome_path() -> PathBuf {
|
||||
|
|
@ -260,13 +262,7 @@ fn detect_browser_version(browser_path: &str) -> Option<String> {
|
|||
let parts: Vec<&str> = version_str.split_whitespace().collect();
|
||||
|
||||
for part in parts {
|
||||
if part.contains('.')
|
||||
&& part
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if part.contains('.') && part.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
let major = part.split('.').next()?;
|
||||
return Some(major.to_string());
|
||||
}
|
||||
|
|
@ -276,7 +272,7 @@ fn detect_browser_version(browser_path: &str) -> Option<String> {
|
|||
}
|
||||
|
||||
fn detect_chromedriver_for_version(major_version: &str) -> Option<PathBuf> {
|
||||
let pattern = format!("chromedriver-{}", major_version);
|
||||
let pattern = format!("chromedriver-{major_version}");
|
||||
let cache_dir = get_cache_dir();
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
|
||||
|
|
@ -312,7 +308,7 @@ async fn download_file(url: &str, dest: &PathBuf) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
|
||||
fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
|
||||
info!("Extracting: {:?} to {:?}", zip_path, dest_dir);
|
||||
|
||||
let file = std::fs::File::open(zip_path)?;
|
||||
|
|
@ -353,8 +349,7 @@ async fn extract_zip(zip_path: &PathBuf, dest_dir: &PathBuf) -> Result<()> {
|
|||
|
||||
async fn get_chromedriver_version_for_browser(major_version: &str) -> Result<String> {
|
||||
let url = format!(
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{}",
|
||||
major_version
|
||||
"https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_{major_version}"
|
||||
);
|
||||
|
||||
info!("Fetching ChromeDriver version for Chrome {}", major_version);
|
||||
|
|
@ -389,15 +384,13 @@ async fn setup_chromedriver(browser_path: &str) -> Result<PathBuf> {
|
|||
|
||||
let chrome_version = get_chromedriver_version_for_browser(&major_version).await?;
|
||||
|
||||
let chromedriver_url = format!(
|
||||
"{}/{}/linux64/chromedriver-linux64.zip",
|
||||
CHROMEDRIVER_URL, chrome_version
|
||||
);
|
||||
let chromedriver_url =
|
||||
format!("{CHROMEDRIVER_URL}/{chrome_version}/linux64/chromedriver-linux64.zip");
|
||||
|
||||
let zip_path = cache_dir.join("chromedriver.zip");
|
||||
download_file(&chromedriver_url, &zip_path).await?;
|
||||
|
||||
extract_zip(&zip_path, &cache_dir).await?;
|
||||
extract_zip(&zip_path, &cache_dir)?;
|
||||
|
||||
let extracted_driver = cache_dir.join("chromedriver-linux64").join("chromedriver");
|
||||
let final_path = get_chromedriver_path(&major_version);
|
||||
|
|
@ -441,15 +434,12 @@ async fn setup_chrome_for_testing() -> Result<PathBuf> {
|
|||
.await
|
||||
.unwrap_or_else(|_| "131.0.6778.204".to_string());
|
||||
|
||||
let chrome_url = format!(
|
||||
"{}/{}/linux64/chrome-linux64.zip",
|
||||
CHROMEDRIVER_URL, chrome_version
|
||||
);
|
||||
let chrome_url = format!("{CHROMEDRIVER_URL}/{chrome_version}/linux64/chrome-linux64.zip");
|
||||
|
||||
let zip_path = cache_dir.join("chrome.zip");
|
||||
download_file(&chrome_url, &zip_path).await?;
|
||||
|
||||
extract_zip(&zip_path, &cache_dir).await?;
|
||||
extract_zip(&zip_path, &cache_dir)?;
|
||||
|
||||
std::fs::remove_file(&zip_path).ok();
|
||||
|
||||
|
|
@ -485,7 +475,7 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
|
|||
info!("Starting ChromeDriver on port {}...", port);
|
||||
|
||||
let child = std::process::Command::new(chromedriver_path)
|
||||
.arg(format!("--port={}", port))
|
||||
.arg(format!("--port={port}"))
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
|
|
@ -502,14 +492,13 @@ async fn start_chromedriver(chromedriver_path: &PathBuf, port: u16) -> Result<st
|
|||
}
|
||||
|
||||
async fn check_webdriver_available(port: u16) -> bool {
|
||||
let url = format!("http://localhost:{}/status", port);
|
||||
let url = format!("http://localhost:{port}/status");
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
client.get(&url).send().await.is_ok()
|
||||
|
|
@ -518,13 +507,12 @@ async fn check_webdriver_available(port: u16) -> bool {
|
|||
async fn run_browser_demo() -> Result<()> {
|
||||
info!("Running browser demo...");
|
||||
|
||||
// Use CDP directly via BrowserService
|
||||
let debug_port = 9222u16;
|
||||
|
||||
let mut browser_service = match services::BrowserService::start(debug_port).await {
|
||||
Ok(bs) => bs,
|
||||
Err(e) => {
|
||||
anyhow::bail!("Failed to start browser: {}", e);
|
||||
anyhow::bail!("Failed to start browser: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -540,7 +528,7 @@ async fn run_browser_demo() -> Result<()> {
|
|||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let _ = browser_service.stop().await;
|
||||
anyhow::bail!("Failed to connect to browser CDP: {}", e);
|
||||
anyhow::bail!("Failed to connect to browser CDP: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -558,7 +546,7 @@ async fn run_browser_demo() -> Result<()> {
|
|||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
info!("Closing browser...");
|
||||
let _ = browser.close().await;
|
||||
let _ = browser.close();
|
||||
let _ = browser_service.stop().await;
|
||||
|
||||
info!("Demo complete!");
|
||||
|
|
@ -575,7 +563,7 @@ fn discover_test_files(test_dir: &str) -> Vec<String> {
|
|||
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||
for entry in entries.flatten() {
|
||||
let file_path = entry.path();
|
||||
if file_path.extension().map(|e| e == "rs").unwrap_or(false) {
|
||||
if file_path.extension().is_some_and(|e| e == "rs") {
|
||||
if let Some(name) = file_path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str != "mod" {
|
||||
|
|
@ -626,7 +614,7 @@ fn run_cargo_test(
|
|||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}\n{}", stdout, stderr);
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
|
||||
let mut passed = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
|
@ -652,7 +640,7 @@ fn run_cargo_test(
|
|||
Ok((passed, failed, skipped))
|
||||
}
|
||||
|
||||
async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
|
||||
fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
|
||||
info!("Running unit tests...");
|
||||
|
||||
let mut results = TestResults::new("unit");
|
||||
|
|
@ -679,7 +667,7 @@ async fn run_unit_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
Err(e) => {
|
||||
results
|
||||
.errors
|
||||
.push(format!("Failed to run unit tests: {}", e));
|
||||
.push(format!("Failed to run unit tests: {e}"));
|
||||
results.failed = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -712,7 +700,7 @@ async fn run_integration_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
Err(e) => {
|
||||
error!("Failed to set up test harness: {}", e);
|
||||
results.failed = 1;
|
||||
results.errors.push(format!("Harness setup failed: {}", e));
|
||||
results.errors.push(format!("Harness setup failed: {e}"));
|
||||
return Ok(results);
|
||||
}
|
||||
};
|
||||
|
|
@ -761,16 +749,16 @@ async fn run_integration_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
Err(e) => {
|
||||
results
|
||||
.errors
|
||||
.push(format!("Failed to run integration tests: {}", e));
|
||||
.push(format!("Failed to run integration tests: {e}"));
|
||||
results.failed = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !config.keep_env {
|
||||
info!("Cleaning up test environment...");
|
||||
} else {
|
||||
if config.keep_env {
|
||||
info!("Keeping test environment for inspection (KEEP_ENV=1)");
|
||||
info!(" Data dir: {:?}", ctx.data_dir);
|
||||
} else {
|
||||
info!("Cleaning up test environment...");
|
||||
}
|
||||
|
||||
results.duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
|
@ -837,7 +825,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
results.failed = 1;
|
||||
results
|
||||
.errors
|
||||
.push(format!("ChromeDriver start failed: {}", e));
|
||||
.push(format!("ChromeDriver start failed: {e}"));
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
|
|
@ -852,7 +840,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
let _ = child.kill();
|
||||
}
|
||||
results.failed = 1;
|
||||
results.errors.push(format!("Harness setup failed: {}", e));
|
||||
results.errors.push(format!("Harness setup failed: {e}"));
|
||||
return Ok(results);
|
||||
}
|
||||
};
|
||||
|
|
@ -867,9 +855,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
let _ = child.kill();
|
||||
}
|
||||
results.failed = 1;
|
||||
results
|
||||
.errors
|
||||
.push(format!("Botserver start failed: {}", e));
|
||||
results.errors.push(format!("Botserver start failed: {e}"));
|
||||
return Ok(results);
|
||||
}
|
||||
};
|
||||
|
|
@ -898,7 +884,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
let directory_url = ctx.zitadel_url();
|
||||
let server_url = server.url.clone();
|
||||
let chrome_binary = chrome_path.to_string_lossy().to_string();
|
||||
let webdriver_url = format!("http://localhost:{}", webdriver_port);
|
||||
let webdriver_url = format!("http://localhost:{webdriver_port}");
|
||||
|
||||
let env_vars: Vec<(&str, &str)> = vec![
|
||||
("DATABASE_URL", &db_url),
|
||||
|
|
@ -920,9 +906,7 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
results.skipped = skipped;
|
||||
}
|
||||
Err(e) => {
|
||||
results
|
||||
.errors
|
||||
.push(format!("Failed to run E2E tests: {}", e));
|
||||
results.errors.push(format!("Failed to run E2E tests: {e}"));
|
||||
results.failed = 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -933,12 +917,12 @@ async fn run_e2e_tests(config: &RunnerConfig) -> Result<TestResults> {
|
|||
let _ = child.wait();
|
||||
}
|
||||
|
||||
if !config.keep_env {
|
||||
info!("Cleaning up test environment...");
|
||||
} else {
|
||||
if config.keep_env {
|
||||
info!("Keeping test environment for inspection (KEEP_ENV=1)");
|
||||
info!(" Server URL: {}", server.url);
|
||||
info!(" Data dir: {:?}", ctx.data_dir);
|
||||
} else {
|
||||
info!("Cleaning up test environment...");
|
||||
}
|
||||
|
||||
results.duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
|
@ -968,7 +952,7 @@ fn print_summary(results: &[TestResults]) {
|
|||
);
|
||||
|
||||
for error in &result.errors {
|
||||
println!(" ERROR: {}", error);
|
||||
println!(" ERROR: {error}");
|
||||
}
|
||||
|
||||
total_passed += result.passed;
|
||||
|
|
@ -979,8 +963,7 @@ fn print_summary(results: &[TestResults]) {
|
|||
|
||||
println!("\n{}", "-".repeat(60));
|
||||
println!(
|
||||
"TOTAL: {} passed, {} failed, {} skipped ({} ms)",
|
||||
total_passed, total_failed, total_skipped, total_duration
|
||||
"TOTAL: {total_passed} passed, {total_failed} failed, {total_skipped} skipped ({total_duration} ms)"
|
||||
);
|
||||
println!("{}", "=".repeat(60));
|
||||
|
||||
|
|
@ -996,7 +979,7 @@ async fn main() -> ExitCode {
|
|||
let (config, setup_only, demo_mode) = match parse_args() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
eprintln!("Error: {e}");
|
||||
print_usage();
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
|
@ -1014,12 +997,12 @@ async fn main() -> ExitCode {
|
|||
match setup_test_dependencies().await {
|
||||
Ok((chromedriver, chrome)) => {
|
||||
println!("\n✅ Dependencies installed successfully!");
|
||||
println!(" ChromeDriver: {:?}", chromedriver);
|
||||
println!(" Browser: {:?}", chrome);
|
||||
println!(" ChromeDriver: {}", chromedriver.display());
|
||||
println!(" Browser: {}", chrome.display());
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Setup failed: {}", e);
|
||||
eprintln!("\n❌ Setup failed: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1028,12 +1011,12 @@ async fn main() -> ExitCode {
|
|||
if demo_mode {
|
||||
info!("Running browser demo...");
|
||||
match run_browser_demo().await {
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
println!("\n✅ Browser demo completed successfully!");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Browser demo failed: {}", e);
|
||||
eprintln!("\n❌ Browser demo failed: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1045,11 +1028,11 @@ async fn main() -> ExitCode {
|
|||
let mut all_results = Vec::new();
|
||||
|
||||
let result = match config.suite {
|
||||
TestSuite::Unit => run_unit_tests(&config).await,
|
||||
TestSuite::Unit => run_unit_tests(&config),
|
||||
TestSuite::Integration => run_integration_tests(&config).await,
|
||||
TestSuite::E2E => run_e2e_tests(&config).await,
|
||||
TestSuite::All => {
|
||||
let unit = run_unit_tests(&config).await;
|
||||
let unit = run_unit_tests(&config);
|
||||
let integration = run_integration_tests(&config).await;
|
||||
let e2e = run_e2e_tests(&config).await;
|
||||
|
||||
|
|
@ -1086,7 +1069,7 @@ async fn main() -> ExitCode {
|
|||
|
||||
print_summary(&all_results);
|
||||
|
||||
let all_passed = all_results.iter().all(|r| r.success());
|
||||
let all_passed = all_results.iter().all(TestResults::success);
|
||||
if all_passed {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use super::{new_expectation_store, Expectation, ExpectationStore};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Write;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
|
@ -89,6 +90,7 @@ struct ChatChoice {
|
|||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
struct Usage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
|
|
@ -167,7 +169,7 @@ struct ErrorDetail {
|
|||
|
||||
impl MockLLM {
|
||||
pub async fn start(port: u16) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockLLM port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
|
|
@ -219,11 +221,13 @@ impl MockLLM {
|
|||
.unwrap()
|
||||
.push(expectation.clone());
|
||||
|
||||
let mut store = self.expectations.lock().unwrap();
|
||||
store.insert(
|
||||
format!("completion:{}", prompt_contains),
|
||||
Expectation::new(&format!("completion containing '{}'", prompt_contains)),
|
||||
);
|
||||
{
|
||||
let mut store = self.expectations.lock().unwrap();
|
||||
store.insert(
|
||||
format!("completion:{prompt_contains}"),
|
||||
Expectation::new(&format!("completion containing '{prompt_contains}'")),
|
||||
);
|
||||
}
|
||||
|
||||
let response_text = response.to_string();
|
||||
let model = self.default_model.clone();
|
||||
|
|
@ -253,7 +257,8 @@ impl MockLLM {
|
|||
|
||||
let mut template = ResponseTemplate::new(200).set_body_json(&response_body);
|
||||
|
||||
if let Some(delay) = *latency.lock().unwrap() {
|
||||
let latency_value = *latency.lock().unwrap();
|
||||
if let Some(delay) = latency_value {
|
||||
template = template.set_delay(delay);
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +279,7 @@ impl MockLLM {
|
|||
prompt_contains: Some(prompt_contains.to_string()),
|
||||
response: chunks.join(""),
|
||||
stream: true,
|
||||
chunks: chunks.iter().map(|s| s.to_string()).collect(),
|
||||
chunks: chunks.iter().map(|s| (*s).to_string()).collect(),
|
||||
tool_calls: Vec::new(),
|
||||
};
|
||||
|
||||
|
|
@ -303,10 +308,11 @@ impl MockLLM {
|
|||
finish_reason: None,
|
||||
}],
|
||||
};
|
||||
sse_body.push_str(&format!(
|
||||
"data: {}\n\n",
|
||||
let _ = writeln!(
|
||||
sse_body,
|
||||
"data: {}\n",
|
||||
serde_json::to_string(&first_chunk).unwrap()
|
||||
));
|
||||
);
|
||||
|
||||
for chunk_text in &chunks {
|
||||
let chunk = StreamChunk {
|
||||
|
|
@ -318,15 +324,16 @@ impl MockLLM {
|
|||
index: 0,
|
||||
delta: StreamDelta {
|
||||
role: None,
|
||||
content: Some(chunk_text.to_string()),
|
||||
content: Some((*chunk_text).to_string()),
|
||||
},
|
||||
finish_reason: None,
|
||||
}],
|
||||
};
|
||||
sse_body.push_str(&format!(
|
||||
"data: {}\n\n",
|
||||
let _ = writeln!(
|
||||
sse_body,
|
||||
"data: {}\n",
|
||||
serde_json::to_string(&chunk).unwrap()
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
let final_chunk = StreamChunk {
|
||||
|
|
@ -343,10 +350,11 @@ impl MockLLM {
|
|||
finish_reason: Some("stop".to_string()),
|
||||
}],
|
||||
};
|
||||
sse_body.push_str(&format!(
|
||||
"data: {}\n\n",
|
||||
let _ = writeln!(
|
||||
sse_body,
|
||||
"data: {}\n",
|
||||
serde_json::to_string(&final_chunk).unwrap()
|
||||
));
|
||||
);
|
||||
sse_body.push_str("data: [DONE]\n\n");
|
||||
|
||||
let template = ResponseTemplate::new(200)
|
||||
|
|
@ -470,7 +478,7 @@ impl MockLLM {
|
|||
error: ErrorDetail {
|
||||
message: message.to_string(),
|
||||
r#type: "error".to_string(),
|
||||
code: format!("error_{}", status),
|
||||
code: format!("error_{status}"),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -563,11 +571,13 @@ impl MockLLM {
|
|||
.await;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
|
|
@ -594,19 +604,14 @@ impl MockLLM {
|
|||
}
|
||||
|
||||
pub async fn call_count(&self) -> usize {
|
||||
self.server
|
||||
.received_requests()
|
||||
.await
|
||||
.map(|r| r.len())
|
||||
.unwrap_or(0)
|
||||
self.server.received_requests().await.map_or(0, |r| r.len())
|
||||
}
|
||||
|
||||
pub async fn assert_called_times(&self, expected: usize) {
|
||||
let actual = self.call_count().await;
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Expected {} calls to MockLLM, but got {}",
|
||||
expected, actual
|
||||
"Expected {expected} calls to MockLLM, but got {actual}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -620,7 +625,7 @@ impl MockLLM {
|
|||
|
||||
pub async fn assert_not_called(&self) {
|
||||
let count = self.call_count().await;
|
||||
assert_eq!(count, 0, "Expected no calls to MockLLM, but got {}", count);
|
||||
assert_eq!(count, 0, "Expected no calls to MockLLM, but got {count}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -649,7 +654,7 @@ mod tests {
|
|||
let response = ChatCompletionResponse {
|
||||
id: "test-id".to_string(),
|
||||
object: "chat.completion".to_string(),
|
||||
created: 1234567890,
|
||||
created: 1_234_567_890,
|
||||
model: "gpt-4".to_string(),
|
||||
choices: vec![ChatChoice {
|
||||
index: 0,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//! Mock servers for testing external service integrations
|
||||
//!
|
||||
//! Provides mock implementations of:
|
||||
//! - LLM API (OpenAI-compatible)
|
||||
//! - WhatsApp Business API
|
||||
//! - Microsoft Teams Bot Framework
|
||||
//! - Zitadel Auth/OIDC
|
||||
|
||||
mod llm;
|
||||
mod teams;
|
||||
|
|
@ -20,7 +13,6 @@ use anyhow::Result;
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Registry of all mock servers for a test
|
||||
pub struct MockRegistry {
|
||||
pub llm: Option<MockLLM>,
|
||||
pub whatsapp: Option<MockWhatsApp>,
|
||||
|
|
@ -29,8 +21,8 @@ pub struct MockRegistry {
|
|||
}
|
||||
|
||||
impl MockRegistry {
|
||||
/// Create an empty registry
|
||||
pub fn new() -> Self {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
llm: None,
|
||||
whatsapp: None,
|
||||
|
|
@ -39,27 +31,26 @@ impl MockRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the LLM mock, panics if not configured
|
||||
pub fn llm(&self) -> &MockLLM {
|
||||
#[must_use]
|
||||
pub const fn llm(&self) -> &MockLLM {
|
||||
self.llm.as_ref().expect("LLM mock not configured")
|
||||
}
|
||||
|
||||
/// Get the WhatsApp mock, panics if not configured
|
||||
pub fn whatsapp(&self) -> &MockWhatsApp {
|
||||
#[must_use]
|
||||
pub const fn whatsapp(&self) -> &MockWhatsApp {
|
||||
self.whatsapp.as_ref().expect("WhatsApp mock not configured")
|
||||
}
|
||||
|
||||
/// Get the Teams mock, panics if not configured
|
||||
pub fn teams(&self) -> &MockTeams {
|
||||
#[must_use]
|
||||
pub const fn teams(&self) -> &MockTeams {
|
||||
self.teams.as_ref().expect("Teams mock not configured")
|
||||
}
|
||||
|
||||
/// Get the Zitadel mock, panics if not configured
|
||||
pub fn zitadel(&self) -> &MockZitadel {
|
||||
#[must_use]
|
||||
pub const fn zitadel(&self) -> &MockZitadel {
|
||||
self.zitadel.as_ref().expect("Zitadel mock not configured")
|
||||
}
|
||||
|
||||
/// Verify all mock expectations were met
|
||||
pub fn verify_all(&self) -> Result<()> {
|
||||
if let Some(ref llm) = self.llm {
|
||||
llm.verify()?;
|
||||
|
|
@ -76,7 +67,6 @@ impl MockRegistry {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all mock servers
|
||||
pub async fn reset_all(&self) {
|
||||
if let Some(ref llm) = self.llm {
|
||||
llm.reset().await;
|
||||
|
|
@ -99,7 +89,6 @@ impl Default for MockRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Expectation tracking for mock verification
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Expectation {
|
||||
pub name: String,
|
||||
|
|
@ -109,6 +98,7 @@ pub struct Expectation {
|
|||
}
|
||||
|
||||
impl Expectation {
|
||||
#[must_use]
|
||||
pub fn new(name: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
|
|
@ -118,12 +108,13 @@ impl Expectation {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn times(mut self, n: usize) -> Self {
|
||||
#[must_use]
|
||||
pub const fn times(mut self, n: usize) -> Self {
|
||||
self.expected_calls = Some(n);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn record_call(&mut self) {
|
||||
pub const fn record_call(&mut self) {
|
||||
self.actual_calls += 1;
|
||||
self.matched = true;
|
||||
}
|
||||
|
|
@ -143,10 +134,9 @@ impl Expectation {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shared state for tracking expectations across async handlers
|
||||
pub type ExpectationStore = Arc<Mutex<HashMap<String, Expectation>>>;
|
||||
|
||||
/// Create a new expectation store
|
||||
#[must_use]
|
||||
pub fn new_expectation_store() -> ExpectationStore {
|
||||
Arc::new(Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Mock Microsoft Teams Bot Framework server for testing
|
||||
//!
|
||||
//! Provides a mock server that simulates the Microsoft Bot Framework API
|
||||
//! for Teams integration testing, including activities, conversations, and attachments.
|
||||
|
||||
use super::{new_expectation_store, ExpectationStore};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -12,7 +7,6 @@ use uuid::Uuid;
|
|||
use wiremock::matchers::{method, path, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Mock Teams Bot Framework server
|
||||
pub struct MockTeams {
|
||||
server: MockServer,
|
||||
port: u16,
|
||||
|
|
@ -25,9 +19,9 @@ pub struct MockTeams {
|
|||
service_url: String,
|
||||
}
|
||||
|
||||
/// Bot Framework Activity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Activity {
|
||||
#[serde(rename = "type")]
|
||||
pub activity_type: String,
|
||||
|
|
@ -88,7 +82,6 @@ impl Default for Activity {
|
|||
}
|
||||
}
|
||||
|
||||
/// Channel account (user or bot)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelAccount {
|
||||
|
|
@ -101,7 +94,6 @@ pub struct ChannelAccount {
|
|||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// Conversation account
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationAccount {
|
||||
|
|
@ -116,7 +108,6 @@ pub struct ConversationAccount {
|
|||
pub tenant_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Attachment in an activity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attachment {
|
||||
|
|
@ -131,9 +122,9 @@ pub struct Attachment {
|
|||
pub thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Entity in an activity (mentions, etc.)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Entity {
|
||||
#[serde(rename = "type")]
|
||||
pub entity_type: String,
|
||||
|
|
@ -145,7 +136,6 @@ pub struct Entity {
|
|||
pub additional: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Conversation information stored by the mock
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationInfo {
|
||||
pub id: String,
|
||||
|
|
@ -155,13 +145,11 @@ pub struct ConversationInfo {
|
|||
pub is_group: bool,
|
||||
}
|
||||
|
||||
/// Resource response from sending an activity
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResourceResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Conversations result
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConversationsResult {
|
||||
|
|
@ -170,14 +158,12 @@ pub struct ConversationsResult {
|
|||
pub conversations: Vec<ConversationMembers>,
|
||||
}
|
||||
|
||||
/// Conversation members
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConversationMembers {
|
||||
pub id: String,
|
||||
pub members: Vec<ChannelAccount>,
|
||||
}
|
||||
|
||||
/// Teams channel account (extended)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TeamsChannelAccount {
|
||||
|
|
@ -198,7 +184,6 @@ pub struct TeamsChannelAccount {
|
|||
pub surname: Option<String>,
|
||||
}
|
||||
|
||||
/// Teams meeting info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TeamsMeetingInfo {
|
||||
|
|
@ -209,7 +194,6 @@ pub struct TeamsMeetingInfo {
|
|||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Adaptive card action response
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdaptiveCardInvokeResponse {
|
||||
|
|
@ -220,20 +204,17 @@ pub struct AdaptiveCardInvokeResponse {
|
|||
pub value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Error response
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorBody,
|
||||
}
|
||||
|
||||
/// Error body
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorBody {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Invoke response for bot actions
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvokeResponse {
|
||||
|
|
@ -243,22 +224,18 @@ pub struct InvokeResponse {
|
|||
}
|
||||
|
||||
impl MockTeams {
|
||||
/// Default bot ID for testing
|
||||
pub const DEFAULT_BOT_ID: &'static str = "28:test-bot-id";
|
||||
|
||||
/// Default bot name
|
||||
pub const DEFAULT_BOT_NAME: &'static str = "TestBot";
|
||||
|
||||
/// Default tenant ID
|
||||
pub const DEFAULT_TENANT_ID: &'static str = "test-tenant-id";
|
||||
|
||||
/// Start a new mock Teams server on the specified port
|
||||
pub async fn start(port: u16) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockTeams port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
let service_url = format!("http://127.0.0.1:{}", port);
|
||||
let service_url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let mock = Self {
|
||||
server,
|
||||
|
|
@ -277,18 +254,17 @@ impl MockTeams {
|
|||
Ok(mock)
|
||||
}
|
||||
|
||||
/// Start with custom bot configuration
|
||||
pub async fn start_with_config(
|
||||
port: u16,
|
||||
bot_id: &str,
|
||||
bot_name: &str,
|
||||
tenant_id: &str,
|
||||
) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockTeams port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
let service_url = format!("http://127.0.0.1:{}", port);
|
||||
let service_url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let mock = Self {
|
||||
server,
|
||||
|
|
@ -307,11 +283,9 @@ impl MockTeams {
|
|||
Ok(mock)
|
||||
}
|
||||
|
||||
/// Set up default API routes
|
||||
async fn setup_default_routes(&self) {
|
||||
let sent_activities = self.sent_activities.clone();
|
||||
|
||||
// Send to conversation endpoint
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v3/conversations/.+/activities"))
|
||||
.respond_with(move |req: &wiremock::Request| {
|
||||
|
|
@ -345,16 +319,13 @@ impl MockTeams {
|
|||
|
||||
sent_activities.lock().unwrap().push(activity.clone());
|
||||
|
||||
let response = ResourceResponse {
|
||||
id: activity.id.clone(),
|
||||
};
|
||||
let response = ResourceResponse { id: activity.id };
|
||||
|
||||
ResponseTemplate::new(200).set_body_json(&response)
|
||||
})
|
||||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Reply to activity endpoint
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -366,7 +337,6 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Update activity endpoint
|
||||
Mock::given(method("PUT"))
|
||||
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -378,14 +348,12 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Delete activity endpoint
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path_regex(r"/v3/conversations/.+/activities/.+"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Get conversation members endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/v3/conversations/.+/members"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -404,7 +372,6 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Get single member endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/v3/conversations/.+/members/.+"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -423,7 +390,6 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Create conversation endpoint
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v3/conversations"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -439,7 +405,6 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Get conversations endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/v3/conversations"))
|
||||
.respond_with(|_req: &wiremock::Request| {
|
||||
|
|
@ -452,7 +417,6 @@ impl MockTeams {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Token endpoint (for bot authentication simulation)
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/botframework.com/oauth2/v2.0/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
|
|
@ -464,7 +428,7 @@ impl MockTeams {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Simulate an incoming message from a user
|
||||
#[must_use]
|
||||
pub fn simulate_message(&self, from_id: &str, from_name: &str, text: &str) -> Activity {
|
||||
let conversation_id = format!("conv-{}", Uuid::new_v4());
|
||||
|
||||
|
|
@ -511,12 +475,12 @@ impl MockTeams {
|
|||
}
|
||||
}
|
||||
|
||||
/// Simulate an incoming message with a mention
|
||||
#[must_use]
|
||||
pub fn simulate_mention(&self, from_id: &str, from_name: &str, text: &str) -> Activity {
|
||||
let mut activity = self.simulate_message(from_id, from_name, text);
|
||||
|
||||
let mention_text = format!("<at>{}</at>", self.bot_name);
|
||||
activity.text = Some(format!("{} {}", mention_text, text));
|
||||
activity.text = Some(format!("{mention_text} {text}"));
|
||||
|
||||
activity.entities = Some(vec![Entity {
|
||||
entity_type: "mention".to_string(),
|
||||
|
|
@ -533,7 +497,7 @@ impl MockTeams {
|
|||
activity
|
||||
}
|
||||
|
||||
/// Simulate a conversation update (member added)
|
||||
#[must_use]
|
||||
pub fn simulate_member_added(&self, member_id: &str, member_name: &str) -> Activity {
|
||||
let conversation_id = format!("conv-{}", Uuid::new_v4());
|
||||
|
||||
|
|
@ -581,7 +545,7 @@ impl MockTeams {
|
|||
}
|
||||
}
|
||||
|
||||
/// Simulate an invoke activity (adaptive card action, etc.)
|
||||
#[must_use]
|
||||
pub fn simulate_invoke(
|
||||
&self,
|
||||
from_id: &str,
|
||||
|
|
@ -634,7 +598,7 @@ impl MockTeams {
|
|||
}
|
||||
}
|
||||
|
||||
/// Simulate an adaptive card action
|
||||
#[must_use]
|
||||
pub fn simulate_adaptive_card_action(
|
||||
&self,
|
||||
from_id: &str,
|
||||
|
|
@ -655,7 +619,7 @@ impl MockTeams {
|
|||
)
|
||||
}
|
||||
|
||||
/// Simulate a message reaction
|
||||
#[must_use]
|
||||
pub fn simulate_reaction(
|
||||
&self,
|
||||
from_id: &str,
|
||||
|
|
@ -708,7 +672,6 @@ impl MockTeams {
|
|||
}
|
||||
}
|
||||
|
||||
/// Expect an error response for the next request
|
||||
pub async fn expect_error(&self, code: &str, message: &str) {
|
||||
let error_response = ErrorResponse {
|
||||
error: ErrorBody {
|
||||
|
|
@ -724,45 +687,41 @@ impl MockTeams {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect an unauthorized error
|
||||
pub async fn expect_unauthorized(&self) {
|
||||
self.expect_error("Unauthorized", "Token validation failed")
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Expect a not found error
|
||||
pub async fn expect_not_found(&self) {
|
||||
self.expect_error("NotFound", "Conversation not found")
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Get all sent activities
|
||||
#[must_use]
|
||||
pub fn sent_activities(&self) -> Vec<Activity> {
|
||||
self.sent_activities.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get sent activities with specific text
|
||||
#[must_use]
|
||||
pub fn sent_activities_containing(&self, text: &str) -> Vec<Activity> {
|
||||
self.sent_activities
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|a| a.text.as_ref().map(|t| t.contains(text)).unwrap_or(false))
|
||||
.filter(|a| a.text.as_ref().is_some_and(|t| t.contains(text)))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the last sent activity
|
||||
#[must_use]
|
||||
pub fn last_sent_activity(&self) -> Option<Activity> {
|
||||
self.sent_activities.lock().unwrap().last().cloned()
|
||||
}
|
||||
|
||||
/// Clear sent activities
|
||||
pub fn clear_sent_activities(&self) {
|
||||
self.sent_activities.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Register a conversation
|
||||
pub fn register_conversation(&self, info: ConversationInfo) {
|
||||
self.conversations
|
||||
.lock()
|
||||
|
|
@ -770,37 +729,36 @@ impl MockTeams {
|
|||
.insert(info.id.clone(), info);
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Get the service URL (same as server URL)
|
||||
#[must_use]
|
||||
pub fn service_url(&self) -> String {
|
||||
self.service_url.clone()
|
||||
}
|
||||
|
||||
/// Get the port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Get the bot ID
|
||||
#[must_use]
|
||||
pub fn bot_id(&self) -> &str {
|
||||
&self.bot_id
|
||||
}
|
||||
|
||||
/// Get the bot name
|
||||
#[must_use]
|
||||
pub fn bot_name(&self) -> &str {
|
||||
&self.bot_name
|
||||
}
|
||||
|
||||
/// Get the tenant ID
|
||||
#[must_use]
|
||||
pub fn tenant_id(&self) -> &str {
|
||||
&self.tenant_id
|
||||
}
|
||||
|
||||
/// Verify all expectations were met
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
let store = self.expectations.lock().unwrap();
|
||||
for (_, exp) in store.iter() {
|
||||
|
|
@ -809,7 +767,6 @@ impl MockTeams {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all mocks
|
||||
pub async fn reset(&self) {
|
||||
self.server.reset().await;
|
||||
self.sent_activities.lock().unwrap().clear();
|
||||
|
|
@ -818,13 +775,11 @@ impl MockTeams {
|
|||
self.setup_default_routes().await;
|
||||
}
|
||||
|
||||
/// Get received requests for inspection
|
||||
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
|
||||
self.server.received_requests().await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to create an adaptive card attachment
|
||||
pub fn adaptive_card(content: serde_json::Value) -> Attachment {
|
||||
Attachment {
|
||||
content_type: "application/vnd.microsoft.card.adaptive".to_string(),
|
||||
|
|
@ -835,7 +790,6 @@ pub fn adaptive_card(content: serde_json::Value) -> Attachment {
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper to create a hero card attachment
|
||||
pub fn hero_card(title: &str, subtitle: Option<&str>, text: Option<&str>) -> Attachment {
|
||||
Attachment {
|
||||
content_type: "application/vnd.microsoft.card.hero".to_string(),
|
||||
|
|
@ -850,7 +804,6 @@ pub fn hero_card(title: &str, subtitle: Option<&str>, text: Option<&str>) -> Att
|
|||
}
|
||||
}
|
||||
|
||||
/// Helper to create a thumbnail card attachment
|
||||
pub fn thumbnail_card(
|
||||
title: &str,
|
||||
subtitle: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Mock WhatsApp Business API server for testing
|
||||
//!
|
||||
//! Provides a mock server that simulates the WhatsApp Business API
|
||||
//! including message sending, template messages, and webhook events.
|
||||
|
||||
use super::{new_expectation_store, ExpectationStore};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -11,7 +6,6 @@ use uuid::Uuid;
|
|||
use wiremock::matchers::{method, path_regex};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Mock WhatsApp Business API server
|
||||
pub struct MockWhatsApp {
|
||||
server: MockServer,
|
||||
port: u16,
|
||||
|
|
@ -23,7 +17,6 @@ pub struct MockWhatsApp {
|
|||
access_token: String,
|
||||
}
|
||||
|
||||
/// A message that was "sent" through the mock
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SentMessage {
|
||||
pub id: String,
|
||||
|
|
@ -33,8 +26,7 @@ pub struct SentMessage {
|
|||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Type of message
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageType {
|
||||
Text,
|
||||
|
|
@ -49,7 +41,6 @@ pub enum MessageType {
|
|||
Reaction,
|
||||
}
|
||||
|
||||
/// Message content variants
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum MessageContent {
|
||||
|
|
@ -80,28 +71,24 @@ pub enum MessageContent {
|
|||
},
|
||||
}
|
||||
|
||||
/// Webhook event from WhatsApp
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookEvent {
|
||||
pub object: String,
|
||||
pub entry: Vec<WebhookEntry>,
|
||||
}
|
||||
|
||||
/// Entry in a webhook event
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookEntry {
|
||||
pub id: String,
|
||||
pub changes: Vec<WebhookChange>,
|
||||
}
|
||||
|
||||
/// Change in a webhook entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookChange {
|
||||
pub value: WebhookValue,
|
||||
pub field: String,
|
||||
}
|
||||
|
||||
/// Value in a webhook change
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookValue {
|
||||
pub messaging_product: String,
|
||||
|
|
@ -114,27 +101,23 @@ pub struct WebhookValue {
|
|||
pub statuses: Option<Vec<MessageStatus>>,
|
||||
}
|
||||
|
||||
/// Metadata in webhook
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookMetadata {
|
||||
pub display_phone_number: String,
|
||||
pub phone_number_id: String,
|
||||
}
|
||||
|
||||
/// Contact in webhook
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookContact {
|
||||
pub profile: ContactProfile,
|
||||
pub wa_id: String,
|
||||
}
|
||||
|
||||
/// Contact profile
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContactProfile {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Incoming message from webhook
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IncomingMessage {
|
||||
pub from: String,
|
||||
|
|
@ -154,13 +137,11 @@ pub struct IncomingMessage {
|
|||
pub interactive: Option<InteractiveReply>,
|
||||
}
|
||||
|
||||
/// Text message content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextMessage {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Media message content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MediaMessage {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -173,14 +154,12 @@ pub struct MediaMessage {
|
|||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
/// Button reply
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ButtonReply {
|
||||
pub payload: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Interactive reply (list/button)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InteractiveReply {
|
||||
#[serde(rename = "type")]
|
||||
|
|
@ -191,14 +170,12 @@ pub struct InteractiveReply {
|
|||
pub list_reply: Option<ListReplyContent>,
|
||||
}
|
||||
|
||||
/// Button reply content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ButtonReplyContent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// List reply content
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListReplyContent {
|
||||
pub id: String,
|
||||
|
|
@ -207,7 +184,6 @@ pub struct ListReplyContent {
|
|||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Message status update
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageStatus {
|
||||
pub id: String,
|
||||
|
|
@ -220,7 +196,6 @@ pub struct MessageStatus {
|
|||
pub pricing: Option<Pricing>,
|
||||
}
|
||||
|
||||
/// Conversation info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub id: String,
|
||||
|
|
@ -228,22 +203,20 @@ pub struct Conversation {
|
|||
pub origin: Option<ConversationOrigin>,
|
||||
}
|
||||
|
||||
/// Conversation origin
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationOrigin {
|
||||
#[serde(rename = "type")]
|
||||
pub origin_type: String,
|
||||
}
|
||||
|
||||
/// Pricing info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pricing {
|
||||
pub billable: bool,
|
||||
pub pricing_model: String,
|
||||
#[serde(alias = "pricing_model")]
|
||||
pub model: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// Send message API request
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SendMessageRequest {
|
||||
messaging_product: String,
|
||||
|
|
@ -255,7 +228,6 @@ struct SendMessageRequest {
|
|||
content: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Send message API response
|
||||
#[derive(Serialize)]
|
||||
struct SendMessageResponse {
|
||||
messaging_product: String,
|
||||
|
|
@ -263,26 +235,22 @@ struct SendMessageResponse {
|
|||
messages: Vec<MessageResponse>,
|
||||
}
|
||||
|
||||
/// Contact in send response
|
||||
#[derive(Serialize)]
|
||||
struct ContactResponse {
|
||||
input: String,
|
||||
wa_id: String,
|
||||
}
|
||||
|
||||
/// Message in send response
|
||||
#[derive(Serialize)]
|
||||
struct MessageResponse {
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Error response
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: ErrorDetail,
|
||||
}
|
||||
|
||||
/// Error detail
|
||||
#[derive(Serialize)]
|
||||
struct ErrorDetail {
|
||||
message: String,
|
||||
|
|
@ -292,7 +260,6 @@ struct ErrorDetail {
|
|||
fbtrace_id: String,
|
||||
}
|
||||
|
||||
/// Expectation builder for message sending
|
||||
pub struct MessageExpectation {
|
||||
to: String,
|
||||
message_type: Option<MessageType>,
|
||||
|
|
@ -300,20 +267,17 @@ pub struct MessageExpectation {
|
|||
}
|
||||
|
||||
impl MessageExpectation {
|
||||
/// Expect a specific message type
|
||||
pub fn of_type(mut self, t: MessageType) -> Self {
|
||||
pub const fn of_type(mut self, t: MessageType) -> Self {
|
||||
self.message_type = Some(t);
|
||||
self
|
||||
}
|
||||
|
||||
/// Expect message to contain specific text
|
||||
pub fn containing(mut self, text: &str) -> Self {
|
||||
self.contains = Some(text.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Expectation builder for template messages
|
||||
pub struct TemplateExpectation {
|
||||
name: String,
|
||||
to: Option<String>,
|
||||
|
|
@ -321,13 +285,11 @@ pub struct TemplateExpectation {
|
|||
}
|
||||
|
||||
impl TemplateExpectation {
|
||||
/// Expect template to be sent to specific number
|
||||
pub fn to(mut self, phone: &str) -> Self {
|
||||
self.to = Some(phone.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Expect template with specific language
|
||||
pub fn with_language(mut self, lang: &str) -> Self {
|
||||
self.language = Some(lang.to_string());
|
||||
self
|
||||
|
|
@ -335,18 +297,14 @@ impl TemplateExpectation {
|
|||
}
|
||||
|
||||
impl MockWhatsApp {
|
||||
/// Default phone number ID for testing
|
||||
pub const DEFAULT_PHONE_NUMBER_ID: &'static str = "123456789012345";
|
||||
|
||||
/// Default business account ID
|
||||
pub const DEFAULT_BUSINESS_ACCOUNT_ID: &'static str = "987654321098765";
|
||||
|
||||
/// Default access token
|
||||
pub const DEFAULT_ACCESS_TOKEN: &'static str = "test_access_token_12345";
|
||||
|
||||
/// Start a new mock WhatsApp server on the specified port
|
||||
pub async fn start(port: u16) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockWhatsApp port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
|
|
@ -367,14 +325,13 @@ impl MockWhatsApp {
|
|||
Ok(mock)
|
||||
}
|
||||
|
||||
/// Start with custom configuration
|
||||
pub async fn start_with_config(
|
||||
port: u16,
|
||||
phone_number_id: &str,
|
||||
business_account_id: &str,
|
||||
access_token: &str,
|
||||
) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockWhatsApp port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
|
|
@ -395,11 +352,8 @@ impl MockWhatsApp {
|
|||
Ok(mock)
|
||||
}
|
||||
|
||||
/// Set up default API routes
|
||||
async fn setup_default_routes(&self) {
|
||||
// Send message endpoint
|
||||
let sent_messages = self.sent_messages.clone();
|
||||
let _phone_id = self.phone_number_id.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v\d+\.\d+/\d+/messages"))
|
||||
|
|
@ -408,7 +362,7 @@ impl MockWhatsApp {
|
|||
let to = body.get("to").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let msg_type = body.get("type").and_then(|v| v.as_str()).unwrap_or("text");
|
||||
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -458,7 +412,6 @@ impl MockWhatsApp {
|
|||
id: message_id.clone(),
|
||||
to: to.to_string(),
|
||||
message_type: match msg_type {
|
||||
"text" => MessageType::Text,
|
||||
"template" => MessageType::Template,
|
||||
"image" => MessageType::Image,
|
||||
"document" => MessageType::Document,
|
||||
|
|
@ -489,7 +442,6 @@ impl MockWhatsApp {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Media upload endpoint
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex(r"/v\d+\.\d+/\d+/media"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
|
|
@ -498,7 +450,6 @@ impl MockWhatsApp {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Media download endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/v\d+\.\d+/\d+"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
|
|
@ -511,7 +462,6 @@ impl MockWhatsApp {
|
|||
.mount(&self.server)
|
||||
.await;
|
||||
|
||||
// Business profile endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path_regex(r"/v\d+\.\d+/\d+/whatsapp_business_profile"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
|
|
@ -529,8 +479,9 @@ impl MockWhatsApp {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect a message to be sent to a specific number
|
||||
#[must_use]
|
||||
pub fn expect_send_message(&self, to: &str) -> MessageExpectation {
|
||||
let _ = self;
|
||||
MessageExpectation {
|
||||
to: to.to_string(),
|
||||
message_type: None,
|
||||
|
|
@ -538,8 +489,9 @@ impl MockWhatsApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Expect a template message to be sent
|
||||
#[must_use]
|
||||
pub fn expect_send_template(&self, name: &str) -> TemplateExpectation {
|
||||
let _ = self;
|
||||
TemplateExpectation {
|
||||
name: name.to_string(),
|
||||
to: None,
|
||||
|
|
@ -547,9 +499,8 @@ impl MockWhatsApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Simulate an incoming message
|
||||
pub fn simulate_incoming(&self, from: &str, text: &str) -> Result<WebhookEvent> {
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
|
@ -597,14 +548,13 @@ impl MockWhatsApp {
|
|||
Ok(event)
|
||||
}
|
||||
|
||||
/// Simulate an incoming image message
|
||||
pub fn simulate_incoming_image(
|
||||
&self,
|
||||
from: &str,
|
||||
media_id: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<WebhookEvent> {
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
|
@ -638,7 +588,7 @@ impl MockWhatsApp {
|
|||
id: Some(media_id.to_string()),
|
||||
mime_type: Some("image/jpeg".to_string()),
|
||||
sha256: Some("abc123".to_string()),
|
||||
caption: caption.map(|c| c.to_string()),
|
||||
caption: caption.map(std::string::ToString::to_string),
|
||||
}),
|
||||
document: None,
|
||||
button: None,
|
||||
|
|
@ -655,14 +605,13 @@ impl MockWhatsApp {
|
|||
Ok(event)
|
||||
}
|
||||
|
||||
/// Simulate a button reply
|
||||
pub fn simulate_button_reply(
|
||||
&self,
|
||||
from: &str,
|
||||
button_id: &str,
|
||||
button_text: &str,
|
||||
) -> Result<WebhookEvent> {
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace("-", ""));
|
||||
let message_id = format!("wamid.{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
|
|
@ -715,13 +664,11 @@ impl MockWhatsApp {
|
|||
Ok(event)
|
||||
}
|
||||
|
||||
/// Simulate a webhook event
|
||||
pub fn simulate_webhook(&self, event: WebhookEvent) -> Result<()> {
|
||||
self.received_webhooks.lock().unwrap().push(event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Simulate a message status update
|
||||
pub fn simulate_status(
|
||||
&self,
|
||||
message_id: &str,
|
||||
|
|
@ -760,7 +707,7 @@ impl MockWhatsApp {
|
|||
}),
|
||||
pricing: Some(Pricing {
|
||||
billable: true,
|
||||
pricing_model: "CBP".to_string(),
|
||||
model: "CBP".to_string(),
|
||||
category: "business_initiated".to_string(),
|
||||
}),
|
||||
}]),
|
||||
|
|
@ -774,7 +721,6 @@ impl MockWhatsApp {
|
|||
Ok(event)
|
||||
}
|
||||
|
||||
/// Expect an error response for the next request
|
||||
pub async fn expect_error(&self, code: u32, message: &str) {
|
||||
let error_response = ErrorResponse {
|
||||
error: ErrorDetail {
|
||||
|
|
@ -792,12 +738,10 @@ impl MockWhatsApp {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect rate limit error
|
||||
pub async fn expect_rate_limit(&self) {
|
||||
self.expect_error(80007, "Rate limit hit").await;
|
||||
}
|
||||
|
||||
/// Expect invalid token error
|
||||
pub async fn expect_invalid_token(&self) {
|
||||
let error_response = ErrorResponse {
|
||||
error: ErrorDetail {
|
||||
|
|
@ -815,12 +759,12 @@ impl MockWhatsApp {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Get all sent messages
|
||||
#[must_use]
|
||||
pub fn sent_messages(&self) -> Vec<SentMessage> {
|
||||
self.sent_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get sent messages to a specific number
|
||||
#[must_use]
|
||||
pub fn sent_messages_to(&self, phone: &str) -> Vec<SentMessage> {
|
||||
self.sent_messages
|
||||
.lock()
|
||||
|
|
@ -831,47 +775,45 @@ impl MockWhatsApp {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Get the last sent message
|
||||
#[must_use]
|
||||
pub fn last_sent_message(&self) -> Option<SentMessage> {
|
||||
self.sent_messages.lock().unwrap().last().cloned()
|
||||
}
|
||||
|
||||
/// Clear sent messages
|
||||
pub fn clear_sent_messages(&self) {
|
||||
self.sent_messages.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Get the Graph API base URL
|
||||
#[must_use]
|
||||
pub fn graph_api_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/v17.0", self.port)
|
||||
}
|
||||
|
||||
/// Get the port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Get the phone number ID
|
||||
#[must_use]
|
||||
pub fn phone_number_id(&self) -> &str {
|
||||
&self.phone_number_id
|
||||
}
|
||||
|
||||
/// Get the business account ID
|
||||
#[must_use]
|
||||
pub fn business_account_id(&self) -> &str {
|
||||
&self.business_account_id
|
||||
}
|
||||
|
||||
/// Get the access token
|
||||
#[must_use]
|
||||
pub fn access_token(&self) -> &str {
|
||||
&self.access_token
|
||||
}
|
||||
|
||||
/// Verify all expectations were met
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
let store = self.expectations.lock().unwrap();
|
||||
for (_, exp) in store.iter() {
|
||||
|
|
@ -880,7 +822,6 @@ impl MockWhatsApp {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all mocks
|
||||
pub async fn reset(&self) {
|
||||
self.server.reset().await;
|
||||
self.sent_messages.lock().unwrap().clear();
|
||||
|
|
@ -889,7 +830,6 @@ impl MockWhatsApp {
|
|||
self.setup_default_routes().await;
|
||||
}
|
||||
|
||||
/// Get received requests for inspection
|
||||
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
|
||||
self.server.received_requests().await.unwrap_or_default()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Mock Zitadel server for testing OIDC/Auth flows
|
||||
//!
|
||||
//! Provides a mock authentication server that simulates Zitadel's OIDC endpoints
|
||||
//! including login, token issuance, refresh, and introspection.
|
||||
|
||||
use super::{new_expectation_store, ExpectationStore};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -13,7 +8,6 @@ use uuid::Uuid;
|
|||
use wiremock::matchers::{body_string_contains, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Mock Zitadel server for OIDC testing
|
||||
pub struct MockZitadel {
|
||||
server: MockServer,
|
||||
port: u16,
|
||||
|
|
@ -23,7 +17,6 @@ pub struct MockZitadel {
|
|||
issuer: String,
|
||||
}
|
||||
|
||||
/// Test user for authentication
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestUser {
|
||||
pub id: String,
|
||||
|
|
@ -47,7 +40,6 @@ impl Default for TestUser {
|
|||
}
|
||||
}
|
||||
|
||||
/// Token information stored by the mock
|
||||
#[derive(Debug, Clone)]
|
||||
struct TokenInfo {
|
||||
user_id: String,
|
||||
|
|
@ -58,7 +50,6 @@ struct TokenInfo {
|
|||
active: bool,
|
||||
}
|
||||
|
||||
/// Token response from authorization endpoints
|
||||
#[derive(Serialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
|
|
@ -71,7 +62,6 @@ struct TokenResponse {
|
|||
scope: String,
|
||||
}
|
||||
|
||||
/// OIDC discovery document
|
||||
#[derive(Serialize)]
|
||||
struct OIDCDiscovery {
|
||||
issuer: String,
|
||||
|
|
@ -89,7 +79,6 @@ struct OIDCDiscovery {
|
|||
claims_supported: Vec<String>,
|
||||
}
|
||||
|
||||
/// Introspection response
|
||||
#[derive(Serialize)]
|
||||
struct IntrospectionResponse {
|
||||
active: bool,
|
||||
|
|
@ -113,7 +102,6 @@ struct IntrospectionResponse {
|
|||
iss: Option<String>,
|
||||
}
|
||||
|
||||
/// User info response
|
||||
#[derive(Serialize)]
|
||||
struct UserInfoResponse {
|
||||
sub: String,
|
||||
|
|
@ -125,7 +113,6 @@ struct UserInfoResponse {
|
|||
roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Error response
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
|
|
@ -133,13 +120,12 @@ struct ErrorResponse {
|
|||
}
|
||||
|
||||
impl MockZitadel {
|
||||
/// Start a new mock Zitadel server on the specified port
|
||||
pub async fn start(port: u16) -> Result<Self> {
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{}", port))
|
||||
let listener = std::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.context("Failed to bind MockZitadel port")?;
|
||||
|
||||
let server = MockServer::builder().listener(listener).start().await;
|
||||
let issuer = format!("http://127.0.0.1:{}", port);
|
||||
let issuer = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let mock = Self {
|
||||
server,
|
||||
|
|
@ -156,18 +142,17 @@ impl MockZitadel {
|
|||
Ok(mock)
|
||||
}
|
||||
|
||||
/// Set up the OIDC discovery endpoint
|
||||
async fn setup_discovery_endpoint(&self) {
|
||||
let base_url = self.url();
|
||||
|
||||
let discovery = OIDCDiscovery {
|
||||
issuer: base_url.clone(),
|
||||
authorization_endpoint: format!("{}/oauth/v2/authorize", base_url),
|
||||
token_endpoint: format!("{}/oauth/v2/token", base_url),
|
||||
userinfo_endpoint: format!("{}/oidc/v1/userinfo", base_url),
|
||||
introspection_endpoint: format!("{}/oauth/v2/introspect", base_url),
|
||||
revocation_endpoint: format!("{}/oauth/v2/revoke", base_url),
|
||||
jwks_uri: format!("{}/oauth/v2/keys", base_url),
|
||||
authorization_endpoint: format!("{base_url}/oauth/v2/authorize"),
|
||||
token_endpoint: format!("{base_url}/oauth/v2/token"),
|
||||
userinfo_endpoint: format!("{base_url}/oidc/v1/userinfo"),
|
||||
introspection_endpoint: format!("{base_url}/oauth/v2/introspect"),
|
||||
revocation_endpoint: format!("{base_url}/oauth/v2/revoke"),
|
||||
jwks_uri: format!("{base_url}/oauth/v2/keys"),
|
||||
response_types_supported: vec![
|
||||
"code".to_string(),
|
||||
"token".to_string(),
|
||||
|
|
@ -210,9 +195,7 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Set up the JWKS endpoint with a mock key
|
||||
async fn setup_jwks_endpoint(&self) {
|
||||
// Simple mock JWKS - in production this would be a real RSA key
|
||||
let jwks = serde_json::json!({
|
||||
"keys": [{
|
||||
"kty": "RSA",
|
||||
|
|
@ -231,7 +214,7 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Create a test user and return their ID
|
||||
#[must_use]
|
||||
pub fn create_test_user(&self, email: &str) -> TestUser {
|
||||
let user = TestUser {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
|
|
@ -248,7 +231,7 @@ impl MockZitadel {
|
|||
user
|
||||
}
|
||||
|
||||
/// Create a test user with specific details
|
||||
#[must_use]
|
||||
pub fn create_user(&self, user: TestUser) -> TestUser {
|
||||
self.users
|
||||
.lock()
|
||||
|
|
@ -257,7 +240,6 @@ impl MockZitadel {
|
|||
user
|
||||
}
|
||||
|
||||
/// Expect a login with specific credentials and return a token response
|
||||
pub async fn expect_login(&self, email: &str, password: &str) -> String {
|
||||
let user = self
|
||||
.users
|
||||
|
|
@ -286,7 +268,6 @@ impl MockZitadel {
|
|||
.as_secs();
|
||||
let expires_in = 3600u64;
|
||||
|
||||
// Store token info
|
||||
self.tokens.lock().unwrap().insert(
|
||||
access_token.clone(),
|
||||
TokenInfo {
|
||||
|
|
@ -312,10 +293,9 @@ impl MockZitadel {
|
|||
scope: "openid profile email".to_string(),
|
||||
};
|
||||
|
||||
// Set up the mock for password grant
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/v2/token"))
|
||||
.and(body_string_contains(&format!("username={}", email)))
|
||||
.and(body_string_contains(format!("username={email}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&token_response))
|
||||
.mount(&self.server)
|
||||
.await;
|
||||
|
|
@ -323,7 +303,6 @@ impl MockZitadel {
|
|||
access_token
|
||||
}
|
||||
|
||||
/// Expect token refresh
|
||||
pub async fn expect_token_refresh(&self) {
|
||||
let access_token = format!("test_access_{}", Uuid::new_v4());
|
||||
let refresh_token = format!("test_refresh_{}", Uuid::new_v4());
|
||||
|
|
@ -345,7 +324,6 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect token introspection
|
||||
pub async fn expect_introspect(&self, token: &str, active: bool) {
|
||||
let response = if active {
|
||||
let now = SystemTime::now()
|
||||
|
|
@ -382,13 +360,12 @@ impl MockZitadel {
|
|||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/v2/introspect"))
|
||||
.and(body_string_contains(&format!("token={}", token)))
|
||||
.and(body_string_contains(format!("token={token}")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
|
||||
.mount(&self.server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set up default introspection that always returns active
|
||||
pub async fn expect_any_introspect_active(&self) {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
|
@ -415,7 +392,6 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect userinfo request
|
||||
pub async fn expect_userinfo(&self, token: &str, user: &TestUser) {
|
||||
let response = UserInfoResponse {
|
||||
sub: user.id.clone(),
|
||||
|
|
@ -428,16 +404,12 @@ impl MockZitadel {
|
|||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/oidc/v1/userinfo"))
|
||||
.and(header(
|
||||
"authorization",
|
||||
format!("Bearer {}", token).as_str(),
|
||||
))
|
||||
.and(header("authorization", format!("Bearer {token}").as_str()))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
|
||||
.mount(&self.server)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Set up default userinfo endpoint
|
||||
pub async fn expect_any_userinfo(&self) {
|
||||
let response = UserInfoResponse {
|
||||
sub: Uuid::new_v4().to_string(),
|
||||
|
|
@ -455,7 +427,6 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect token revocation
|
||||
pub async fn expect_revoke(&self) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/v2/revoke"))
|
||||
|
|
@ -464,7 +435,6 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect an authentication error
|
||||
pub async fn expect_auth_error(&self, error: &str, description: &str) {
|
||||
let response = ErrorResponse {
|
||||
error: error.to_string(),
|
||||
|
|
@ -478,19 +448,16 @@ impl MockZitadel {
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Expect invalid credentials error
|
||||
pub async fn expect_invalid_credentials(&self) {
|
||||
self.expect_auth_error("invalid_grant", "Invalid username or password")
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Expect client authentication error
|
||||
pub async fn expect_invalid_client(&self) {
|
||||
self.expect_auth_error("invalid_client", "Client authentication failed")
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Create a mock ID token (not cryptographically valid, for testing only)
|
||||
fn create_mock_id_token(&self, user: &TestUser) -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
|
@ -513,10 +480,10 @@ impl MockZitadel {
|
|||
);
|
||||
let signature = base64_url_encode("mock-signature");
|
||||
|
||||
format!("{}.{}.{}", header, payload, signature)
|
||||
format!("{header}.{payload}.{signature}")
|
||||
}
|
||||
|
||||
/// Generate an access token for a user
|
||||
#[must_use]
|
||||
pub fn generate_token(&self, user: &TestUser) -> String {
|
||||
let access_token = format!("test_access_{}", Uuid::new_v4());
|
||||
let now = SystemTime::now()
|
||||
|
|
@ -543,34 +510,32 @@ impl MockZitadel {
|
|||
access_token
|
||||
}
|
||||
|
||||
/// Invalidate a token
|
||||
pub fn invalidate_token(&self, token: &str) {
|
||||
if let Some(info) = self.tokens.lock().unwrap().get_mut(token) {
|
||||
info.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Get the issuer URL (same as server URL)
|
||||
#[must_use]
|
||||
pub fn issuer(&self) -> String {
|
||||
self.issuer.clone()
|
||||
}
|
||||
|
||||
/// Get the port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Get the OIDC discovery URL
|
||||
#[must_use]
|
||||
pub fn discovery_url(&self) -> String {
|
||||
format!("{}/.well-known/openid-configuration", self.url())
|
||||
}
|
||||
|
||||
/// Verify all expectations were met
|
||||
pub fn verify(&self) -> Result<()> {
|
||||
let store = self.expectations.lock().unwrap();
|
||||
for (_, exp) in store.iter() {
|
||||
|
|
@ -579,7 +544,6 @@ impl MockZitadel {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all mocks
|
||||
pub async fn reset(&self) {
|
||||
self.server.reset().await;
|
||||
self.users.lock().unwrap().clear();
|
||||
|
|
@ -589,13 +553,11 @@ impl MockZitadel {
|
|||
self.setup_jwks_endpoint().await;
|
||||
}
|
||||
|
||||
/// Get received requests for inspection
|
||||
pub async fn received_requests(&self) -> Vec<wiremock::Request> {
|
||||
self.server.received_requests().await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple base64 URL encoding (for mock tokens)
|
||||
fn base64_url_encode(input: &str) -> String {
|
||||
use std::io::Write;
|
||||
|
||||
|
|
@ -611,11 +573,10 @@ fn base64_url_encode(input: &str) -> String {
|
|||
.replace('=', "")
|
||||
}
|
||||
|
||||
/// Create a base64 encoder
|
||||
fn base64_encoder(output: &mut Vec<u8>) -> impl std::io::Write + '_ {
|
||||
struct Base64Writer<'a>(&'a mut Vec<u8>);
|
||||
|
||||
impl<'a> std::io::Write for Base64Writer<'a> {
|
||||
impl std::io::Write for Base64Writer<'_> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
|
@ -687,7 +648,7 @@ mod tests {
|
|||
assert!(json.contains("access_token"));
|
||||
assert!(json.contains("Bearer"));
|
||||
assert!(json.contains("refresh_token"));
|
||||
assert!(!json.contains("id_token")); // Should be skipped when None
|
||||
assert!(!json.contains("id_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -698,8 +659,8 @@ mod tests {
|
|||
client_id: Some("client".to_string()),
|
||||
username: Some("user@test.com".to_string()),
|
||||
token_type: Some("Bearer".to_string()),
|
||||
exp: Some(1234567890),
|
||||
iat: Some(1234567800),
|
||||
exp: Some(1_234_567_890),
|
||||
iat: Some(1_234_567_800),
|
||||
sub: Some("user-id".to_string()),
|
||||
aud: Some("audience".to_string()),
|
||||
iss: Some("issuer".to_string()),
|
||||
|
|
@ -726,7 +687,6 @@ mod tests {
|
|||
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains(r#""active":false"#));
|
||||
// Optional fields should be omitted
|
||||
assert!(!json.contains("scope"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
//! Port allocation for parallel test execution
|
||||
//!
|
||||
//! Ensures each test gets unique ports to avoid conflicts
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
|
@ -29,6 +26,7 @@ impl PortAllocator {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allocate_range(count: usize) -> Vec<u16> {
|
||||
(0..count).map(|_| Self::allocate()).collect()
|
||||
}
|
||||
|
|
@ -71,8 +69,6 @@ impl TestPorts {
|
|||
|
||||
impl Drop for TestPorts {
|
||||
fn drop(&mut self) {
|
||||
// Only release dynamically allocated ports (>= 15000)
|
||||
// Fixed ports from existing stack should not be released
|
||||
if self.postgres >= 15000 {
|
||||
PortAllocator::release(self.postgres);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
//! Browser service for E2E testing using Chrome DevTools Protocol (CDP)
|
||||
//!
|
||||
//! Launches browser directly with --remote-debugging-port, bypassing chromedriver.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use log::{info, warn};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
/// Default debugging port for CDP
|
||||
pub const DEFAULT_DEBUG_PORT: u16 = 9222;
|
||||
|
||||
/// Browser service that manages a browser instance with CDP enabled
|
||||
pub struct BrowserService {
|
||||
port: u16,
|
||||
process: Option<Child>,
|
||||
|
|
@ -19,33 +13,28 @@ pub struct BrowserService {
|
|||
}
|
||||
|
||||
impl BrowserService {
|
||||
/// Start a browser with remote debugging enabled
|
||||
pub async fn start(port: u16) -> Result<Self> {
|
||||
// First, kill any existing browser on this port
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.args(["-9", "-f", &format!("--remote-debugging-port={}", port)])
|
||||
.args(["-9", "-f", &format!("--remote-debugging-port={port}")])
|
||||
.output();
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let binary_path = Self::detect_browser_binary()?;
|
||||
let user_data_dir = format!("/tmp/browser-cdp-{}-{}", std::process::id(), port);
|
||||
|
||||
// Clean up and create user data directory
|
||||
let _ = std::fs::remove_dir_all(&user_data_dir);
|
||||
std::fs::create_dir_all(&user_data_dir)?;
|
||||
|
||||
info!("Starting browser with CDP on port {}", port);
|
||||
println!("🌐 Starting browser: {}", binary_path);
|
||||
info!(" Binary: {}", binary_path);
|
||||
info!(" User data: {}", user_data_dir);
|
||||
info!("Starting browser with CDP on port {port}");
|
||||
println!("🌐 Starting browser: {binary_path}");
|
||||
info!(" Binary: {binary_path}");
|
||||
info!(" User data: {user_data_dir}");
|
||||
|
||||
// Default: SHOW browser window so user can see tests
|
||||
// Set HEADLESS=1 to run without browser window (CI/automation)
|
||||
let headless = std::env::var("HEADLESS").is_ok();
|
||||
|
||||
let mut cmd = Command::new(&binary_path);
|
||||
cmd.arg(format!("--remote-debugging-port={}", port))
|
||||
.arg(format!("--user-data-dir={}", user_data_dir))
|
||||
cmd.arg(format!("--remote-debugging-port={port}"))
|
||||
.arg(format!("--user-data-dir={user_data_dir}"))
|
||||
.arg("--no-sandbox")
|
||||
.arg("--disable-dev-shm-usage")
|
||||
.arg("--disable-extensions")
|
||||
|
|
@ -56,7 +45,6 @@ impl BrowserService {
|
|||
.arg("--metrics-recording-only")
|
||||
.arg("--no-first-run")
|
||||
.arg("--safebrowsing-disable-auto-update")
|
||||
// SSL/TLS certificate bypass flags
|
||||
.arg("--ignore-certificate-errors")
|
||||
.arg("--ignore-certificate-errors-spki-list")
|
||||
.arg("--ignore-ssl-errors")
|
||||
|
|
@ -64,27 +52,24 @@ impl BrowserService {
|
|||
.arg("--allow-running-insecure-content")
|
||||
.arg("--disable-web-security")
|
||||
.arg("--reduce-security-for-testing")
|
||||
// Window position and size to make it visible
|
||||
.arg("--window-position=100,100")
|
||||
.arg("--window-size=1280,800")
|
||||
.arg("--start-maximized");
|
||||
|
||||
// Headless flags BEFORE the URL
|
||||
if headless {
|
||||
cmd.arg("--headless=new");
|
||||
cmd.arg("--disable-gpu");
|
||||
}
|
||||
|
||||
// URL goes last
|
||||
cmd.arg("about:blank");
|
||||
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
|
||||
let process = cmd
|
||||
.spawn()
|
||||
.context(format!("Failed to start browser: {}", binary_path))?;
|
||||
.context(format!("Failed to start browser: {binary_path}"))?;
|
||||
|
||||
println!(" ⏳ Waiting for CDP on port {}...", port);
|
||||
println!(" ⏳ Waiting for CDP on port {port}...");
|
||||
|
||||
let service = Self {
|
||||
port,
|
||||
|
|
@ -93,12 +78,11 @@ impl BrowserService {
|
|||
user_data_dir,
|
||||
};
|
||||
|
||||
// Wait for CDP to be ready - be patient!
|
||||
for i in 0..100 {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
if service.is_ready().await {
|
||||
info!("Browser CDP ready on port {}", port);
|
||||
println!(" ✓ Browser CDP ready on port {}", port);
|
||||
info!("Browser CDP ready on port {port}");
|
||||
println!(" ✓ Browser CDP ready on port {port}");
|
||||
return Ok(service);
|
||||
}
|
||||
if i % 20 == 0 && i > 0 {
|
||||
|
|
@ -107,12 +91,11 @@ impl BrowserService {
|
|||
}
|
||||
}
|
||||
|
||||
warn!("Browser may not be fully ready on CDP port {}", port);
|
||||
warn!("Browser may not be fully ready on CDP port {port}");
|
||||
println!(" ⚠ Browser may not be fully ready");
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Check if CDP is ready by fetching the version endpoint
|
||||
async fn is_ready(&self) -> bool {
|
||||
let url = format!("http://127.0.0.1:{}/json/version", self.port);
|
||||
match reqwest::get(&url).await {
|
||||
|
|
@ -121,17 +104,14 @@ impl BrowserService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Detect the best available browser binary for CDP testing
|
||||
fn detect_browser_binary() -> Result<String> {
|
||||
// Check for BROWSER_BINARY env var first
|
||||
if let Ok(path) = std::env::var("BROWSER_BINARY") {
|
||||
if std::path::Path::new(&path).exists() {
|
||||
info!("Using browser from BROWSER_BINARY env var: {}", path);
|
||||
info!("Using browser from BROWSER_BINARY env var: {path}");
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer Brave first
|
||||
let brave_paths = [
|
||||
"/opt/brave.com/brave-nightly/brave",
|
||||
"/opt/brave.com/brave/brave",
|
||||
|
|
@ -140,12 +120,11 @@ impl BrowserService {
|
|||
];
|
||||
for path in brave_paths {
|
||||
if std::path::Path::new(path).exists() {
|
||||
info!("Detected Brave binary at: {}", path);
|
||||
info!("Detected Brave binary at: {path}");
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Chrome second
|
||||
let chrome_paths = [
|
||||
"/opt/google/chrome/chrome",
|
||||
"/opt/google/chrome/google-chrome",
|
||||
|
|
@ -154,12 +133,11 @@ impl BrowserService {
|
|||
];
|
||||
for path in chrome_paths {
|
||||
if std::path::Path::new(path).exists() {
|
||||
info!("Detected Chrome binary at: {}", path);
|
||||
info!("Detected Chrome binary at: {path}");
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium last
|
||||
let chromium_paths = [
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/chromium",
|
||||
|
|
@ -167,7 +145,7 @@ impl BrowserService {
|
|||
];
|
||||
for path in chromium_paths {
|
||||
if std::path::Path::new(path).exists() {
|
||||
info!("Detected Chromium binary at: {}", path);
|
||||
info!("Detected Chromium binary at: {path}");
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
}
|
||||
|
|
@ -175,22 +153,22 @@ impl BrowserService {
|
|||
anyhow::bail!("No supported browser found. Install Brave, Chrome, or Chromium.")
|
||||
}
|
||||
|
||||
/// Get the CDP WebSocket URL for connecting
|
||||
#[must_use]
|
||||
pub fn ws_url(&self) -> String {
|
||||
format!("ws://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Get the HTTP URL for CDP endpoints
|
||||
#[must_use]
|
||||
pub fn http_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.port)
|
||||
}
|
||||
|
||||
/// Get the debugging port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Stop the browser
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
if let Some(mut process) = self.process.take() {
|
||||
info!("Stopping browser");
|
||||
|
|
@ -198,7 +176,6 @@ impl BrowserService {
|
|||
process.wait().ok();
|
||||
}
|
||||
|
||||
// Clean up user data directory
|
||||
if std::path::Path::new(&self.user_data_dir).exists() {
|
||||
std::fs::remove_dir_all(&self.user_data_dir).ok();
|
||||
}
|
||||
|
|
@ -206,7 +183,6 @@ impl BrowserService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleanup resources
|
||||
pub fn cleanup(&mut self) {
|
||||
if let Some(mut process) = self.process.take() {
|
||||
process.kill().ok();
|
||||
|
|
@ -231,9 +207,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_detect_browser() {
|
||||
// Should not fail - will find at least one browser or return error
|
||||
let result = BrowserService::detect_browser_binary();
|
||||
// Test passes if we found a browser
|
||||
if let Ok(path) = result {
|
||||
assert!(!path.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
//! MinIO service management for test infrastructure
|
||||
//!
|
||||
//! Starts and manages a MinIO instance for S3-compatible storage testing.
|
||||
//! Finds MinIO binary from system installation or botserver-stack.
|
||||
//! Provides bucket creation, object operations, and credential management.
|
||||
|
||||
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
|
||||
use anyhow::{Context, Result};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
|
|
@ -14,7 +8,6 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// MinIO service for S3-compatible storage in test environments
|
||||
pub struct MinioService {
|
||||
api_port: u16,
|
||||
console_port: u16,
|
||||
|
|
@ -26,24 +19,22 @@ pub struct MinioService {
|
|||
}
|
||||
|
||||
impl MinioService {
|
||||
/// Default access key for tests
|
||||
pub const DEFAULT_ACCESS_KEY: &'static str = "minioadmin";
|
||||
|
||||
/// Default secret key for tests
|
||||
pub const DEFAULT_SECRET_KEY: &'static str = "minioadmin";
|
||||
|
||||
/// Find MinIO binary - checks botserver-stack first, then system paths
|
||||
fn find_minio_binary() -> Result<PathBuf> {
|
||||
// First, check BOTSERVER_STACK_PATH env var
|
||||
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
|
||||
let minio_path = PathBuf::from(&stack_path).join("bin/drive/minio");
|
||||
if minio_path.exists() {
|
||||
log::info!("Using MinIO from BOTSERVER_STACK_PATH: {:?}", minio_path);
|
||||
log::info!(
|
||||
"Using MinIO from BOTSERVER_STACK_PATH: {}",
|
||||
minio_path.display()
|
||||
);
|
||||
return Ok(minio_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check relative paths from current directory
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let relative_paths = [
|
||||
"../botserver/botserver-stack/bin/drive/minio",
|
||||
|
|
@ -54,12 +45,11 @@ impl MinioService {
|
|||
for rel_path in &relative_paths {
|
||||
let minio_path = cwd.join(rel_path);
|
||||
if minio_path.exists() {
|
||||
log::info!("Using MinIO from botserver-stack: {:?}", minio_path);
|
||||
log::info!("Using MinIO from botserver-stack: {}", minio_path.display());
|
||||
return Ok(minio_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check system paths
|
||||
let system_paths = [
|
||||
"/usr/local/bin/minio",
|
||||
"/usr/bin/minio",
|
||||
|
|
@ -70,29 +60,26 @@ impl MinioService {
|
|||
for path in &system_paths {
|
||||
let minio_path = PathBuf::from(path);
|
||||
if minio_path.exists() {
|
||||
log::info!("Using system MinIO from: {:?}", minio_path);
|
||||
log::info!("Using system MinIO from: {}", minio_path.display());
|
||||
return Ok(minio_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try to find via which
|
||||
if let Ok(minio_path) = which::which("minio") {
|
||||
log::info!("Using MinIO from PATH: {:?}", minio_path);
|
||||
log::info!("Using MinIO from PATH: {}", minio_path.display());
|
||||
return Ok(minio_path);
|
||||
}
|
||||
|
||||
anyhow::bail!("MinIO not found. Install MinIO or set BOTSERVER_STACK_PATH env var")
|
||||
}
|
||||
|
||||
/// Start a new MinIO instance on the specified port
|
||||
pub async fn start(api_port: u16, data_dir: &str) -> Result<Self> {
|
||||
let bin_path = Self::find_minio_binary()?;
|
||||
log::info!("Using MinIO from: {:?}", bin_path);
|
||||
log::info!("Using MinIO from: {}", bin_path.display());
|
||||
|
||||
let data_path = PathBuf::from(data_dir).join("minio");
|
||||
ensure_dir(&data_path)?;
|
||||
|
||||
// Allocate a console port (api_port + 1000 or find available)
|
||||
let console_port = api_port + 1000;
|
||||
|
||||
let mut service = Self {
|
||||
|
|
@ -105,13 +92,12 @@ impl MinioService {
|
|||
secret_key: Self::DEFAULT_SECRET_KEY.to_string(),
|
||||
};
|
||||
|
||||
service.start_server().await?;
|
||||
service.start_server()?;
|
||||
service.wait_ready().await?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Start MinIO with custom credentials
|
||||
pub async fn start_with_credentials(
|
||||
api_port: u16,
|
||||
data_dir: &str,
|
||||
|
|
@ -119,7 +105,7 @@ impl MinioService {
|
|||
secret_key: &str,
|
||||
) -> Result<Self> {
|
||||
let bin_path = Self::find_minio_binary()?;
|
||||
log::info!("Using MinIO from: {:?}", bin_path);
|
||||
log::info!("Using MinIO from: {}", bin_path.display());
|
||||
|
||||
let data_path = PathBuf::from(data_dir).join("minio");
|
||||
ensure_dir(&data_path)?;
|
||||
|
|
@ -136,14 +122,13 @@ impl MinioService {
|
|||
secret_key: secret_key.to_string(),
|
||||
};
|
||||
|
||||
service.start_server().await?;
|
||||
service.start_server()?;
|
||||
service.wait_ready().await?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Start the MinIO server process
|
||||
async fn start_server(&mut self) -> Result<()> {
|
||||
fn start_server(&mut self) -> Result<()> {
|
||||
log::info!(
|
||||
"Starting MinIO on port {} (console: {})",
|
||||
self.api_port,
|
||||
|
|
@ -170,7 +155,6 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for MinIO to be ready
|
||||
async fn wait_ready(&self) -> Result<()> {
|
||||
log::info!("Waiting for MinIO to be ready...");
|
||||
|
||||
|
|
@ -180,7 +164,6 @@ impl MinioService {
|
|||
.await
|
||||
.context("MinIO failed to start in time")?;
|
||||
|
||||
// Additional health check via HTTP
|
||||
let health_url = format!("http://127.0.0.1:{}/minio/health/live", self.api_port);
|
||||
for _ in 0..30 {
|
||||
if let Ok(resp) = reqwest::get(&health_url).await {
|
||||
|
|
@ -191,17 +174,13 @@ impl MinioService {
|
|||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// Even if health check fails, TCP is up so proceed
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new bucket
|
||||
pub async fn create_bucket(&self, name: &str) -> Result<()> {
|
||||
log::info!("Creating bucket '{}'", name);
|
||||
log::info!("Creating bucket '{name}'");
|
||||
|
||||
// Try using mc (MinIO client) if available
|
||||
if let Ok(mc) = Self::find_mc_binary() {
|
||||
// Configure mc alias
|
||||
let alias_name = format!("test{}", self.api_port);
|
||||
let _ = Command::new(&mc)
|
||||
.args([
|
||||
|
|
@ -215,24 +194,19 @@ impl MinioService {
|
|||
.output();
|
||||
|
||||
let output = Command::new(&mc)
|
||||
.args([
|
||||
"mb",
|
||||
"--ignore-existing",
|
||||
&format!("{}/{}", alias_name, name),
|
||||
])
|
||||
.args(["mb", "--ignore-existing", &format!("{alias_name}/{name}")])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("already") {
|
||||
anyhow::bail!("Failed to create bucket: {}", stderr);
|
||||
anyhow::bail!("Failed to create bucket: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fallback: use HTTP PUT request
|
||||
let url = format!("{}/{}", self.endpoint(), name);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
|
|
@ -248,7 +222,6 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Put an object into a bucket
|
||||
pub async fn put_object(&self, bucket: &str, key: &str, data: &[u8]) -> Result<()> {
|
||||
log::debug!("Putting object '{}/{}' ({} bytes)", bucket, key, data.len());
|
||||
|
||||
|
|
@ -268,9 +241,8 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an object from a bucket
|
||||
pub async fn get_object(&self, bucket: &str, key: &str) -> Result<Vec<u8>> {
|
||||
log::debug!("Getting object '{}/{}'", bucket, key);
|
||||
log::debug!("Getting object '{bucket}/{key}'");
|
||||
|
||||
let url = format!("{}/{}/{}", self.endpoint(), bucket, key);
|
||||
let client = reqwest::Client::new();
|
||||
|
|
@ -287,9 +259,8 @@ impl MinioService {
|
|||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
|
||||
/// Delete an object from a bucket
|
||||
pub async fn delete_object(&self, bucket: &str, key: &str) -> Result<()> {
|
||||
log::debug!("Deleting object '{}/{}'", bucket, key);
|
||||
log::debug!("Deleting object '{bucket}/{key}'");
|
||||
|
||||
let url = format!("{}/{}/{}", self.endpoint(), bucket, key);
|
||||
let client = reqwest::Client::new();
|
||||
|
|
@ -306,13 +277,12 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// List objects in a bucket
|
||||
pub async fn list_objects(&self, bucket: &str, prefix: Option<&str>) -> Result<Vec<String>> {
|
||||
log::debug!("Listing objects in bucket '{}'", bucket);
|
||||
log::debug!("Listing objects in bucket '{bucket}'");
|
||||
|
||||
let mut url = format!("{}/{}", self.endpoint(), bucket);
|
||||
if let Some(p) = prefix {
|
||||
url = format!("{}?prefix={}", url, p);
|
||||
url = format!("{url}?prefix={p}");
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
|
@ -326,11 +296,9 @@ impl MinioService {
|
|||
anyhow::bail!("Failed to list objects: {}", resp.status());
|
||||
}
|
||||
|
||||
// Parse XML response (simplified)
|
||||
let body = resp.text().await?;
|
||||
let mut objects = Vec::new();
|
||||
|
||||
// Simple XML parsing for <Key> elements
|
||||
for line in body.lines() {
|
||||
if let Some(start) = line.find("<Key>") {
|
||||
if let Some(end) = line.find("</Key>") {
|
||||
|
|
@ -343,7 +311,6 @@ impl MinioService {
|
|||
Ok(objects)
|
||||
}
|
||||
|
||||
/// Check if a bucket exists
|
||||
pub async fn bucket_exists(&self, name: &str) -> Result<bool> {
|
||||
let url = format!("{}/{}", self.endpoint(), name);
|
||||
let client = reqwest::Client::new();
|
||||
|
|
@ -356,9 +323,8 @@ impl MinioService {
|
|||
Ok(resp.status().is_success())
|
||||
}
|
||||
|
||||
/// Delete a bucket
|
||||
pub async fn delete_bucket(&self, name: &str) -> Result<()> {
|
||||
log::info!("Deleting bucket '{}'", name);
|
||||
log::info!("Deleting bucket '{name}'");
|
||||
|
||||
let url = format!("{}/{}", self.endpoint(), name);
|
||||
let client = reqwest::Client::new();
|
||||
|
|
@ -375,32 +341,32 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the S3 endpoint URL
|
||||
#[must_use]
|
||||
pub fn endpoint(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.api_port)
|
||||
}
|
||||
|
||||
/// Get the console URL
|
||||
#[must_use]
|
||||
pub fn console_url(&self) -> String {
|
||||
format!("http://127.0.0.1:{}", self.console_port)
|
||||
}
|
||||
|
||||
/// Get the API port
|
||||
pub fn api_port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn api_port(&self) -> u16 {
|
||||
self.api_port
|
||||
}
|
||||
|
||||
/// Get the console port
|
||||
pub fn console_port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn console_port(&self) -> u16 {
|
||||
self.console_port
|
||||
}
|
||||
|
||||
/// Get credentials as (access_key, secret_key)
|
||||
#[must_use]
|
||||
pub fn credentials(&self) -> (String, String) {
|
||||
(self.access_key.clone(), self.secret_key.clone())
|
||||
}
|
||||
|
||||
/// Get S3-compatible configuration for AWS SDK
|
||||
#[must_use]
|
||||
pub fn s3_config(&self) -> HashMap<String, String> {
|
||||
let mut config = HashMap::new();
|
||||
config.insert("endpoint_url".to_string(), self.endpoint());
|
||||
|
|
@ -411,7 +377,6 @@ impl MinioService {
|
|||
config
|
||||
}
|
||||
|
||||
/// Find the MinIO client (mc) binary
|
||||
fn find_mc_binary() -> Result<PathBuf> {
|
||||
let common_paths = ["/usr/local/bin/mc", "/usr/bin/mc", "/opt/homebrew/bin/mc"];
|
||||
|
||||
|
|
@ -425,7 +390,6 @@ impl MinioService {
|
|||
which::which("mc").context("mc binary not found")
|
||||
}
|
||||
|
||||
/// Stop the MinIO server
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
if let Some(ref mut child) = self.process {
|
||||
log::info!("Stopping MinIO...");
|
||||
|
|
@ -452,7 +416,6 @@ impl MinioService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up data directory
|
||||
pub fn cleanup(&self) -> Result<()> {
|
||||
if self.data_dir.exists() {
|
||||
std::fs::remove_dir_all(&self.data_dir)?;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
//! Service management for test infrastructure
|
||||
//!
|
||||
//! Provides real service instances (PostgreSQL, MinIO, Redis) for integration testing.
|
||||
//! Each service runs on a dynamic port to enable parallel test execution.
|
||||
|
||||
mod browser_service;
|
||||
mod minio;
|
||||
|
|
@ -18,13 +14,10 @@ use std::path::Path;
|
|||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Default timeout for service health checks
|
||||
pub const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Default interval between health check attempts
|
||||
pub const HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Wait for a condition to become true with timeout
|
||||
pub async fn wait_for<F, Fut>(timeout: Duration, interval: Duration, mut check: F) -> Result<()>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
|
|
@ -40,12 +33,10 @@ where
|
|||
anyhow::bail!("Timeout waiting for condition")
|
||||
}
|
||||
|
||||
/// Check if a TCP port is accepting connections
|
||||
pub async fn check_tcp_port(host: &str, port: u16) -> bool {
|
||||
tokio::net::TcpStream::connect((host, port)).await.is_ok()
|
||||
}
|
||||
|
||||
/// Create a directory if it doesn't exist
|
||||
pub fn ensure_dir(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path)?;
|
||||
|
|
@ -53,23 +44,17 @@ pub fn ensure_dir(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Service trait for common operations
|
||||
#[async_trait::async_trait]
|
||||
pub trait Service: Send + Sync {
|
||||
/// Start the service
|
||||
async fn start(&mut self) -> Result<()>;
|
||||
|
||||
/// Stop the service gracefully
|
||||
async fn stop(&mut self) -> Result<()>;
|
||||
|
||||
/// Check if the service is healthy
|
||||
async fn health_check(&self) -> Result<bool>;
|
||||
|
||||
/// Get the service connection URL
|
||||
fn connection_url(&self) -> String;
|
||||
}
|
||||
|
||||
/// Service status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ServiceStatus {
|
||||
Stopped,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! PostgreSQL service management for test infrastructure
|
||||
//!
|
||||
//! Starts and manages a PostgreSQL instance for integration testing.
|
||||
//! Finds PostgreSQL binaries from system installation or botserver-stack.
|
||||
|
||||
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
|
||||
use anyhow::{Context, Result};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
|
|
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// PostgreSQL service for test environments
|
||||
pub struct PostgresService {
|
||||
port: u16,
|
||||
data_dir: PathBuf,
|
||||
|
|
@ -26,28 +20,25 @@ pub struct PostgresService {
|
|||
}
|
||||
|
||||
impl PostgresService {
|
||||
/// Default database name for tests
|
||||
pub const DEFAULT_DATABASE: &'static str = "bottest";
|
||||
|
||||
/// Default username for tests
|
||||
pub const DEFAULT_USERNAME: &'static str = "bottest";
|
||||
|
||||
/// Default password for tests
|
||||
pub const DEFAULT_PASSWORD: &'static str = "bottest";
|
||||
|
||||
/// Find PostgreSQL binaries - checks botserver-stack first, then system paths
|
||||
fn find_postgres_installation() -> Result<(PathBuf, Option<PathBuf>)> {
|
||||
// First, check BOTSERVER_STACK_PATH env var
|
||||
if let Ok(stack_path) = std::env::var("BOTSERVER_STACK_PATH") {
|
||||
let bin_dir = PathBuf::from(&stack_path).join("bin/tables/bin");
|
||||
let lib_dir = PathBuf::from(&stack_path).join("bin/tables/lib");
|
||||
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
|
||||
log::info!("Using PostgreSQL from BOTSERVER_STACK_PATH: {:?}", bin_dir);
|
||||
log::info!(
|
||||
"Using PostgreSQL from BOTSERVER_STACK_PATH: {}",
|
||||
bin_dir.display()
|
||||
);
|
||||
return Ok((bin_dir, Some(lib_dir)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check relative paths from current directory
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let relative_paths = [
|
||||
"../botserver/botserver-stack/bin/tables/bin",
|
||||
|
|
@ -59,7 +50,10 @@ impl PostgresService {
|
|||
let bin_dir = cwd.join(rel_path);
|
||||
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
|
||||
let lib_dir = bin_dir.parent().unwrap().join("lib");
|
||||
log::info!("Using PostgreSQL from botserver-stack: {:?}", bin_dir);
|
||||
log::info!(
|
||||
"Using PostgreSQL from botserver-stack: {}",
|
||||
bin_dir.display()
|
||||
);
|
||||
return Ok((
|
||||
bin_dir,
|
||||
if lib_dir.exists() {
|
||||
|
|
@ -71,7 +65,6 @@ impl PostgresService {
|
|||
}
|
||||
}
|
||||
|
||||
// Check system PostgreSQL paths
|
||||
let system_paths = [
|
||||
"/usr/lib/postgresql/17/bin",
|
||||
"/usr/lib/postgresql/16/bin",
|
||||
|
|
@ -88,15 +81,14 @@ impl PostgresService {
|
|||
for path in &system_paths {
|
||||
let bin_dir = PathBuf::from(path);
|
||||
if bin_dir.join("postgres").exists() || bin_dir.join("initdb").exists() {
|
||||
log::info!("Using system PostgreSQL from: {:?}", bin_dir);
|
||||
log::info!("Using system PostgreSQL from: {}", bin_dir.display());
|
||||
return Ok((bin_dir, None));
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try to find via which
|
||||
if let Ok(initdb_path) = which::which("initdb") {
|
||||
if let Some(bin_dir) = initdb_path.parent() {
|
||||
log::info!("Using PostgreSQL from PATH: {:?}", bin_dir);
|
||||
log::info!("Using PostgreSQL from PATH: {}", bin_dir.display());
|
||||
return Ok((bin_dir.to_path_buf(), None));
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +98,6 @@ impl PostgresService {
|
|||
)
|
||||
}
|
||||
|
||||
/// Start a new PostgreSQL instance on the specified port
|
||||
pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
|
||||
let (bin_dir, lib_dir) = Self::find_postgres_installation()?;
|
||||
|
||||
|
|
@ -127,29 +118,23 @@ impl PostgresService {
|
|||
|
||||
service.connection_string = service.build_connection_string();
|
||||
|
||||
// Initialize database cluster if needed
|
||||
if !data_path.join("PG_VERSION").exists() {
|
||||
service.init_db().await?;
|
||||
service.init_db()?;
|
||||
}
|
||||
|
||||
// Start PostgreSQL
|
||||
service.start_server().await?;
|
||||
service.start_server()?;
|
||||
|
||||
// Wait for it to be ready
|
||||
service.wait_ready().await?;
|
||||
|
||||
// Create test database and user
|
||||
service.setup_test_database().await?;
|
||||
service.setup_test_database()?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Get binary path
|
||||
fn get_binary(&self, name: &str) -> PathBuf {
|
||||
self.bin_dir.join(name)
|
||||
}
|
||||
|
||||
/// Build command with LD_LIBRARY_PATH if needed
|
||||
fn build_command(&self, binary_name: &str) -> Command {
|
||||
let binary = self.get_binary(binary_name);
|
||||
let mut cmd = Command::new(&binary);
|
||||
|
|
@ -159,11 +144,10 @@ impl PostgresService {
|
|||
cmd
|
||||
}
|
||||
|
||||
/// Initialize the database cluster
|
||||
async fn init_db(&self) -> Result<()> {
|
||||
fn init_db(&self) -> Result<()> {
|
||||
log::info!(
|
||||
"Initializing PostgreSQL data directory at {:?}",
|
||||
self.data_dir
|
||||
"Initializing PostgreSQL data directory at {}",
|
||||
self.data_dir.display()
|
||||
);
|
||||
|
||||
let output = self
|
||||
|
|
@ -184,27 +168,24 @@ impl PostgresService {
|
|||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("initdb failed: {}", stderr);
|
||||
anyhow::bail!("initdb failed: {stderr}");
|
||||
}
|
||||
|
||||
// Configure postgresql.conf for testing
|
||||
self.configure_for_testing()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure PostgreSQL for fast testing (reduced durability)
|
||||
fn configure_for_testing(&self) -> Result<()> {
|
||||
let config_path = self.data_dir.join("postgresql.conf");
|
||||
|
||||
// Use absolute path for unix_socket_directories
|
||||
let abs_data_dir = self
|
||||
.data_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| self.data_dir.clone());
|
||||
|
||||
let config = format!(
|
||||
r#"
|
||||
r"
|
||||
# Test configuration - optimized for speed, not durability
|
||||
listen_addresses = '127.0.0.1'
|
||||
port = {}
|
||||
|
|
@ -222,7 +203,7 @@ logging_collector = off
|
|||
log_statement = 'none'
|
||||
log_duration = off
|
||||
unix_socket_directories = '{}'
|
||||
"#,
|
||||
",
|
||||
self.port,
|
||||
abs_data_dir.to_str().unwrap()
|
||||
);
|
||||
|
|
@ -231,17 +212,15 @@ unix_socket_directories = '{}'
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the PostgreSQL server process
|
||||
async fn start_server(&mut self) -> Result<()> {
|
||||
fn start_server(&mut self) -> Result<()> {
|
||||
log::info!("Starting PostgreSQL on port {}", self.port);
|
||||
|
||||
// Create log file for debugging
|
||||
let log_path = self.data_dir.join("postgres.log");
|
||||
let log_file = std::fs::File::create(&log_path)
|
||||
.context(format!("Failed to create log file {:?}", log_path))?;
|
||||
.context(format!("Failed to create log file {}", log_path.display()))?;
|
||||
let stderr_file = log_file.try_clone()?;
|
||||
|
||||
log::debug!("PostgreSQL log file: {:?}", log_path);
|
||||
log::debug!("PostgreSQL log file: {}", log_path.display());
|
||||
|
||||
let mut cmd = self.build_command("postgres");
|
||||
let child = cmd
|
||||
|
|
@ -255,7 +234,6 @@ unix_socket_directories = '{}'
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for PostgreSQL to be ready to accept connections
|
||||
async fn wait_ready(&self) -> Result<()> {
|
||||
log::info!("Waiting for PostgreSQL to be ready...");
|
||||
|
||||
|
|
@ -264,18 +242,16 @@ unix_socket_directories = '{}'
|
|||
})
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
// Read log file to show error
|
||||
if let Err(e) = result {
|
||||
let log_path = self.data_dir.join("postgres.log");
|
||||
if log_path.exists() {
|
||||
if let Ok(log_content) = std::fs::read_to_string(&log_path) {
|
||||
log::error!("PostgreSQL log:\n{}", log_content);
|
||||
log::error!("PostgreSQL log:\n{log_content}");
|
||||
}
|
||||
}
|
||||
return Err(result.unwrap_err()).context("PostgreSQL failed to start in time");
|
||||
return Err(e).context("PostgreSQL failed to start in time");
|
||||
}
|
||||
|
||||
// Additional wait for pg_isready
|
||||
for _ in 0..30 {
|
||||
let status = self
|
||||
.build_command("pg_isready")
|
||||
|
|
@ -291,11 +267,9 @@ unix_socket_directories = '{}'
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the test database and user
|
||||
async fn setup_test_database(&self) -> Result<()> {
|
||||
fn setup_test_database(&self) -> Result<()> {
|
||||
log::info!("Setting up test database '{}'", self.database_name);
|
||||
|
||||
// Create user
|
||||
let _ = self
|
||||
.build_command("psql")
|
||||
.args([
|
||||
|
|
@ -313,7 +287,6 @@ unix_socket_directories = '{}'
|
|||
])
|
||||
.output();
|
||||
|
||||
// Create database
|
||||
let _ = self
|
||||
.build_command("psql")
|
||||
.args([
|
||||
|
|
@ -334,11 +307,9 @@ unix_socket_directories = '{}'
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Run database migrations
|
||||
pub async fn run_migrations(&self) -> Result<()> {
|
||||
pub fn run_migrations(&self) -> Result<()> {
|
||||
log::info!("Running database migrations...");
|
||||
|
||||
// Try to run migrations using diesel CLI if available
|
||||
if let Ok(diesel) = which::which("diesel") {
|
||||
let status = Command::new(diesel)
|
||||
.args([
|
||||
|
|
@ -354,13 +325,11 @@ unix_socket_directories = '{}'
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback: run migrations programmatically via botlib if available
|
||||
log::warn!("diesel CLI not available, skipping migrations");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new database with the given name
|
||||
pub async fn create_database(&self, name: &str) -> Result<()> {
|
||||
pub fn create_database(&self, name: &str) -> Result<()> {
|
||||
let output = self
|
||||
.build_command("psql")
|
||||
.args([
|
||||
|
|
@ -371,22 +340,21 @@ unix_socket_directories = '{}'
|
|||
"-U",
|
||||
&self.username,
|
||||
"-c",
|
||||
&format!("CREATE DATABASE {}", name),
|
||||
&format!("CREATE DATABASE {name}"),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("already exists") {
|
||||
anyhow::bail!("Failed to create database: {}", stderr);
|
||||
anyhow::bail!("Failed to create database: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute raw SQL
|
||||
pub async fn execute(&self, sql: &str) -> Result<()> {
|
||||
pub fn execute(&self, sql: &str) -> Result<()> {
|
||||
let output = self
|
||||
.build_command("psql")
|
||||
.args([
|
||||
|
|
@ -405,14 +373,13 @@ unix_socket_directories = '{}'
|
|||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("SQL execution failed: {}", stderr);
|
||||
anyhow::bail!("SQL execution failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute SQL and return results as JSON
|
||||
pub async fn query(&self, sql: &str) -> Result<String> {
|
||||
pub fn query(&self, sql: &str) -> Result<String> {
|
||||
let output = self
|
||||
.build_command("psql")
|
||||
.args([
|
||||
|
|
@ -424,8 +391,8 @@ unix_socket_directories = '{}'
|
|||
&self.username,
|
||||
"-d",
|
||||
&self.database_name,
|
||||
"-t", // tuples only
|
||||
"-A", // unaligned
|
||||
"-t",
|
||||
"-A",
|
||||
"-c",
|
||||
sql,
|
||||
])
|
||||
|
|
@ -433,23 +400,22 @@ unix_socket_directories = '{}'
|
|||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("SQL query failed: {}", stderr);
|
||||
anyhow::bail!("SQL query failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Get the connection string
|
||||
#[must_use]
|
||||
pub fn connection_string(&self) -> String {
|
||||
self.connection_string.clone()
|
||||
}
|
||||
|
||||
/// Get the port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Build the connection string
|
||||
fn build_connection_string(&self) -> String {
|
||||
format!(
|
||||
"postgres://{}:{}@127.0.0.1:{}/{}",
|
||||
|
|
@ -457,16 +423,13 @@ unix_socket_directories = '{}'
|
|||
)
|
||||
}
|
||||
|
||||
/// Stop the PostgreSQL server
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
if let Some(ref mut child) = self.process {
|
||||
log::info!("Stopping PostgreSQL...");
|
||||
|
||||
// Try graceful shutdown first
|
||||
let pid = Pid::from_raw(child.id() as i32);
|
||||
let _ = kill(pid, Signal::SIGTERM);
|
||||
|
||||
// Wait for process to exit
|
||||
for _ in 0..50 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
|
|
@ -478,7 +441,6 @@ unix_socket_directories = '{}'
|
|||
}
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
let _ = kill(pid, Signal::SIGKILL);
|
||||
let _ = child.wait();
|
||||
self.process = None;
|
||||
|
|
@ -487,7 +449,6 @@ unix_socket_directories = '{}'
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up data directory
|
||||
pub fn cleanup(&self) -> Result<()> {
|
||||
if self.data_dir.exists() {
|
||||
std::fs::remove_dir_all(&self.data_dir)?;
|
||||
|
|
@ -502,10 +463,8 @@ impl Drop for PostgresService {
|
|||
let pid = Pid::from_raw(child.id() as i32);
|
||||
let _ = kill(pid, Signal::SIGTERM);
|
||||
|
||||
// Give it a moment to shut down gracefully
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
// Force kill if needed
|
||||
let _ = kill(pid, Signal::SIGKILL);
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
//! Redis service management for test infrastructure
|
||||
//!
|
||||
//! Starts and manages a Redis instance for caching and pub/sub testing.
|
||||
//! Provides connection management and common operations.
|
||||
|
||||
use super::{check_tcp_port, ensure_dir, wait_for, HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT};
|
||||
use anyhow::{Context, Result};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
|
|
@ -12,7 +7,6 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Redis service for test environments
|
||||
pub struct RedisService {
|
||||
port: u16,
|
||||
data_dir: PathBuf,
|
||||
|
|
@ -21,7 +15,6 @@ pub struct RedisService {
|
|||
}
|
||||
|
||||
impl RedisService {
|
||||
/// Start a new Redis instance on the specified port
|
||||
pub async fn start(port: u16, data_dir: &str) -> Result<Self> {
|
||||
let data_path = PathBuf::from(data_dir).join("redis");
|
||||
ensure_dir(&data_path)?;
|
||||
|
|
@ -39,7 +32,6 @@ impl RedisService {
|
|||
Ok(service)
|
||||
}
|
||||
|
||||
/// Start Redis with password authentication
|
||||
pub async fn start_with_password(port: u16, data_dir: &str, password: &str) -> Result<Self> {
|
||||
let data_path = PathBuf::from(data_dir).join("redis");
|
||||
ensure_dir(&data_path)?;
|
||||
|
|
@ -57,7 +49,7 @@ impl RedisService {
|
|||
Ok(service)
|
||||
}
|
||||
|
||||
/// Start the Redis server process
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn start_server(&mut self) -> Result<()> {
|
||||
log::info!("Starting Redis on port {}", self.port);
|
||||
|
||||
|
|
@ -72,12 +64,10 @@ impl RedisService {
|
|||
self.data_dir.to_str().unwrap().to_string(),
|
||||
"--daemonize".to_string(),
|
||||
"no".to_string(),
|
||||
// Disable persistence for faster testing
|
||||
"--save".to_string(),
|
||||
"".to_string(),
|
||||
String::new(),
|
||||
"--appendonly".to_string(),
|
||||
"no".to_string(),
|
||||
// Reduce memory usage
|
||||
"--maxmemory".to_string(),
|
||||
"64mb".to_string(),
|
||||
"--maxmemory-policy".to_string(),
|
||||
|
|
@ -100,7 +90,6 @@ impl RedisService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for Redis to be ready
|
||||
async fn wait_ready(&self) -> Result<()> {
|
||||
log::info!("Waiting for Redis to be ready...");
|
||||
|
||||
|
|
@ -110,7 +99,6 @@ impl RedisService {
|
|||
.await
|
||||
.context("Redis failed to start in time")?;
|
||||
|
||||
// Additional check using redis-cli PING
|
||||
if let Ok(redis_cli) = Self::find_cli_binary() {
|
||||
for _ in 0..30 {
|
||||
let mut cmd = Command::new(&redis_cli);
|
||||
|
|
@ -137,7 +125,7 @@ impl RedisService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a Redis command and return the result
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self, args: &[&str]) -> Result<String> {
|
||||
let redis_cli = Self::find_cli_binary()?;
|
||||
|
||||
|
|
@ -154,26 +142,23 @@ impl RedisService {
|
|||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Redis command failed: {}", stderr);
|
||||
anyhow::bail!("Redis command failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// Set a key-value pair
|
||||
pub async fn set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.execute(&["SET", key, value]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a key-value pair with expiration (seconds)
|
||||
pub async fn setex(&self, key: &str, seconds: u64, value: &str) -> Result<()> {
|
||||
self.execute(&["SETEX", key, &seconds.to_string(), value])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a value by key
|
||||
pub async fn get(&self, key: &str) -> Result<Option<String>> {
|
||||
let result = self.execute(&["GET", key]).await?;
|
||||
if result.is_empty() || result == "(nil)" {
|
||||
|
|
@ -183,58 +168,49 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Delete a key
|
||||
pub async fn del(&self, key: &str) -> Result<()> {
|
||||
self.execute(&["DEL", key]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a key exists
|
||||
pub async fn exists(&self, key: &str) -> Result<bool> {
|
||||
let result = self.execute(&["EXISTS", key]).await?;
|
||||
Ok(result == "1" || result == "(integer) 1")
|
||||
}
|
||||
|
||||
/// Get all keys matching a pattern
|
||||
pub async fn keys(&self, pattern: &str) -> Result<Vec<String>> {
|
||||
let result = self.execute(&["KEYS", pattern]).await?;
|
||||
if result.is_empty() || result == "(empty list or set)" {
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Ok(result.lines().map(|s| s.to_string()).collect())
|
||||
Ok(result
|
||||
.lines()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush all data
|
||||
pub async fn flushall(&self) -> Result<()> {
|
||||
self.execute(&["FLUSHALL"]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a message to a channel
|
||||
pub async fn publish(&self, channel: &str, message: &str) -> Result<i64> {
|
||||
let result = self.execute(&["PUBLISH", channel, message]).await?;
|
||||
// Parse "(integer) N" format
|
||||
let count = result
|
||||
.replace("(integer) ", "")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let count = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Push to a list (left)
|
||||
pub async fn lpush(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.execute(&["LPUSH", key, value]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push to a list (right)
|
||||
pub async fn rpush(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.execute(&["RPUSH", key, value]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pop from a list (left)
|
||||
pub async fn lpop(&self, key: &str) -> Result<Option<String>> {
|
||||
let result = self.execute(&["LPOP", key]).await?;
|
||||
if result.is_empty() || result == "(nil)" {
|
||||
|
|
@ -244,7 +220,6 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pop from a list (right)
|
||||
pub async fn rpop(&self, key: &str) -> Result<Option<String>> {
|
||||
let result = self.execute(&["RPOP", key]).await?;
|
||||
if result.is_empty() || result == "(nil)" {
|
||||
|
|
@ -254,23 +229,17 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get list length
|
||||
pub async fn llen(&self, key: &str) -> Result<i64> {
|
||||
let result = self.execute(&["LLEN", key]).await?;
|
||||
let len = result
|
||||
.replace("(integer) ", "")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let len = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
|
||||
Ok(len)
|
||||
}
|
||||
|
||||
/// Set hash field
|
||||
pub async fn hset(&self, key: &str, field: &str, value: &str) -> Result<()> {
|
||||
self.execute(&["HSET", key, field, value]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get hash field
|
||||
pub async fn hget(&self, key: &str, field: &str) -> Result<Option<String>> {
|
||||
let result = self.execute(&["HGET", key, field]).await?;
|
||||
if result.is_empty() || result == "(nil)" {
|
||||
|
|
@ -280,7 +249,6 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get all hash fields and values
|
||||
pub async fn hgetall(&self, key: &str) -> Result<Vec<(String, String)>> {
|
||||
let result = self.execute(&["HGETALL", key]).await?;
|
||||
if result.is_empty() || result == "(empty list or set)" {
|
||||
|
|
@ -299,27 +267,19 @@ impl RedisService {
|
|||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// Increment a value
|
||||
pub async fn incr(&self, key: &str) -> Result<i64> {
|
||||
let result = self.execute(&["INCR", key]).await?;
|
||||
let val = result
|
||||
.replace("(integer) ", "")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Decrement a value
|
||||
pub async fn decr(&self, key: &str) -> Result<i64> {
|
||||
let result = self.execute(&["DECR", key]).await?;
|
||||
let val = result
|
||||
.replace("(integer) ", "")
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let val = result.replace("(integer) ", "").parse::<i64>().unwrap_or(0);
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Get the connection string
|
||||
#[must_use]
|
||||
pub fn connection_string(&self) -> String {
|
||||
match &self.password {
|
||||
Some(pw) => format!("redis://:{}@127.0.0.1:{}", pw, self.port),
|
||||
|
|
@ -327,22 +287,21 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the connection URL (alias for connection_string)
|
||||
#[must_use]
|
||||
pub fn url(&self) -> String {
|
||||
self.connection_string()
|
||||
}
|
||||
|
||||
/// Get the port
|
||||
pub fn port(&self) -> u16 {
|
||||
#[must_use]
|
||||
pub const fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// Get host and port tuple
|
||||
pub fn host_port(&self) -> (&str, u16) {
|
||||
#[must_use]
|
||||
pub const fn host_port(&self) -> (&str, u16) {
|
||||
("127.0.0.1", self.port)
|
||||
}
|
||||
|
||||
/// Find the Redis server binary
|
||||
fn find_binary() -> Result<PathBuf> {
|
||||
let common_paths = [
|
||||
"/usr/bin/redis-server",
|
||||
|
|
@ -362,7 +321,6 @@ impl RedisService {
|
|||
.context("redis-server binary not found in PATH or common locations")
|
||||
}
|
||||
|
||||
/// Find the Redis CLI binary
|
||||
fn find_cli_binary() -> Result<PathBuf> {
|
||||
let common_paths = [
|
||||
"/usr/bin/redis-cli",
|
||||
|
|
@ -381,12 +339,10 @@ impl RedisService {
|
|||
which::which("redis-cli").context("redis-cli binary not found")
|
||||
}
|
||||
|
||||
/// Stop the Redis server
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
if let Some(ref mut child) = self.process {
|
||||
log::info!("Stopping Redis...");
|
||||
|
||||
// Try graceful shutdown via SHUTDOWN command first
|
||||
if let Ok(redis_cli) = Self::find_cli_binary() {
|
||||
let mut cmd = Command::new(&redis_cli);
|
||||
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]);
|
||||
|
|
@ -401,7 +357,6 @@ impl RedisService {
|
|||
let _ = cmd.output();
|
||||
}
|
||||
|
||||
// Wait for process to exit
|
||||
for _ in 0..30 {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
|
|
@ -413,7 +368,6 @@ impl RedisService {
|
|||
}
|
||||
}
|
||||
|
||||
// Force kill if still running
|
||||
let pid = Pid::from_raw(child.id() as i32);
|
||||
let _ = kill(pid, Signal::SIGTERM);
|
||||
|
||||
|
|
@ -436,7 +390,6 @@ impl RedisService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up data directory
|
||||
pub fn cleanup(&self) -> Result<()> {
|
||||
if self.data_dir.exists() {
|
||||
std::fs::remove_dir_all(&self.data_dir)?;
|
||||
|
|
@ -448,7 +401,6 @@ impl RedisService {
|
|||
impl Drop for RedisService {
|
||||
fn drop(&mut self) {
|
||||
if let Some(ref mut child) = self.process {
|
||||
// Try graceful shutdown
|
||||
if let Ok(redis_cli) = Self::find_cli_binary() {
|
||||
let mut cmd = Command::new(&redis_cli);
|
||||
cmd.args(["-h", "127.0.0.1", "-p", &self.port.to_string()]);
|
||||
|
|
@ -463,7 +415,6 @@ impl Drop for RedisService {
|
|||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
// Force kill if needed
|
||||
let pid = Pid::from_raw(child.id() as i32);
|
||||
let _ = kill(pid, Signal::SIGTERM);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
108
src/web/mod.rs
108
src/web/mod.rs
|
|
@ -1,8 +1,3 @@
|
|||
//! Web E2E testing module
|
||||
//!
|
||||
//! Provides tools for browser-based end-to-end testing using WebDriver
|
||||
//! (via fantoccini) to automate browser interactions with the chat interface.
|
||||
|
||||
pub mod browser;
|
||||
pub mod pages;
|
||||
|
||||
|
|
@ -11,24 +6,15 @@ pub use browser::{Browser, BrowserConfig, BrowserType};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for E2E tests
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct E2EConfig {
|
||||
/// Browser type to use
|
||||
pub browser: BrowserType,
|
||||
/// Whether to run headless
|
||||
pub headless: bool,
|
||||
/// Default timeout for operations
|
||||
pub timeout: Duration,
|
||||
/// Window width
|
||||
pub window_width: u32,
|
||||
/// Window height
|
||||
pub window_height: u32,
|
||||
/// WebDriver URL
|
||||
pub webdriver_url: String,
|
||||
/// Whether to capture screenshots on failure
|
||||
pub screenshot_on_failure: bool,
|
||||
/// Directory to save screenshots
|
||||
pub screenshot_dir: String,
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +34,7 @@ impl Default for E2EConfig {
|
|||
}
|
||||
|
||||
impl E2EConfig {
|
||||
/// Create a BrowserConfig from this E2EConfig
|
||||
#[must_use]
|
||||
pub fn to_browser_config(&self) -> BrowserConfig {
|
||||
BrowserConfig::default()
|
||||
.with_browser(self.browser)
|
||||
|
|
@ -59,7 +45,6 @@ impl E2EConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Result of an E2E test
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct E2ETestResult {
|
||||
pub name: String,
|
||||
|
|
@ -70,7 +55,6 @@ pub struct E2ETestResult {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// A step in an E2E test
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestStep {
|
||||
pub name: String,
|
||||
|
|
@ -79,72 +63,68 @@ pub struct TestStep {
|
|||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Element locator strategies
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Locator {
|
||||
/// CSS selector
|
||||
Css(String),
|
||||
/// XPath expression
|
||||
XPath(String),
|
||||
/// Element ID
|
||||
Id(String),
|
||||
/// Element name attribute
|
||||
Name(String),
|
||||
/// Link text
|
||||
LinkText(String),
|
||||
/// Partial link text
|
||||
PartialLinkText(String),
|
||||
/// Tag name
|
||||
TagName(String),
|
||||
/// Class name
|
||||
ClassName(String),
|
||||
}
|
||||
|
||||
impl Locator {
|
||||
#[must_use]
|
||||
pub fn css(selector: &str) -> Self {
|
||||
Self::Css(selector.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn xpath(expr: &str) -> Self {
|
||||
Self::XPath(expr.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn id(id: &str) -> Self {
|
||||
Self::Id(id.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(name: &str) -> Self {
|
||||
Self::Name(name.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn link_text(text: &str) -> Self {
|
||||
Self::LinkText(text.to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn class(name: &str) -> Self {
|
||||
Self::ClassName(name.to_string())
|
||||
}
|
||||
|
||||
/// Convert locator to CSS selector string for CDP
|
||||
|
||||
#[must_use]
|
||||
#[allow(clippy::match_same_arms)]
|
||||
pub fn to_css_selector(&self) -> String {
|
||||
match self {
|
||||
Locator::Css(s) => s.clone(),
|
||||
Locator::XPath(_) => {
|
||||
// XPath not directly supported in CSS - log warning and return generic
|
||||
Self::Css(s) => s.clone(),
|
||||
Self::XPath(_) => {
|
||||
log::warn!("XPath locators not directly supported in CDP, use CSS selectors");
|
||||
"*".to_string()
|
||||
}
|
||||
Locator::Id(s) => format!("#{}", s),
|
||||
Locator::Name(s) => format!("[name='{}']", s),
|
||||
Locator::LinkText(s) => format!("a:contains('{}')", s),
|
||||
Locator::PartialLinkText(s) => format!("a[href*='{}']", s),
|
||||
Locator::TagName(s) => s.clone(),
|
||||
Locator::ClassName(s) => format!(".{}", s),
|
||||
Self::Id(s) => format!("#{s}"),
|
||||
Self::Name(s) => format!("[name='{s}']"),
|
||||
Self::LinkText(s) => format!("a:contains('{s}')"),
|
||||
Self::PartialLinkText(s) => format!("a[href*='{s}']"),
|
||||
Self::TagName(s) => s.clone(),
|
||||
Self::ClassName(s) => format!(".{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard keys for special key presses
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Key {
|
||||
Enter,
|
||||
|
|
@ -178,7 +158,6 @@ pub enum Key {
|
|||
Meta,
|
||||
}
|
||||
|
||||
/// Mouse button
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
|
|
@ -186,33 +165,22 @@ pub enum MouseButton {
|
|||
Middle,
|
||||
}
|
||||
|
||||
/// Wait condition for elements
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WaitCondition {
|
||||
/// Element is present in DOM
|
||||
Present,
|
||||
/// Element is visible
|
||||
Visible,
|
||||
/// Element is clickable
|
||||
Clickable,
|
||||
/// Element is not present
|
||||
NotPresent,
|
||||
/// Element is not visible
|
||||
NotVisible,
|
||||
/// Element contains text
|
||||
ContainsText(String),
|
||||
/// Element has attribute value
|
||||
HasAttribute(String, String),
|
||||
/// Custom JavaScript condition
|
||||
Script(String),
|
||||
}
|
||||
|
||||
/// Action chain for complex interactions
|
||||
pub struct ActionChain {
|
||||
actions: Vec<Action>,
|
||||
}
|
||||
|
||||
/// Individual action in a chain
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
Click(Locator),
|
||||
|
|
@ -230,86 +198,86 @@ pub enum Action {
|
|||
}
|
||||
|
||||
impl ActionChain {
|
||||
/// Create a new action chain
|
||||
pub fn new() -> Self {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a click action
|
||||
#[must_use]
|
||||
pub fn click(mut self, locator: Locator) -> Self {
|
||||
self.actions.push(Action::Click(locator));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a double click action
|
||||
#[must_use]
|
||||
pub fn double_click(mut self, locator: Locator) -> Self {
|
||||
self.actions.push(Action::DoubleClick(locator));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a right click action
|
||||
#[must_use]
|
||||
pub fn right_click(mut self, locator: Locator) -> Self {
|
||||
self.actions.push(Action::RightClick(locator));
|
||||
self
|
||||
}
|
||||
|
||||
/// Move to an element
|
||||
#[must_use]
|
||||
pub fn move_to(mut self, locator: Locator) -> Self {
|
||||
self.actions.push(Action::MoveTo(locator));
|
||||
self
|
||||
}
|
||||
|
||||
/// Move by offset
|
||||
#[must_use]
|
||||
pub fn move_by(mut self, x: i32, y: i32) -> Self {
|
||||
self.actions.push(Action::MoveByOffset(x, y));
|
||||
self
|
||||
}
|
||||
|
||||
/// Press a key down
|
||||
#[must_use]
|
||||
pub fn key_down(mut self, key: Key) -> Self {
|
||||
self.actions.push(Action::KeyDown(key));
|
||||
self
|
||||
}
|
||||
|
||||
/// Release a key
|
||||
#[must_use]
|
||||
pub fn key_up(mut self, key: Key) -> Self {
|
||||
self.actions.push(Action::KeyUp(key));
|
||||
self
|
||||
}
|
||||
|
||||
/// Send keys (type text)
|
||||
#[must_use]
|
||||
pub fn send_keys(mut self, text: &str) -> Self {
|
||||
self.actions.push(Action::SendKeys(text.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Pause for a duration
|
||||
#[must_use]
|
||||
pub fn pause(mut self, duration: Duration) -> Self {
|
||||
self.actions.push(Action::Pause(duration));
|
||||
self
|
||||
}
|
||||
|
||||
/// Drag and drop
|
||||
#[must_use]
|
||||
pub fn drag_and_drop(mut self, source: Locator, target: Locator) -> Self {
|
||||
self.actions.push(Action::DragAndDrop(source, target));
|
||||
self
|
||||
}
|
||||
|
||||
/// Scroll to element
|
||||
#[must_use]
|
||||
pub fn scroll_to(mut self, locator: Locator) -> Self {
|
||||
self.actions.push(Action::ScrollTo(locator));
|
||||
self
|
||||
}
|
||||
|
||||
/// Scroll by amount
|
||||
#[must_use]
|
||||
pub fn scroll_by(mut self, x: i32, y: i32) -> Self {
|
||||
self.actions.push(Action::ScrollByAmount(x, y));
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the actions
|
||||
#[must_use]
|
||||
pub fn actions(&self) -> &[Action] {
|
||||
&self.actions
|
||||
}
|
||||
|
|
@ -321,7 +289,6 @@ impl Default for ActionChain {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cookie data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
|
|
@ -335,6 +302,7 @@ pub struct Cookie {
|
|||
}
|
||||
|
||||
impl Cookie {
|
||||
#[must_use]
|
||||
pub fn new(name: &str, value: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
|
|
@ -348,22 +316,26 @@ impl Cookie {
|
|||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_domain(mut self, domain: &str) -> Self {
|
||||
self.domain = Some(domain.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_path(mut self, path: &str) -> Self {
|
||||
self.path = Some(path.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secure(mut self) -> Self {
|
||||
#[must_use]
|
||||
pub const fn secure(mut self) -> Self {
|
||||
self.secure = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn http_only(mut self) -> Self {
|
||||
#[must_use]
|
||||
pub const fn http_only(mut self) -> Self {
|
||||
self.http_only = Some(true);
|
||||
self
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
//! Page Object Pattern implementations for E2E testing
|
||||
//!
|
||||
//! Provides structured page objects for interacting with botserver's web interface.
|
||||
//! Each page object encapsulates the locators and actions for a specific page.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
|
@ -9,99 +5,83 @@ use std::time::Duration;
|
|||
use super::browser::{Browser, Element};
|
||||
use super::Locator;
|
||||
|
||||
/// Base trait for all page objects
|
||||
#[async_trait::async_trait]
|
||||
pub trait Page {
|
||||
/// Get the expected URL pattern for this page
|
||||
fn url_pattern(&self) -> &str;
|
||||
|
||||
/// Check if we're on this page
|
||||
async fn is_current(&self, browser: &Browser) -> Result<bool> {
|
||||
let url = browser.current_url().await?;
|
||||
Ok(url.contains(self.url_pattern()))
|
||||
}
|
||||
|
||||
/// Wait for the page to be fully loaded
|
||||
async fn wait_for_load(&self, browser: &Browser) -> Result<()>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Login Page
|
||||
// =============================================================================
|
||||
|
||||
/// Login page object
|
||||
pub struct LoginPage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl LoginPage {
|
||||
/// Create a new login page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the login page
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser.goto(&format!("{}/login", self.base_url)).await
|
||||
}
|
||||
|
||||
/// Email input locator
|
||||
#[must_use]
|
||||
pub fn email_input() -> Locator {
|
||||
Locator::css("#email, input[name='email'], input[type='email']")
|
||||
}
|
||||
|
||||
/// Password input locator
|
||||
#[must_use]
|
||||
pub fn password_input() -> Locator {
|
||||
Locator::css("#password, input[name='password'], input[type='password']")
|
||||
}
|
||||
|
||||
/// Login button locator
|
||||
#[must_use]
|
||||
pub fn login_button() -> Locator {
|
||||
Locator::css(
|
||||
"#login-button, button[type='submit'], input[type='submit'], .login-btn, .btn-login",
|
||||
)
|
||||
}
|
||||
|
||||
/// Error message locator
|
||||
#[must_use]
|
||||
pub fn error_message() -> Locator {
|
||||
Locator::css(".error, .error-message, .alert-error, .alert-danger, [role='alert']")
|
||||
}
|
||||
|
||||
/// Enter email
|
||||
pub async fn enter_email(&self, browser: &Browser, email: &str) -> Result<()> {
|
||||
browser.fill(Self::email_input(), email).await
|
||||
}
|
||||
|
||||
/// Enter password
|
||||
pub async fn enter_password(&self, browser: &Browser, password: &str) -> Result<()> {
|
||||
browser.fill(Self::password_input(), password).await
|
||||
}
|
||||
|
||||
/// Click login button
|
||||
pub async fn click_login(&self, browser: &Browser) -> Result<()> {
|
||||
browser.click(Self::login_button()).await
|
||||
}
|
||||
|
||||
/// Perform full login
|
||||
pub async fn login(&self, browser: &Browser, email: &str, password: &str) -> Result<()> {
|
||||
self.navigate(browser).await?;
|
||||
self.wait_for_load(browser).await?;
|
||||
self.enter_email(browser, email).await?;
|
||||
self.enter_password(browser, password).await?;
|
||||
self.click_login(browser).await?;
|
||||
// Wait for navigation
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if error message is displayed
|
||||
pub async fn has_error(&self, browser: &Browser) -> bool {
|
||||
browser.exists(Self::error_message()).await
|
||||
}
|
||||
|
||||
/// Get error message text
|
||||
pub async fn get_error_message(&self, browser: &Browser) -> Result<String> {
|
||||
browser.text(Self::error_message()).await
|
||||
}
|
||||
|
|
@ -109,7 +89,7 @@ impl LoginPage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for LoginPage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/login"
|
||||
}
|
||||
|
||||
|
|
@ -120,65 +100,55 @@ impl Page for LoginPage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dashboard Page
|
||||
// =============================================================================
|
||||
|
||||
/// Dashboard home page object
|
||||
pub struct DashboardPage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl DashboardPage {
|
||||
/// Create a new dashboard page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the dashboard
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser.goto(&format!("{}/dashboard", self.base_url)).await
|
||||
}
|
||||
|
||||
/// Stats cards container locator
|
||||
/// Stats cards locator
|
||||
#[must_use]
|
||||
pub fn stats_cards() -> Locator {
|
||||
Locator::css(".stats-card, .dashboard-stat, .metric-card")
|
||||
}
|
||||
|
||||
/// Navigation menu locator
|
||||
#[must_use]
|
||||
pub fn nav_menu() -> Locator {
|
||||
Locator::css("nav, .nav, .sidebar, .navigation")
|
||||
}
|
||||
|
||||
/// User profile button locator
|
||||
#[must_use]
|
||||
pub fn user_profile() -> Locator {
|
||||
Locator::css(".user-profile, .user-menu, .profile-dropdown, .avatar")
|
||||
}
|
||||
|
||||
/// Logout button locator
|
||||
#[must_use]
|
||||
pub fn logout_button() -> Locator {
|
||||
Locator::css(".logout, .logout-btn, #logout, a[href*='logout'], button:contains('Logout')")
|
||||
}
|
||||
|
||||
/// Get navigation menu items
|
||||
pub async fn get_nav_items(&self, browser: &Browser) -> Result<Vec<Element>> {
|
||||
browser
|
||||
.find_all(Locator::css("nav a, .nav-item, .menu-item"))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Click a navigation item by text
|
||||
pub async fn navigate_to(&self, browser: &Browser, menu_text: &str) -> Result<()> {
|
||||
let locator = Locator::xpath(&format!("//nav//a[contains(text(), '{}')]", menu_text));
|
||||
let locator = Locator::xpath(&format!("//nav//a[contains(text(), '{menu_text}')]"));
|
||||
browser.click(locator).await
|
||||
}
|
||||
|
||||
/// Click logout
|
||||
pub async fn logout(&self, browser: &Browser) -> Result<()> {
|
||||
// First try to open user menu if needed
|
||||
if browser.exists(Self::user_profile()).await {
|
||||
let _ = browser.click(Self::user_profile()).await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
|
@ -189,7 +159,7 @@ impl DashboardPage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for DashboardPage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/dashboard"
|
||||
}
|
||||
|
||||
|
|
@ -199,18 +169,14 @@ impl Page for DashboardPage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Chat Page
|
||||
// =============================================================================
|
||||
|
||||
/// Chat interface page object
|
||||
pub struct ChatPage {
|
||||
pub base_url: String,
|
||||
pub bot_name: String,
|
||||
}
|
||||
|
||||
impl ChatPage {
|
||||
/// Create a new chat page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str, bot_name: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
|
|
@ -218,68 +184,63 @@ impl ChatPage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Navigate to the chat page
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser
|
||||
.goto(&format!("{}/chat/{}", self.base_url, self.bot_name))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Chat input locator
|
||||
/// Chat input field locator
|
||||
#[must_use]
|
||||
pub fn chat_input() -> Locator {
|
||||
Locator::css(
|
||||
"#chat-input, .chat-input, input[name='message'], textarea[name='message'], .message-input",
|
||||
)
|
||||
}
|
||||
|
||||
/// Send button locator
|
||||
#[must_use]
|
||||
pub fn send_button() -> Locator {
|
||||
Locator::css("#send, .send-btn, button[type='submit'], .send-message")
|
||||
}
|
||||
|
||||
/// Message list container locator
|
||||
#[must_use]
|
||||
pub fn message_list() -> Locator {
|
||||
Locator::css(".messages, .message-list, .chat-messages, #messages")
|
||||
}
|
||||
|
||||
/// Bot message locator
|
||||
#[must_use]
|
||||
pub fn bot_message() -> Locator {
|
||||
Locator::css(".bot-message, .message-bot, .assistant-message, [data-role='bot']")
|
||||
}
|
||||
|
||||
/// User message locator
|
||||
#[must_use]
|
||||
pub fn user_message() -> Locator {
|
||||
Locator::css(".user-message, .message-user, [data-role='user']")
|
||||
}
|
||||
|
||||
/// Typing indicator locator
|
||||
#[must_use]
|
||||
pub fn typing_indicator() -> Locator {
|
||||
Locator::css(".typing, .typing-indicator, .is-typing, [data-typing]")
|
||||
}
|
||||
|
||||
/// File upload button locator
|
||||
#[must_use]
|
||||
pub fn file_upload_button() -> Locator {
|
||||
Locator::css(".upload-btn, .file-upload, input[type='file'], .attach-file")
|
||||
}
|
||||
|
||||
/// Quick reply buttons locator
|
||||
#[must_use]
|
||||
pub fn quick_reply_buttons() -> Locator {
|
||||
Locator::css(".quick-replies, .quick-reply, .suggested-reply")
|
||||
}
|
||||
|
||||
/// Send a message
|
||||
pub async fn send_message(&self, browser: &Browser, message: &str) -> Result<()> {
|
||||
browser.fill(Self::chat_input(), message).await?;
|
||||
browser.click(Self::send_button()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for bot response
|
||||
pub async fn wait_for_response(&self, browser: &Browser, timeout: Duration) -> Result<()> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// First wait for typing indicator to appear
|
||||
while start.elapsed() < timeout {
|
||||
if browser.exists(Self::typing_indicator()).await {
|
||||
break;
|
||||
|
|
@ -287,7 +248,6 @@ impl ChatPage {
|
|||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// Then wait for typing indicator to disappear
|
||||
while start.elapsed() < timeout {
|
||||
if !browser.exists(Self::typing_indicator()).await {
|
||||
return Ok(());
|
||||
|
|
@ -298,7 +258,6 @@ impl ChatPage {
|
|||
anyhow::bail!("Timeout waiting for bot response")
|
||||
}
|
||||
|
||||
/// Get all bot messages
|
||||
pub async fn get_bot_messages(&self, browser: &Browser) -> Result<Vec<String>> {
|
||||
let elements = browser.find_all(Self::bot_message()).await?;
|
||||
let mut messages = Vec::new();
|
||||
|
|
@ -310,7 +269,6 @@ impl ChatPage {
|
|||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Get all user messages
|
||||
pub async fn get_user_messages(&self, browser: &Browser) -> Result<Vec<String>> {
|
||||
let elements = browser.find_all(Self::user_message()).await?;
|
||||
let mut messages = Vec::new();
|
||||
|
|
@ -322,7 +280,6 @@ impl ChatPage {
|
|||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Get the last bot message
|
||||
pub async fn get_last_bot_message(&self, browser: &Browser) -> Result<String> {
|
||||
let messages = self.get_bot_messages(browser).await?;
|
||||
messages
|
||||
|
|
@ -331,16 +288,13 @@ impl ChatPage {
|
|||
.ok_or_else(|| anyhow::anyhow!("No bot messages found"))
|
||||
}
|
||||
|
||||
/// Check if typing indicator is visible
|
||||
pub async fn is_typing(&self, browser: &Browser) -> bool {
|
||||
browser.exists(Self::typing_indicator()).await
|
||||
}
|
||||
|
||||
/// Click a quick reply button by text
|
||||
pub async fn click_quick_reply(&self, browser: &Browser, text: &str) -> Result<()> {
|
||||
let locator = Locator::xpath(&format!(
|
||||
"//*[contains(@class, 'quick-reply') and contains(text(), '{}')]",
|
||||
text
|
||||
"//*[contains(@class, 'quick-reply') and contains(text(), '{text}')]"
|
||||
));
|
||||
browser.click(locator).await
|
||||
}
|
||||
|
|
@ -348,7 +302,7 @@ impl ChatPage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for ChatPage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/chat/"
|
||||
}
|
||||
|
||||
|
|
@ -359,61 +313,53 @@ impl Page for ChatPage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Queue Panel Page
|
||||
// =============================================================================
|
||||
|
||||
/// Queue management panel page object
|
||||
pub struct QueuePage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl QueuePage {
|
||||
/// Create a new queue page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the queue panel
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser.goto(&format!("{}/queue", self.base_url)).await
|
||||
}
|
||||
|
||||
/// Queue panel container locator
|
||||
#[must_use]
|
||||
pub fn queue_panel() -> Locator {
|
||||
Locator::css(".queue-panel, .queue-container, #queue-panel")
|
||||
}
|
||||
|
||||
/// Queue count display locator
|
||||
#[must_use]
|
||||
pub fn queue_count() -> Locator {
|
||||
Locator::css(".queue-count, .waiting-count, #queue-count")
|
||||
}
|
||||
|
||||
/// Queue entry locator
|
||||
#[must_use]
|
||||
pub fn queue_entry() -> Locator {
|
||||
Locator::css(".queue-entry, .queue-item, .waiting-customer")
|
||||
}
|
||||
|
||||
/// Take next button locator
|
||||
#[must_use]
|
||||
pub fn take_next_button() -> Locator {
|
||||
Locator::css(".take-next, #take-next, button:contains('Take Next')")
|
||||
}
|
||||
|
||||
/// Get queue count
|
||||
pub async fn get_queue_count(&self, browser: &Browser) -> Result<u32> {
|
||||
let text = browser.text(Self::queue_count()).await?;
|
||||
text.parse::<u32>()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to parse queue count: {}", text))
|
||||
.map_err(|_| anyhow::anyhow!("Failed to parse queue count: {text}"))
|
||||
}
|
||||
|
||||
/// Get all queue entries
|
||||
pub async fn get_queue_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
|
||||
browser.find_all(Self::queue_entry()).await
|
||||
}
|
||||
|
||||
/// Click take next button
|
||||
pub async fn take_next(&self, browser: &Browser) -> Result<()> {
|
||||
browser.click(Self::take_next_button()).await
|
||||
}
|
||||
|
|
@ -421,7 +367,7 @@ impl QueuePage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for QueuePage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/queue"
|
||||
}
|
||||
|
||||
|
|
@ -431,69 +377,61 @@ impl Page for QueuePage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bot Management Page
|
||||
// =============================================================================
|
||||
|
||||
/// Bot management page object
|
||||
pub struct BotManagementPage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl BotManagementPage {
|
||||
/// Create a new bot management page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to bot management
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser.goto(&format!("{}/admin/bots", self.base_url)).await
|
||||
}
|
||||
|
||||
/// Bot list container locator
|
||||
#[must_use]
|
||||
pub fn bot_list() -> Locator {
|
||||
Locator::css(".bot-list, .bots-container, #bots")
|
||||
}
|
||||
|
||||
/// Bot item locator
|
||||
#[must_use]
|
||||
pub fn bot_item() -> Locator {
|
||||
Locator::css(".bot-item, .bot-card, .bot-entry")
|
||||
}
|
||||
|
||||
/// Create bot button locator
|
||||
#[must_use]
|
||||
pub fn create_bot_button() -> Locator {
|
||||
Locator::css(".create-bot, .new-bot, #create-bot, button:contains('Create')")
|
||||
}
|
||||
|
||||
/// Bot name input locator
|
||||
#[must_use]
|
||||
pub fn bot_name_input() -> Locator {
|
||||
Locator::css("#bot-name, input[name='name'], .bot-name-input")
|
||||
}
|
||||
|
||||
/// Bot description input locator
|
||||
#[must_use]
|
||||
pub fn bot_description_input() -> Locator {
|
||||
Locator::css("#bot-description, textarea[name='description'], .bot-description-input")
|
||||
}
|
||||
|
||||
/// Save button locator
|
||||
#[must_use]
|
||||
pub fn save_button() -> Locator {
|
||||
Locator::css(".save-btn, button[type='submit'], #save, button:contains('Save')")
|
||||
}
|
||||
|
||||
/// Get all bots
|
||||
pub async fn get_bots(&self, browser: &Browser) -> Result<Vec<Element>> {
|
||||
browser.find_all(Self::bot_item()).await
|
||||
}
|
||||
|
||||
/// Click create bot button
|
||||
pub async fn click_create_bot(&self, browser: &Browser) -> Result<()> {
|
||||
browser.click(Self::create_bot_button()).await
|
||||
}
|
||||
|
||||
/// Create a new bot
|
||||
pub async fn create_bot(&self, browser: &Browser, name: &str, description: &str) -> Result<()> {
|
||||
self.click_create_bot(browser).await?;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
|
@ -505,12 +443,9 @@ impl BotManagementPage {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Click edit on a bot by name
|
||||
/// Edit a bot by name
|
||||
pub async fn edit_bot(&self, browser: &Browser, bot_name: &str) -> Result<()> {
|
||||
let locator = Locator::xpath(&format!(
|
||||
"//*[contains(@class, 'bot-item') and contains(., '{}')]//button[contains(@class, 'edit')]",
|
||||
bot_name
|
||||
"//*[contains(@class, 'bot-item') and contains(., '{bot_name}')]//button[contains(@class, 'edit')]"
|
||||
));
|
||||
browser.click(locator).await
|
||||
}
|
||||
|
|
@ -518,7 +453,7 @@ impl BotManagementPage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for BotManagementPage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/admin/bots"
|
||||
}
|
||||
|
||||
|
|
@ -528,60 +463,52 @@ impl Page for BotManagementPage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Knowledge Base Page
|
||||
// =============================================================================
|
||||
|
||||
/// Knowledge base management page object
|
||||
pub struct KnowledgeBasePage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl KnowledgeBasePage {
|
||||
/// Create a new knowledge base page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to knowledge base
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser.goto(&format!("{}/admin/kb", self.base_url)).await
|
||||
}
|
||||
|
||||
/// KB entries list locator
|
||||
/// KB list container locator
|
||||
#[must_use]
|
||||
pub fn kb_list() -> Locator {
|
||||
Locator::css(".kb-list, .knowledge-base-list, #kb-list")
|
||||
}
|
||||
|
||||
/// KB entry locator
|
||||
#[must_use]
|
||||
pub fn kb_entry() -> Locator {
|
||||
Locator::css(".kb-entry, .kb-item, .knowledge-entry")
|
||||
}
|
||||
|
||||
/// Upload button locator
|
||||
#[must_use]
|
||||
pub fn upload_button() -> Locator {
|
||||
Locator::css(".upload-btn, #upload, button:contains('Upload')")
|
||||
}
|
||||
|
||||
/// File input locator
|
||||
#[must_use]
|
||||
pub fn file_input() -> Locator {
|
||||
Locator::css("input[type='file']")
|
||||
}
|
||||
|
||||
/// Search input locator
|
||||
#[must_use]
|
||||
pub fn search_input() -> Locator {
|
||||
Locator::css(".search-input, #search, input[placeholder*='search']")
|
||||
}
|
||||
|
||||
/// Get all KB entries
|
||||
pub async fn get_entries(&self, browser: &Browser) -> Result<Vec<Element>> {
|
||||
browser.find_all(Self::kb_entry()).await
|
||||
}
|
||||
|
||||
/// Search the knowledge base
|
||||
pub async fn search(&self, browser: &Browser, query: &str) -> Result<()> {
|
||||
browser.fill(Self::search_input(), query).await
|
||||
}
|
||||
|
|
@ -589,7 +516,7 @@ impl KnowledgeBasePage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for KnowledgeBasePage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/admin/kb"
|
||||
}
|
||||
|
||||
|
|
@ -599,46 +526,40 @@ impl Page for KnowledgeBasePage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Analytics Page
|
||||
// =============================================================================
|
||||
|
||||
/// Analytics dashboard page object
|
||||
pub struct AnalyticsPage {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl AnalyticsPage {
|
||||
/// Create a new analytics page object
|
||||
#[must_use]
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to analytics
|
||||
pub async fn navigate(&self, browser: &Browser) -> Result<()> {
|
||||
browser
|
||||
.goto(&format!("{}/admin/analytics", self.base_url))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Charts container locator
|
||||
#[must_use]
|
||||
pub fn charts_container() -> Locator {
|
||||
Locator::css(".charts, .analytics-charts, #charts")
|
||||
}
|
||||
|
||||
/// Date range picker locator
|
||||
#[must_use]
|
||||
pub fn date_range_picker() -> Locator {
|
||||
Locator::css(".date-range, .date-picker, #date-range")
|
||||
}
|
||||
|
||||
/// Metric card locator
|
||||
#[must_use]
|
||||
pub fn metric_card() -> Locator {
|
||||
Locator::css(".metric-card, .analytics-metric, .stat-card")
|
||||
}
|
||||
|
||||
/// Get all metric cards
|
||||
pub async fn get_metrics(&self, browser: &Browser) -> Result<Vec<Element>> {
|
||||
browser.find_all(Self::metric_card()).await
|
||||
}
|
||||
|
|
@ -646,7 +567,7 @@ impl AnalyticsPage {
|
|||
|
||||
#[async_trait::async_trait]
|
||||
impl Page for AnalyticsPage {
|
||||
fn url_pattern(&self) -> &str {
|
||||
fn url_pattern(&self) -> &'static str {
|
||||
"/admin/analytics"
|
||||
}
|
||||
|
||||
|
|
@ -656,9 +577,6 @@ impl Page for AnalyticsPage {
|
|||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -156,11 +156,9 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
|
|||
|
||||
for selector in &logout_selectors {
|
||||
let locator = Locator::css(selector);
|
||||
if browser.exists(locator.clone()).await {
|
||||
if browser.click(locator).await.is_ok() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
break;
|
||||
}
|
||||
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,20 +169,19 @@ async fn perform_logout(browser: &Browser, base_url: &str) -> Result<bool, Strin
|
|||
|
||||
for selector in &logout_selectors {
|
||||
let locator = Locator::css(selector);
|
||||
if browser.exists(locator.clone()).await {
|
||||
if browser.click(locator).await.is_ok() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
break;
|
||||
}
|
||||
if browser.exists(locator.clone()).await && browser.click(locator).await.is_ok() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current_url = browser.current_url().await.unwrap_or_default();
|
||||
let base_url_with_slash = format!("{base_url}/");
|
||||
let logged_out = current_url.contains("/login")
|
||||
|| current_url.contains("/logout")
|
||||
|| current_url == format!("{}/", base_url)
|
||||
|| current_url == base_url.to_string();
|
||||
|| current_url == base_url_with_slash
|
||||
|| current_url == base_url;
|
||||
|
||||
if logged_out {
|
||||
return Ok(true);
|
||||
|
|
@ -442,10 +439,10 @@ async fn test_session_persistence() {
|
|||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let current_url = browser.current_url().await.unwrap_or_default();
|
||||
|
||||
if !current_url.contains("/login") {
|
||||
println!("✓ Session persisted after page refresh");
|
||||
} else {
|
||||
if current_url.contains("/login") {
|
||||
eprintln!("✗ Session lost after refresh");
|
||||
} else {
|
||||
println!("✓ Session persisted after page refresh");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -454,10 +451,10 @@ async fn test_session_persistence() {
|
|||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let current_url = browser.current_url().await.unwrap_or_default();
|
||||
|
||||
if !current_url.contains("/login") {
|
||||
println!("✓ Session maintained across navigation");
|
||||
} else {
|
||||
if current_url.contains("/login") {
|
||||
eprintln!("✗ Session lost during navigation");
|
||||
} else {
|
||||
println!("✓ Session maintained across navigation");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,105 +1,85 @@
|
|||
use super::{should_run_e2e_tests, E2ETestContext};
|
||||
use anyhow::{bail, Result};
|
||||
use bottest::prelude::*;
|
||||
use bottest::web::Locator;
|
||||
|
||||
/// Simple "hi" chat test with real botserver
|
||||
#[tokio::test]
|
||||
async fn test_chat_hi() {
|
||||
async fn test_chat_hi() -> Result<()> {
|
||||
if !should_run_e2e_tests() {
|
||||
eprintln!("Skipping: E2E tests disabled");
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ctx = match E2ETestContext::setup_with_browser().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
eprintln!("Test failed: {}", e);
|
||||
panic!("Failed to setup E2E context: {}", e);
|
||||
}
|
||||
};
|
||||
let ctx = E2ETestContext::setup_with_browser().await?;
|
||||
|
||||
if !ctx.has_browser() {
|
||||
ctx.close().await;
|
||||
panic!("Browser not available - cannot run E2E test");
|
||||
bail!("Browser not available - cannot run E2E test");
|
||||
}
|
||||
|
||||
// Chat UI requires botui
|
||||
if ctx.ui.is_none() {
|
||||
ctx.close().await;
|
||||
panic!("BotUI not available - chat tests require botui running on port 3000");
|
||||
bail!("BotUI not available - chat tests require botui running on port 3000");
|
||||
}
|
||||
|
||||
let browser = ctx.browser.as_ref().unwrap();
|
||||
// Use botui URL for chat (botserver is API only)
|
||||
let ui_url = ctx.ui.as_ref().unwrap().url.clone();
|
||||
let chat_url = format!("{}/#chat", ui_url);
|
||||
|
||||
println!("🌐 Navigating to: {}", chat_url);
|
||||
println!("🌐 Navigating to: {chat_url}");
|
||||
|
||||
if let Err(e) = browser.goto(&chat_url).await {
|
||||
ctx.close().await;
|
||||
panic!("Failed to navigate to chat: {}", e);
|
||||
bail!("Failed to navigate to chat: {e}");
|
||||
}
|
||||
|
||||
// Wait for page to load and HTMX to initialize chat content
|
||||
println!("⏳ Waiting for page to load...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
// Chat input: botui uses #messageInput or #ai-input
|
||||
let input = Locator::css("#messageInput, #ai-input, .ai-input");
|
||||
|
||||
// Try to find input with retries (HTMX loads content dynamically)
|
||||
let mut found_input = false;
|
||||
for attempt in 1..=10 {
|
||||
if browser.exists(input.clone()).await {
|
||||
found_input = true;
|
||||
println!("✓ Chat input found (attempt {})", attempt);
|
||||
println!("✓ Chat input found (attempt {attempt})");
|
||||
break;
|
||||
}
|
||||
println!(" ... waiting for chat input (attempt {}/10)", attempt);
|
||||
println!(" ... waiting for chat input (attempt {attempt}/10)");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
if !found_input {
|
||||
// Take screenshot on failure
|
||||
if let Ok(screenshot) = browser.screenshot().await {
|
||||
let _ = std::fs::write("/tmp/bottest-chat-fail.png", &screenshot);
|
||||
println!("Screenshot saved to /tmp/bottest-chat-fail.png");
|
||||
}
|
||||
// Also print page source for debugging
|
||||
if let Ok(source) = browser.page_source().await {
|
||||
let preview: String = source.chars().take(2000).collect();
|
||||
println!("Page source preview:\n{}", preview);
|
||||
println!("Page source preview:\n{preview}");
|
||||
}
|
||||
ctx.close().await;
|
||||
panic!("Chat input not found after 10 attempts");
|
||||
bail!("Chat input not found after 10 attempts");
|
||||
}
|
||||
|
||||
// Type "hi"
|
||||
println!("⌨️ Typing 'hi'...");
|
||||
if let Err(e) = browser.type_text(input.clone(), "hi").await {
|
||||
ctx.close().await;
|
||||
panic!("Failed to type: {}", e);
|
||||
bail!("Failed to type: {e}");
|
||||
}
|
||||
|
||||
// Click send button or press Enter
|
||||
let send_btn = Locator::css("#sendBtn, #ai-send, .ai-send, button[type='submit']");
|
||||
match browser.click(send_btn).await {
|
||||
Ok(_) => println!("✓ Message sent (click)"),
|
||||
Err(_) => {
|
||||
// Try Enter key instead
|
||||
match browser.press_key(input, "Enter").await {
|
||||
Ok(_) => println!("✓ Message sent (Enter key)"),
|
||||
Err(e) => println!("⚠ Send may have failed: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(()) => println!("✓ Message sent (click)"),
|
||||
Err(_) => match browser.press_key(input, "Enter").await {
|
||||
Ok(()) => println!("✓ Message sent (Enter key)"),
|
||||
Err(e) => println!("⚠ Send may have failed: {e}"),
|
||||
},
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
println!("⏳ Waiting for bot response...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
// Check for response - botui uses .message.bot or .assistant class
|
||||
let response =
|
||||
Locator::css(".message.bot, .message.assistant, .bot-message, .assistant-message");
|
||||
match browser.find_elements(response).await {
|
||||
|
|
@ -111,7 +91,6 @@ async fn test_chat_hi() {
|
|||
}
|
||||
}
|
||||
|
||||
// Take final screenshot
|
||||
if let Ok(screenshot) = browser.screenshot().await {
|
||||
let _ = std::fs::write("/tmp/bottest-chat-result.png", &screenshot);
|
||||
println!("📸 Screenshot: /tmp/bottest-chat-result.png");
|
||||
|
|
@ -119,40 +98,34 @@ async fn test_chat_hi() {
|
|||
|
||||
ctx.close().await;
|
||||
println!("✅ Chat test complete!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chat_page_loads() {
|
||||
async fn test_chat_page_loads() -> Result<()> {
|
||||
if !should_run_e2e_tests() {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ctx = match E2ETestContext::setup_with_browser().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
panic!("Setup failed: {}", e);
|
||||
}
|
||||
};
|
||||
let ctx = E2ETestContext::setup_with_browser().await?;
|
||||
|
||||
if !ctx.has_browser() {
|
||||
ctx.close().await;
|
||||
panic!("Browser not available");
|
||||
bail!("Browser not available");
|
||||
}
|
||||
|
||||
// Chat UI requires botui
|
||||
if ctx.ui.is_none() {
|
||||
ctx.close().await;
|
||||
panic!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
|
||||
bail!("BotUI not available - chat tests require botui. Start it with: cd ../botui && cargo run");
|
||||
}
|
||||
|
||||
let browser = ctx.browser.as_ref().unwrap();
|
||||
// Use botui URL for chat (botserver is API only)
|
||||
let ui_url = ctx.ui.as_ref().unwrap().url.clone();
|
||||
let chat_url = format!("{}/#chat", ui_url);
|
||||
|
||||
if let Err(e) = browser.goto(&chat_url).await {
|
||||
ctx.close().await;
|
||||
panic!("Navigation failed: {}", e);
|
||||
bail!("Navigation failed: {e}");
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
|
@ -165,9 +138,10 @@ async fn test_chat_page_loads() {
|
|||
let _ = std::fs::write("/tmp/bottest-fail.png", &s);
|
||||
}
|
||||
ctx.close().await;
|
||||
panic!("Chat not loaded: {}", e);
|
||||
bail!("Chat not loaded: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
ctx.close().await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -759,8 +759,6 @@ async fn test_with_fixtures() {
|
|||
return;
|
||||
}
|
||||
|
||||
// This test inserts fixtures into DB - requires direct DB connection
|
||||
// When using existing stack, we connect to the existing database
|
||||
let ctx = match E2ETestContext::setup().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
|
|
@ -773,10 +771,12 @@ async fn test_with_fixtures() {
|
|||
let bot = bot_with_kb("e2e-test-bot");
|
||||
let customer = customer("+15551234567");
|
||||
|
||||
// Try to insert - may fail if DB schema doesn't match or DB not accessible
|
||||
match ctx.ctx.insert_user(&user).await {
|
||||
Ok(_) => println!("Inserted test user: {}", user.email),
|
||||
Err(e) => eprintln!("Could not insert user (DB may not be directly accessible): {}", e),
|
||||
Err(e) => eprintln!(
|
||||
"Could not insert user (DB may not be directly accessible): {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
match ctx.ctx.insert_bot(&bot).await {
|
||||
|
|
@ -799,9 +799,6 @@ async fn test_mock_services_available() {
|
|||
return;
|
||||
}
|
||||
|
||||
// This test checks for harness-started mock services
|
||||
// When using existing stack (default), harness mocks are started but PostgreSQL is not
|
||||
// (we connect to the existing PostgreSQL instead)
|
||||
let ctx = match E2ETestContext::setup().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
|
|
@ -810,7 +807,6 @@ async fn test_mock_services_available() {
|
|||
}
|
||||
};
|
||||
|
||||
// Mock services are started by harness in both modes
|
||||
if ctx.ctx.mock_llm().is_some() {
|
||||
println!("✓ MockLLM is available");
|
||||
} else {
|
||||
|
|
@ -823,22 +819,16 @@ async fn test_mock_services_available() {
|
|||
eprintln!("MockZitadel not available");
|
||||
}
|
||||
|
||||
// PostgreSQL: only started by harness with FRESH_STACK=1
|
||||
// In existing stack mode, postgres() returns None (we use external DB)
|
||||
if ctx.ctx.use_existing_stack {
|
||||
println!("Using existing stack - PostgreSQL is external (not managed by harness)");
|
||||
// Verify we can connect to the existing database
|
||||
match ctx.ctx.db_pool().await {
|
||||
Ok(_pool) => println!("✓ Connected to existing PostgreSQL"),
|
||||
Err(e) => eprintln!("Could not connect to existing PostgreSQL: {}", e),
|
||||
}
|
||||
} else if ctx.ctx.postgres().is_some() {
|
||||
println!("✓ PostgreSQL is managed by harness");
|
||||
} else {
|
||||
// Fresh stack mode - harness starts PostgreSQL
|
||||
if ctx.ctx.postgres().is_some() {
|
||||
println!("✓ PostgreSQL is managed by harness");
|
||||
} else {
|
||||
eprintln!("PostgreSQL should be started in fresh stack mode");
|
||||
}
|
||||
eprintln!("PostgreSQL should be started in fresh stack mode");
|
||||
}
|
||||
|
||||
ctx.close().await;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ pub struct E2ETestContext {
|
|||
browser_service: Option<BrowserService>,
|
||||
}
|
||||
|
||||
/// Check if a service is running at the given URL
|
||||
async fn is_service_running(url: &str) -> bool {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
|
|
@ -24,8 +23,7 @@ async fn is_service_running(url: &str) -> bool {
|
|||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Try health endpoint first, then root
|
||||
if let Ok(resp) = client.get(&format!("{}/health", url)).send().await {
|
||||
if let Ok(resp) = client.get(format!("{url}/health")).send().await {
|
||||
if resp.status().is_success() {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -38,15 +36,6 @@ async fn is_service_running(url: &str) -> bool {
|
|||
|
||||
impl E2ETestContext {
|
||||
pub async fn setup() -> anyhow::Result<Self> {
|
||||
// Default strategy: Use main botserver stack at https://localhost:8080
|
||||
// This ensures LLM and all services are properly configured
|
||||
// User should start botserver normally: cd botserver && cargo run
|
||||
//
|
||||
// Override with env vars:
|
||||
// BOTSERVER_URL=https://localhost:8080
|
||||
// BOTUI_URL=http://localhost:3000
|
||||
// FRESH_STACK=1 (to start a new temp stack instead)
|
||||
|
||||
let botserver_url =
|
||||
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
|
||||
let botui_url =
|
||||
|
|
@ -55,20 +44,16 @@ impl E2ETestContext {
|
|||
let botserver_running = is_service_running(&botserver_url).await;
|
||||
let botui_running = is_service_running(&botui_url).await;
|
||||
|
||||
// Always use existing stack context (main stack)
|
||||
let ctx = TestHarness::with_existing_stack().await?;
|
||||
|
||||
// Check if botserver is running, if not start it with main stack
|
||||
let server = if botserver_running {
|
||||
println!("🔗 Using existing BotServer at {}", botserver_url);
|
||||
BotServerInstance::existing(&botserver_url)
|
||||
} else {
|
||||
// Auto-start botserver with main stack (includes LLM)
|
||||
println!("🚀 Auto-starting BotServer with main stack...");
|
||||
BotServerInstance::start_with_main_stack().await?
|
||||
};
|
||||
|
||||
// Ensure botui is running (required for chat UI)
|
||||
let ui = if botui_running {
|
||||
println!("🔗 Using existing BotUI at {}", botui_url);
|
||||
Some(BotUIInstance::existing(&botui_url))
|
||||
|
|
@ -100,15 +85,6 @@ impl E2ETestContext {
|
|||
}
|
||||
|
||||
pub async fn setup_with_browser() -> anyhow::Result<Self> {
|
||||
// Default strategy: Use main botserver stack at https://localhost:8080
|
||||
// This ensures LLM and all services are properly configured
|
||||
// User should start botserver normally: cd botserver && cargo run
|
||||
//
|
||||
// Override with env vars:
|
||||
// BOTSERVER_URL=https://localhost:8080
|
||||
// BOTUI_URL=http://localhost:3000
|
||||
// FRESH_STACK=1 (to start a new temp stack instead)
|
||||
|
||||
let botserver_url =
|
||||
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| "https://localhost:8080".to_string());
|
||||
let botui_url =
|
||||
|
|
@ -117,20 +93,16 @@ impl E2ETestContext {
|
|||
let botserver_running = is_service_running(&botserver_url).await;
|
||||
let botui_running = is_service_running(&botui_url).await;
|
||||
|
||||
// Always use existing stack context (main stack)
|
||||
let ctx = TestHarness::with_existing_stack().await?;
|
||||
|
||||
// Check if botserver is running, if not start it with main stack
|
||||
let server = if botserver_running {
|
||||
println!("🔗 Using existing BotServer at {}", botserver_url);
|
||||
BotServerInstance::existing(&botserver_url)
|
||||
} else {
|
||||
// Auto-start botserver with main stack (includes LLM)
|
||||
println!("🚀 Auto-starting BotServer with main stack...");
|
||||
BotServerInstance::start_with_main_stack().await?
|
||||
};
|
||||
|
||||
// Ensure botui is running (required for chat UI)
|
||||
let ui = if botui_running {
|
||||
println!("🔗 Using existing BotUI at {}", botui_url);
|
||||
Some(BotUIInstance::existing(&botui_url))
|
||||
|
|
@ -152,7 +124,6 @@ impl E2ETestContext {
|
|||
}
|
||||
};
|
||||
|
||||
// Start browser with CDP (no chromedriver needed!)
|
||||
let browser_service = match BrowserService::start(DEFAULT_DEBUG_PORT).await {
|
||||
Ok(bs) => {
|
||||
log::info!("Browser started with CDP on port {}", DEFAULT_DEBUG_PORT);
|
||||
|
|
@ -192,7 +163,6 @@ impl E2ETestContext {
|
|||
})
|
||||
}
|
||||
|
||||
/// Get the base URL for browser tests - uses botui if available, otherwise botserver
|
||||
pub fn base_url(&self) -> &str {
|
||||
if let Some(ref ui) = self.ui {
|
||||
&ui.url
|
||||
|
|
@ -201,7 +171,6 @@ impl E2ETestContext {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the botserver API URL
|
||||
pub fn api_url(&self) -> &str {
|
||||
&self.server.url
|
||||
}
|
||||
|
|
@ -212,7 +181,7 @@ impl E2ETestContext {
|
|||
|
||||
pub async fn close(mut self) {
|
||||
if let Some(browser) = self.browser {
|
||||
let _ = browser.close().await;
|
||||
let _ = browser.close();
|
||||
}
|
||||
if let Some(mut bs) = self.browser_service.take() {
|
||||
let _ = bs.stop().await;
|
||||
|
|
@ -221,19 +190,16 @@ impl E2ETestContext {
|
|||
}
|
||||
|
||||
pub fn browser_config() -> BrowserConfig {
|
||||
// Default: SHOW browser window so user can see tests
|
||||
// Set HEADLESS=1 to run without browser window (CI/automation)
|
||||
let headless = std::env::var("HEADLESS").is_ok();
|
||||
let debug_port = std::env::var("CDP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_DEBUG_PORT);
|
||||
|
||||
// Use CDP directly - no chromedriver needed!
|
||||
BrowserConfig::default()
|
||||
.with_browser(BrowserType::Chrome)
|
||||
.with_debug_port(debug_port)
|
||||
.headless(headless) // false by default = show browser
|
||||
.headless(headless)
|
||||
.with_timeout(Duration::from_secs(30))
|
||||
.with_window_size(1920, 1080)
|
||||
}
|
||||
|
|
@ -301,7 +267,6 @@ async fn test_harness_starts_server() {
|
|||
return;
|
||||
}
|
||||
|
||||
// This test explicitly starts a new server - only run with FRESH_STACK=1
|
||||
if std::env::var("FRESH_STACK").is_err() {
|
||||
eprintln!("Skipping: test_harness_starts_server requires FRESH_STACK=1 (uses existing stack by default)");
|
||||
return;
|
||||
|
|
@ -335,7 +300,6 @@ async fn test_harness_starts_server() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_full_harness_has_all_services() {
|
||||
// This test checks harness-started services - only meaningful with FRESH_STACK=1
|
||||
if std::env::var("FRESH_STACK").is_err() {
|
||||
eprintln!("Skipping: test_full_harness_has_all_services requires FRESH_STACK=1 (uses existing stack by default)");
|
||||
return;
|
||||
|
|
@ -349,7 +313,6 @@ async fn test_full_harness_has_all_services() {
|
|||
}
|
||||
};
|
||||
|
||||
// Check services that are enabled in full() config
|
||||
assert!(ctx.postgres().is_some(), "PostgreSQL should be available");
|
||||
assert!(ctx.mock_llm().is_some(), "MockLLM should be available");
|
||||
assert!(
|
||||
|
|
@ -357,17 +320,12 @@ async fn test_full_harness_has_all_services() {
|
|||
"MockZitadel should be available"
|
||||
);
|
||||
|
||||
// MinIO and Redis are disabled in full() config (not in botserver-stack)
|
||||
// so we don't assert they are present
|
||||
|
||||
assert!(ctx.data_dir.exists());
|
||||
assert!(ctx.data_dir.to_str().unwrap().contains("bottest-"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_e2e_cleanup() {
|
||||
// This test creates a temp data dir and cleans it up
|
||||
// Safe to run in both modes since it only cleans up its own tmp dir
|
||||
let mut ctx = match TestHarness::full().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
|
|
@ -384,7 +342,6 @@ async fn test_e2e_cleanup() {
|
|||
assert!(!data_dir.exists());
|
||||
}
|
||||
|
||||
/// Test that checks the existing running stack is accessible
|
||||
#[tokio::test]
|
||||
async fn test_existing_stack_connection() {
|
||||
if !should_run_e2e_tests() {
|
||||
|
|
@ -392,10 +349,8 @@ async fn test_existing_stack_connection() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Use existing stack by default
|
||||
match E2ETestContext::setup().await {
|
||||
Ok(ctx) => {
|
||||
// Check botserver is accessible
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
//! Complete E2E test for General Bots platform flow
|
||||
//!
|
||||
//! Tests the full user journey:
|
||||
//! 1. Platform loading (UI assets)
|
||||
//! 2. BotServer initialization
|
||||
//! 3. User login
|
||||
//! 4. Chat interaction
|
||||
//! 5. User logout
|
||||
|
||||
use bottest::prelude::*;
|
||||
use bottest::web::{Browser, Locator};
|
||||
|
|
@ -13,14 +5,9 @@ use std::time::Duration;
|
|||
|
||||
use super::{check_webdriver_available, should_run_e2e_tests, E2ETestContext};
|
||||
|
||||
/// Step 1: Verify platform loads
|
||||
/// - Check UI is served
|
||||
/// - Verify health endpoint responds
|
||||
/// - Confirm database migrations completed
|
||||
pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Check health endpoint
|
||||
let health_url = format!("{}/health", ctx.base_url());
|
||||
let health_resp = client.get(&health_url).send().await?;
|
||||
assert!(
|
||||
|
|
@ -31,7 +18,6 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
|
|||
|
||||
println!("✓ Platform health check passed");
|
||||
|
||||
// Verify API is responsive
|
||||
let api_url = format!("{}/api/v1", ctx.base_url());
|
||||
let api_resp = client.get(&api_url).send().await?;
|
||||
assert!(
|
||||
|
|
@ -45,19 +31,13 @@ pub async fn verify_platform_loading(ctx: &E2ETestContext) -> anyhow::Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 2: Verify BotServer is running and initialized
|
||||
/// - Check service discovery
|
||||
/// - Verify configuration loaded
|
||||
/// - Confirm database connection
|
||||
pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Check if server is actually running
|
||||
assert!(ctx.server.is_running(), "BotServer process is not running");
|
||||
|
||||
println!("✓ BotServer process running");
|
||||
|
||||
// Verify server info endpoint
|
||||
let info_url = format!("{}/api/v1/server/info", ctx.base_url());
|
||||
match client.get(&info_url).send().await {
|
||||
Ok(resp) => {
|
||||
|
|
@ -88,25 +68,17 @@ pub async fn verify_botserver_running(ctx: &E2ETestContext) -> anyhow::Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 3: User login flow
|
||||
/// - Navigate to login page
|
||||
/// - Enter test credentials
|
||||
/// - Verify session created
|
||||
/// - Confirm redirect to dashboard
|
||||
pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
let login_url = format!("{}/login", ctx.base_url());
|
||||
|
||||
// Navigate to login page
|
||||
browser.goto(&login_url).await?;
|
||||
println!("✓ Navigated to login page: {}", login_url);
|
||||
|
||||
// Wait for login form to be visible
|
||||
browser
|
||||
.wait_for(Locator::css("input[type='email']"))
|
||||
.await?;
|
||||
println!("✓ Login form loaded");
|
||||
|
||||
// Fill in test credentials
|
||||
let test_email = "test@example.com";
|
||||
let test_password = "TestPassword123!";
|
||||
|
||||
|
|
@ -120,17 +92,13 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
|
|||
.await?;
|
||||
println!("✓ Entered password");
|
||||
|
||||
// Submit login form
|
||||
browser.click(Locator::css("button[type='submit']")).await?;
|
||||
println!("✓ Clicked login button");
|
||||
|
||||
// Wait a bit for redirect
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Get current URL
|
||||
let current_url = browser.current_url().await?;
|
||||
|
||||
// Check we're not on login page anymore
|
||||
assert!(
|
||||
!current_url.contains("/login"),
|
||||
"Still on login page after login attempt. URL: {}",
|
||||
|
|
@ -139,7 +107,6 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
|
|||
|
||||
println!("✓ Redirected from login page to: {}", current_url);
|
||||
|
||||
// Verify we can see dashboard or chat area
|
||||
browser
|
||||
.wait_for(Locator::css(
|
||||
"[data-testid='chat-area'], [data-testid='dashboard'], main",
|
||||
|
|
@ -150,18 +117,11 @@ pub async fn test_user_login(browser: &Browser, ctx: &E2ETestContext) -> anyhow:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 4: Chat interaction
|
||||
/// - Open chat window
|
||||
/// - Send test message
|
||||
/// - Receive bot response
|
||||
/// - Verify message persisted
|
||||
pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
// Ensure we're on chat page
|
||||
let chat_url = format!("{}/chat", ctx.base_url());
|
||||
browser.goto(&chat_url).await?;
|
||||
println!("✓ Navigated to chat page");
|
||||
|
||||
// Wait for chat interface to load
|
||||
browser
|
||||
.wait_for(Locator::css(
|
||||
"[data-testid='message-input'], textarea.chat-input, input.message",
|
||||
|
|
@ -169,7 +129,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
.await?;
|
||||
println!("✓ Chat interface loaded");
|
||||
|
||||
// Send test message
|
||||
let test_message = "Hello, I need help";
|
||||
browser
|
||||
.fill(
|
||||
|
|
@ -179,7 +138,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
.await?;
|
||||
println!("✓ Typed message: {}", test_message);
|
||||
|
||||
// Click send button
|
||||
let send_result = browser
|
||||
.click(Locator::css(
|
||||
"button[data-testid='send-button'], button.send-btn",
|
||||
|
|
@ -187,7 +145,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
.await;
|
||||
|
||||
if send_result.is_err() {
|
||||
// Try pressing Enter as alternative - find the input and send Enter key
|
||||
let input = browser
|
||||
.find(Locator::css("textarea.chat-input, input.message"))
|
||||
.await?;
|
||||
|
|
@ -197,7 +154,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
println!("✓ Clicked send button");
|
||||
}
|
||||
|
||||
// Wait for message to appear in chat history
|
||||
browser
|
||||
.wait_for(Locator::css(
|
||||
"[data-testid='message-item'], .message-bubble, [class*='message']",
|
||||
|
|
@ -205,7 +161,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
.await?;
|
||||
println!("✓ Message appeared in chat");
|
||||
|
||||
// Wait for bot response
|
||||
browser
|
||||
.wait_for(Locator::css(
|
||||
"[data-testid='bot-response'], .bot-message, [class*='bot']",
|
||||
|
|
@ -213,7 +168,6 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
.await?;
|
||||
println!("✓ Received bot response");
|
||||
|
||||
// Get response text
|
||||
let response_text = browser
|
||||
.text(Locator::css(
|
||||
"[data-testid='bot-response'], .bot-message, [class*='bot']",
|
||||
|
|
@ -231,13 +185,7 @@ pub async fn test_chat_interaction(browser: &Browser, ctx: &E2ETestContext) -> a
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Step 5: User logout flow
|
||||
/// - Click logout button
|
||||
/// - Verify session invalidated
|
||||
/// - Confirm redirect to login
|
||||
/// - Verify cannot access protected routes
|
||||
pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow::Result<()> {
|
||||
// Find and click logout button
|
||||
let logout_selectors = vec![
|
||||
"button[data-testid='logout-btn']",
|
||||
"button.logout",
|
||||
|
|
@ -260,7 +208,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
|
|||
browser.goto(&logout_url).await?;
|
||||
}
|
||||
|
||||
// Wait for redirect to login
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let current_url = browser.current_url().await?;
|
||||
|
||||
|
|
@ -272,7 +219,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
|
|||
|
||||
println!("✓ Redirected to login page after logout: {}", current_url);
|
||||
|
||||
// Verify we cannot access protected routes
|
||||
let chat_url = format!("{}/chat", ctx.base_url());
|
||||
browser.goto(&chat_url).await?;
|
||||
|
||||
|
|
@ -289,14 +235,6 @@ pub async fn test_user_logout(browser: &Browser, ctx: &E2ETestContext) -> anyhow
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete platform flow test
|
||||
///
|
||||
/// This test validates the entire user journey:
|
||||
/// 1. Platform loads successfully
|
||||
/// 2. BotServer is initialized and running
|
||||
/// 3. User can login with credentials
|
||||
/// 4. User can interact with chat
|
||||
/// 5. User can logout and lose access
|
||||
#[tokio::test]
|
||||
async fn test_complete_platform_flow_login_chat_logout() {
|
||||
if !should_run_e2e_tests() {
|
||||
|
|
@ -311,7 +249,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
|
|||
|
||||
println!("\n=== Starting Complete Platform Flow Test ===\n");
|
||||
|
||||
// Setup context
|
||||
let ctx = match E2ETestContext::setup_with_browser().await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
|
|
@ -327,7 +264,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
|
|||
|
||||
let browser = ctx.browser.as_ref().unwrap();
|
||||
|
||||
// Test each phase
|
||||
println!("\n--- Phase 1: Platform Loading ---");
|
||||
if let Err(e) = verify_platform_loading(&ctx).await {
|
||||
eprintln!("Platform loading test failed: {}", e);
|
||||
|
|
@ -349,7 +285,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
|
|||
println!("\n--- Phase 4: Chat Interaction ---");
|
||||
if let Err(e) = test_chat_interaction(browser, &ctx).await {
|
||||
eprintln!("Chat interaction test failed: {}", e);
|
||||
// Don't return here - try to logout anyway
|
||||
}
|
||||
|
||||
println!("\n--- Phase 5: User Logout ---");
|
||||
|
|
@ -363,7 +298,6 @@ async fn test_complete_platform_flow_login_chat_logout() {
|
|||
ctx.close().await;
|
||||
}
|
||||
|
||||
/// Simpler test for basic platform loading without browser
|
||||
#[tokio::test]
|
||||
async fn test_platform_loading_http_only() {
|
||||
if !should_run_e2e_tests() {
|
||||
|
|
@ -391,7 +325,6 @@ async fn test_platform_loading_http_only() {
|
|||
ctx.close().await;
|
||||
}
|
||||
|
||||
/// Test BotServer startup and health
|
||||
#[tokio::test]
|
||||
async fn test_botserver_startup() {
|
||||
if !should_run_e2e_tests() {
|
||||
|
|
@ -409,7 +342,6 @@ async fn test_botserver_startup() {
|
|||
}
|
||||
};
|
||||
|
||||
// Skip if botserver isn't running (binary not found or failed to start)
|
||||
if !ctx.server.is_running() {
|
||||
eprintln!("Skipping: BotServer not running (BOTSERVER_BIN not set or binary not found)");
|
||||
ctx.close().await;
|
||||
|
|
|
|||
|
|
@ -36,25 +36,6 @@ async fn get_test_server() -> Option<(Option<TestContext>, String)> {
|
|||
}
|
||||
}
|
||||
|
||||
fn is_server_available_sync() -> bool {
|
||||
if std::env::var("SKIP_INTEGRATION_TESTS").is_ok() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(url) = external_server_url() {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
if let Some(client) = client {
|
||||
return client.get(&url).send().is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
macro_rules! skip_if_no_server {
|
||||
($base_url:expr) => {
|
||||
if $base_url.is_none() {
|
||||
|
|
@ -658,7 +639,7 @@ async fn test_mock_llm_assertions() {
|
|||
|
||||
let client = reqwest::Client::new();
|
||||
let _ = client
|
||||
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
|
||||
.post(format!("{}/v1/chat/completions", mock_llm.url()))
|
||||
.json(&serde_json::json!({
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
|
|
@ -685,7 +666,7 @@ async fn test_mock_llm_error_simulation() {
|
|||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(&format!("{}/v1/chat/completions", mock_llm.url()))
|
||||
.post(format!("{}/v1/chat/completions", mock_llm.url()))
|
||||
.json(&serde_json::json!({
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "test"}]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
use rhai::Engine;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// =============================================================================
|
||||
// Test Utilities
|
||||
// =============================================================================
|
||||
|
||||
/// Create a Rhai engine with BASIC-like functions registered
|
||||
fn create_basic_engine() -> Engine {
|
||||
let mut engine = Engine::new();
|
||||
|
||||
// Register string functions
|
||||
engine.register_fn("INSTR", |haystack: &str, needle: &str| -> i64 {
|
||||
if haystack.is_empty() || needle.is_empty() {
|
||||
return 0;
|
||||
|
|
@ -49,7 +43,6 @@ fn create_basic_engine() -> Engine {
|
|||
s.replace(find, replace)
|
||||
});
|
||||
|
||||
// Register math functions
|
||||
engine.register_fn("ABS", |n: i64| -> i64 { n.abs() });
|
||||
engine.register_fn("ABS", |n: f64| -> f64 { n.abs() });
|
||||
engine.register_fn("ROUND", |n: f64| -> i64 { n.round() as i64 });
|
||||
|
|
@ -72,14 +65,12 @@ fn create_basic_engine() -> Engine {
|
|||
engine.register_fn("TAN", |n: f64| -> f64 { n.tan() });
|
||||
engine.register_fn("PI", || -> f64 { std::f64::consts::PI });
|
||||
|
||||
// Register type conversion
|
||||
engine.register_fn("VAL", |s: &str| -> f64 {
|
||||
s.trim().parse::<f64>().unwrap_or(0.0)
|
||||
});
|
||||
engine.register_fn("STR", |n: i64| -> String { n.to_string() });
|
||||
engine.register_fn("STR", |n: f64| -> String { n.to_string() });
|
||||
|
||||
// Register type checking
|
||||
engine.register_fn("IS_NUMERIC", |value: &str| -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -91,7 +82,6 @@ fn create_basic_engine() -> Engine {
|
|||
engine
|
||||
}
|
||||
|
||||
/// Mock output collector for TALK commands
|
||||
#[derive(Clone, Default)]
|
||||
struct OutputCollector {
|
||||
messages: Arc<Mutex<Vec<String>>>,
|
||||
|
|
@ -114,7 +104,6 @@ impl OutputCollector {
|
|||
}
|
||||
}
|
||||
|
||||
/// Mock input provider for HEAR commands
|
||||
#[derive(Clone)]
|
||||
struct InputProvider {
|
||||
inputs: Arc<Mutex<Vec<String>>>,
|
||||
|
|
@ -142,26 +131,18 @@ impl InputProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// Create an engine with TALK/HEAR simulation
|
||||
fn create_conversation_engine(output: OutputCollector, input: InputProvider) -> Engine {
|
||||
let mut engine = create_basic_engine();
|
||||
|
||||
// Register TALK function
|
||||
let output_clone = output.clone();
|
||||
engine.register_fn("TALK", move |msg: &str| {
|
||||
output_clone.add_message(msg.to_string());
|
||||
output.add_message(msg.to_string());
|
||||
});
|
||||
|
||||
// Register HEAR function
|
||||
engine.register_fn("HEAR", move || -> String { input.next_input() });
|
||||
|
||||
engine
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// String Function Tests with Engine
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_string_concatenation_in_engine() {
|
||||
let engine = create_basic_engine();
|
||||
|
|
@ -176,11 +157,9 @@ fn test_string_concatenation_in_engine() {
|
|||
fn test_string_functions_chain() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// Test chained operations: UPPER(TRIM(" hello "))
|
||||
let result: String = engine.eval(r#"UPPER(TRIM(" hello "))"#).unwrap();
|
||||
assert_eq!(result, "HELLO");
|
||||
|
||||
// Test LEN(TRIM(" test "))
|
||||
let result: i64 = engine.eval(r#"LEN(TRIM(" test "))"#).unwrap();
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
|
|
@ -189,15 +168,12 @@ fn test_string_functions_chain() {
|
|||
fn test_substring_extraction() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// LEFT
|
||||
let result: String = engine.eval(r#"LEFT("Hello World", 5)"#).unwrap();
|
||||
assert_eq!(result, "Hello");
|
||||
|
||||
// RIGHT
|
||||
let result: String = engine.eval(r#"RIGHT("Hello World", 5)"#).unwrap();
|
||||
assert_eq!(result, "World");
|
||||
|
||||
// MID
|
||||
let result: String = engine.eval(r#"MID("Hello World", 7, 5)"#).unwrap();
|
||||
assert_eq!(result, "World");
|
||||
}
|
||||
|
|
@ -207,13 +183,13 @@ fn test_instr_function() {
|
|||
let engine = create_basic_engine();
|
||||
|
||||
let result: i64 = engine.eval(r#"INSTR("Hello World", "World")"#).unwrap();
|
||||
assert_eq!(result, 7); // 1-based index
|
||||
assert_eq!(result, 7);
|
||||
|
||||
let result: i64 = engine.eval(r#"INSTR("Hello World", "xyz")"#).unwrap();
|
||||
assert_eq!(result, 0); // Not found
|
||||
assert_eq!(result, 0);
|
||||
|
||||
let result: i64 = engine.eval(r#"INSTR("Hello World", "o")"#).unwrap();
|
||||
assert_eq!(result, 5); // First occurrence
|
||||
assert_eq!(result, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -229,19 +205,13 @@ fn test_replace_function() {
|
|||
assert_eq!(result, "bbb");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Math Function Tests with Engine
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_math_operations_chain() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// SQRT(ABS(-16))
|
||||
let result: f64 = engine.eval("SQRT(ABS(-16.0))").unwrap();
|
||||
assert!((result - 4.0).abs() < f64::EPSILON);
|
||||
|
||||
// MAX(ABS(-5), ABS(-10))
|
||||
let result: i64 = engine.eval("MAX(ABS(-5), ABS(-10))").unwrap();
|
||||
assert_eq!(result, 10);
|
||||
}
|
||||
|
|
@ -250,21 +220,18 @@ fn test_math_operations_chain() {
|
|||
fn test_rounding_functions() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// ROUND
|
||||
let result: i64 = engine.eval("ROUND(3.7)").unwrap();
|
||||
assert_eq!(result, 4);
|
||||
|
||||
let result: i64 = engine.eval("ROUND(3.2)").unwrap();
|
||||
assert_eq!(result, 3);
|
||||
|
||||
// FLOOR
|
||||
let result: i64 = engine.eval("FLOOR(3.9)").unwrap();
|
||||
assert_eq!(result, 3);
|
||||
|
||||
let result: i64 = engine.eval("FLOOR(-3.1)").unwrap();
|
||||
assert_eq!(result, -4);
|
||||
|
||||
// CEIL
|
||||
let result: i64 = engine.eval("CEIL(3.1)").unwrap();
|
||||
assert_eq!(result, 4);
|
||||
|
||||
|
|
@ -293,17 +260,13 @@ fn test_val_function() {
|
|||
let result: f64 = engine.eval(r#"VAL("42")"#).unwrap();
|
||||
assert!((result - 42.0).abs() < f64::EPSILON);
|
||||
|
||||
let result: f64 = engine.eval(r#"VAL("3.14")"#).unwrap();
|
||||
assert!((result - 3.14).abs() < f64::EPSILON);
|
||||
let result: f64 = engine.eval(r#"VAL("3.5")"#).unwrap();
|
||||
assert!((result - 3.5).abs() < f64::EPSILON);
|
||||
|
||||
let result: f64 = engine.eval(r#"VAL("invalid")"#).unwrap();
|
||||
assert!((result - 0.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TALK/HEAR Conversation Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_talk_output() {
|
||||
let output = OutputCollector::new();
|
||||
|
|
@ -422,26 +385,21 @@ fn test_keyword_detection() {
|
|||
|
||||
let messages = output.get_messages();
|
||||
assert_eq!(messages.len(), 1);
|
||||
// Should match "HELP" first since it appears before "ORDER" in the conditions
|
||||
assert_eq!(messages[0], "I can help you! What do you need?");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Variable and Expression Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_variable_assignment() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
let result: i64 = engine
|
||||
.eval(
|
||||
r#"
|
||||
r"
|
||||
let x = 10;
|
||||
let y = 20;
|
||||
let z = x + y;
|
||||
z
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, 30);
|
||||
|
|
@ -468,22 +426,16 @@ fn test_string_variables() {
|
|||
fn test_numeric_expressions() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// Order of operations
|
||||
let result: i64 = engine.eval("2 + 3 * 4").unwrap();
|
||||
assert_eq!(result, 14);
|
||||
|
||||
let result: i64 = engine.eval("(2 + 3) * 4").unwrap();
|
||||
assert_eq!(result, 20);
|
||||
|
||||
// Using functions in expressions
|
||||
let result: i64 = engine.eval("ABS(-5) + MAX(3, 7)").unwrap();
|
||||
assert_eq!(result, 12);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Loop and Control Flow Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_for_loop() {
|
||||
let output = OutputCollector::new();
|
||||
|
|
@ -513,7 +465,7 @@ fn test_while_loop() {
|
|||
|
||||
let result: i64 = engine
|
||||
.eval(
|
||||
r#"
|
||||
r"
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
while count < 5 {
|
||||
|
|
@ -521,25 +473,19 @@ fn test_while_loop() {
|
|||
count = count + 1;
|
||||
}
|
||||
sum
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, 10); // 0 + 1 + 2 + 3 + 4 = 10
|
||||
assert_eq!(result, 10);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Handling Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_division_by_zero() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// Rhai handles division by zero differently for int vs float
|
||||
let result = engine.eval::<f64>("10.0 / 0.0");
|
||||
match result {
|
||||
Ok(val) => assert!(val.is_infinite() || val.is_nan()),
|
||||
Err(_) => (), // Division by zero error is also acceptable
|
||||
if let Ok(val) = result {
|
||||
assert!(val.is_infinite() || val.is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -547,7 +493,6 @@ fn test_division_by_zero() {
|
|||
fn test_invalid_function_call() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// Calling undefined function should error
|
||||
let result = engine.eval::<String>(r#"UNDEFINED_FUNCTION("test")"#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
|
@ -556,22 +501,16 @@ fn test_invalid_function_call() {
|
|||
fn test_type_mismatch() {
|
||||
let engine = create_basic_engine();
|
||||
|
||||
// Trying to use string where number expected
|
||||
let result = engine.eval::<i64>(r#"ABS("not a number")"#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Script Fixture Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_greeting_script_logic() {
|
||||
let output = OutputCollector::new();
|
||||
let input = InputProvider::new(vec!["HELP".to_string()]);
|
||||
let engine = create_conversation_engine(output.clone(), input);
|
||||
|
||||
// Simulated greeting script logic
|
||||
engine
|
||||
.eval::<()>(
|
||||
r#"
|
||||
|
|
@ -659,10 +598,6 @@ fn test_echo_bot_logic() {
|
|||
assert_eq!(messages[2], "You said: How are you?");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Complex Scenario Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_order_lookup_simulation() {
|
||||
let output = OutputCollector::new();
|
||||
|
|
@ -675,7 +610,6 @@ fn test_order_lookup_simulation() {
|
|||
TALK("Please enter your order number:");
|
||||
let order_num = HEAR();
|
||||
|
||||
// Simulate order lookup
|
||||
let is_valid = INSTR(order_num, "ORD-") == 1 && LEN(order_num) >= 9;
|
||||
|
||||
if is_valid {
|
||||
|
|
@ -723,6 +657,5 @@ fn test_price_calculation() {
|
|||
let messages = output.get_messages();
|
||||
assert_eq!(messages.len(), 5);
|
||||
assert!(messages[0].contains("29.99"));
|
||||
// Subtotal should be 89.97
|
||||
assert!(messages[2].contains("89.97"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,37 +154,37 @@ async fn test_query_result_types() {
|
|||
use diesel::sql_query;
|
||||
|
||||
#[derive(QueryableByName)]
|
||||
struct TypeTestResult {
|
||||
struct TypeTestRow {
|
||||
#[diesel(sql_type = diesel::sql_types::Integer)]
|
||||
int_val: i32,
|
||||
integer: i32,
|
||||
#[diesel(sql_type = diesel::sql_types::BigInt)]
|
||||
bigint_val: i64,
|
||||
bigint: i64,
|
||||
#[diesel(sql_type = diesel::sql_types::Text)]
|
||||
text_val: String,
|
||||
text: String,
|
||||
#[diesel(sql_type = diesel::sql_types::Bool)]
|
||||
bool_val: bool,
|
||||
flag: bool,
|
||||
#[diesel(sql_type = diesel::sql_types::Double)]
|
||||
float_val: f64,
|
||||
decimal: f64,
|
||||
}
|
||||
|
||||
let mut conn = pool.get().expect("Failed to get connection");
|
||||
let result: Vec<TypeTestResult> = sql_query(
|
||||
let result: Vec<TypeTestRow> = sql_query(
|
||||
"SELECT
|
||||
42 as int_val,
|
||||
9223372036854775807::bigint as bigint_val,
|
||||
'hello' as text_val,
|
||||
true as bool_val,
|
||||
3.14159 as float_val",
|
||||
42 as integer,
|
||||
9223372036854775807::bigint as bigint,
|
||||
'hello' as text,
|
||||
true as flag,
|
||||
3.125 as decimal",
|
||||
)
|
||||
.load(&mut conn)
|
||||
.expect("Query failed");
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].int_val, 42);
|
||||
assert_eq!(result[0].bigint_val, 9223372036854775807_i64);
|
||||
assert_eq!(result[0].text_val, "hello");
|
||||
assert!(result[0].bool_val);
|
||||
assert!((result[0].float_val - 3.14159).abs() < 0.0001);
|
||||
assert_eq!(result[0].integer, 42);
|
||||
assert_eq!(result[0].bigint, 9_223_372_036_854_775_807_i64);
|
||||
assert_eq!(result[0].text, "hello");
|
||||
assert!(result[0].flag);
|
||||
assert!((result[0].decimal - 3.125).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -29,16 +29,6 @@ pub fn should_run_integration_tests() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! skip_if_no_services {
|
||||
() => {
|
||||
if !crate::integration::should_run_integration_tests() {
|
||||
eprintln!("Skipping integration test: SKIP_INTEGRATION_TESTS is set");
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_harness_database_only() {
|
||||
if !should_run_integration_tests() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
//! Unit Tests for BotServer
|
||||
//!
|
||||
//! These tests verify BASIC language functions (string, math, etc.)
|
||||
//! and core logic like attendance queue handling.
|
||||
//! No external services required (PostgreSQL, Redis, MinIO).
|
||||
|
||||
mod attendance;
|
||||
mod math_functions;
|
||||
mod string_functions;
|
||||
|
||||
/// Verify the test module loads correctly
|
||||
#[test]
|
||||
fn test_unit_module_loads() {
|
||||
// If this compiles and runs, the test infrastructure is working
|
||||
assert!(true);
|
||||
let module_name = module_path!();
|
||||
assert!(module_name.contains("unit"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue