mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
36 Commits
feat/chat-
...
fix-path-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269e27e734 | ||
|
|
79cfffce60 | ||
|
|
b68c75252d | ||
|
|
d58e12ad74 | ||
|
|
bd837039dd | ||
|
|
8a7779068a | ||
|
|
38099934b6 | ||
|
|
75c823c486 | ||
|
|
8941111c4e | ||
|
|
59dd1da7d6 | ||
|
|
934fbe93c2 | ||
|
|
55e4f07200 | ||
|
|
00daaa41d3 | ||
|
|
a0e777503e | ||
|
|
238cab4d08 | ||
|
|
aec95ecf3b | ||
|
|
d591b140c8 | ||
|
|
4dfcd68153 | ||
|
|
f7ca32126f | ||
|
|
c66a112bf5 | ||
|
|
a8ff720b40 | ||
|
|
a14158e818 | ||
|
|
0c51089ab6 | ||
|
|
8189d21445 | ||
|
|
b7e5c9d25b | ||
|
|
873dc6628f | ||
|
|
f5d0af15d9 | ||
|
|
922e3a2431 | ||
|
|
0527c46bba | ||
|
|
b4f00379b8 | ||
|
|
cd2d0ee9a5 | ||
|
|
8e6d05fc3a | ||
|
|
9d82f97a82 | ||
|
|
2c7b25ab1c | ||
|
|
e82bf44324 | ||
|
|
8799c07027 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -234,6 +234,8 @@ yarn-error.log*
|
||||
|
||||
logs
|
||||
|
||||
ralph/
|
||||
|
||||
# agent
|
||||
.envrc
|
||||
/workspace
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
29
AGENTS.md
29
AGENTS.md
@@ -39,6 +39,8 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
|
||||
- For V1 web-app docs, LLM setup should point users to the Settings UI.
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
@@ -342,3 +344,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
|
||||
- Models appear in CLI provider selection based on the verified arrays
|
||||
- The `organize_models_and_providers` function groups models by provider
|
||||
- Default model selection prioritizes verified models for each provider
|
||||
|
||||
### Sandbox Settings API (SDK Credential Inheritance)
|
||||
|
||||
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
|
||||
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
|
||||
never through the SDK client.
|
||||
|
||||
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
|
||||
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
|
||||
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
|
||||
|
||||
Auth requirements for `expose_secrets=true`:
|
||||
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
|
||||
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
|
||||
|
||||
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
|
||||
|
||||
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
|
||||
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
|
||||
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
|
||||
|
||||
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
|
||||
|
||||
#### Related SDK code (in `software-agent-sdk` repo):
|
||||
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
|
||||
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
|
||||
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`
|
||||
|
||||
165
CONTRIBUTING.md
165
CONTRIBUTING.md
@@ -1,83 +1,105 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
|
||||
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
|
||||
|
||||
## Understanding OpenHands's CodeBase
|
||||
## Our Vision
|
||||
|
||||
To understand the codebase, please refer to the README in each module:
|
||||
- [frontend](./frontend/README.md)
|
||||
- [openhands](./openhands/README.md)
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
|
||||
|
||||
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
|
||||
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
|
||||
|
||||
## Setting up Your Development Environment
|
||||
## Getting Started
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
you how to set up a development workflow.
|
||||
### Quick Ways to Contribute
|
||||
|
||||
## How Can I Contribute?
|
||||
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
|
||||
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
|
||||
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
|
||||
- **Share OpenHands** with other developers
|
||||
|
||||
There are many ways that you can contribute:
|
||||
### Set Up Your Development Environment
|
||||
|
||||
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
|
||||
- **Quick setup**: `make build`
|
||||
- **Run locally**: `make run`
|
||||
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
|
||||
|
||||
## What Can I Build?
|
||||
Full details in our [Development Guide](./Development.md).
|
||||
|
||||
Here are a few ways you can help improve the codebase.
|
||||
### Find Your First Issue
|
||||
|
||||
#### UI/UX
|
||||
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
|
||||
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
|
||||
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
|
||||
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
## Understanding the Codebase
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
#### Improving the agent
|
||||
## What Can You Build?
|
||||
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
### Frontend & UI/UX
|
||||
- React & TypeScript development
|
||||
- UI/UX improvements
|
||||
- Mobile responsiveness
|
||||
- Component libraries
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
|
||||
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
|
||||
is getting better over time.
|
||||
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
|
||||
channel in Slack to learn more.
|
||||
### Agent Development
|
||||
- Prompt engineering
|
||||
- New agent types
|
||||
- Agent evaluation
|
||||
- Multi-agent systems
|
||||
|
||||
#### Adding a new agent
|
||||
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
|
||||
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
### Backend & Infrastructure
|
||||
- Python development
|
||||
- Runtime systems (Docker containers, sandboxes)
|
||||
- Cloud integrations
|
||||
- Performance optimization
|
||||
|
||||
#### Adding a new runtime
|
||||
### Testing & Quality Assurance
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Bug hunting
|
||||
- Performance testing
|
||||
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
### Documentation & Education
|
||||
- Technical documentation
|
||||
- Translation
|
||||
- Community support
|
||||
|
||||
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
## Pull Request Process
|
||||
|
||||
#### Testing
|
||||
### Small Improvements
|
||||
- Quick review and approval
|
||||
- Ensure CI tests pass
|
||||
- Include clear description of changes
|
||||
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
|
||||
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
|
||||
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
|
||||
quality of the project.
|
||||
### Core Agent Changes
|
||||
These are evaluated based on:
|
||||
- **Accuracy** - Does it make the agent better at solving problems?
|
||||
- **Efficiency** - Does it improve speed or reduce resource usage?
|
||||
- **Code Quality** - Is the code maintainable and well-tested?
|
||||
|
||||
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
### Pull Request title
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
### Pull Request Title Format
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -95,45 +117,16 @@ For example, a PR title could be:
|
||||
- `refactor: modify package path`
|
||||
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
|
||||
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
### Pull Request Description
|
||||
|
||||
### Pull Request description
|
||||
- Explain what the PR does and why
|
||||
- Link to related issues
|
||||
- Include screenshots for UI changes
|
||||
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
|
||||
please include a short message that we can add to our changelog
|
||||
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
## Need Help?
|
||||
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
|
||||
## How to Make Effective Contributions
|
||||
|
||||
### Opening Issues
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
|
||||
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
|
||||
the community has interest/effort for.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
- **Slack**: [Join our community](https://openhands.dev/joinslack)
|
||||
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
|
||||
- **Email**: contact@openhands.dev
|
||||
|
||||
382
Development.md
382
Development.md
@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
|
||||
on how to clone and setup the project initially before moving on. Otherwise,
|
||||
you can clone the OpenHands project directly.
|
||||
|
||||
## Start the Server for Development
|
||||
## Choose Your Setup
|
||||
|
||||
### 1. Requirements
|
||||
Select your operating system to see the specific setup instructions:
|
||||
|
||||
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
|
||||
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
- [Python](https://www.python.org/downloads/) = 3.12
|
||||
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
|
||||
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
|
||||
- OS-specific dependencies:
|
||||
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
|
||||
- WSL: netcat => `sudo apt-get install netcat`
|
||||
- [macOS](#macos-setup)
|
||||
- [Linux](#linux-setup)
|
||||
- [Windows WSL](#windows-wsl-setup)
|
||||
- [Dev Container](#dev-container)
|
||||
- [Developing in Docker](#developing-in-docker)
|
||||
- [No sudo access?](#develop-without-sudo-access)
|
||||
|
||||
Make sure you have all these dependencies installed before moving on to `make build`.
|
||||
---
|
||||
|
||||
#### Dev container
|
||||
## macOS Setup
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
You'll need the following installed:
|
||||
|
||||
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
|
||||
- **Node.js >= 22** — `brew install node`
|
||||
- **Poetry >= 1.8** — `brew install poetry`
|
||||
- **Docker Desktop** — `brew install --cask docker`
|
||||
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
|
||||
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
|
||||
|
||||
If you are running headless or CLI workflows, you can prepare local defaults with:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
```
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
|
||||
|
||||
---
|
||||
|
||||
## Linux Setup
|
||||
|
||||
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install system dependencies
|
||||
sudo apt install -y build-essential curl netcat software-properties-common
|
||||
|
||||
# Install Python 3.12
|
||||
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
|
||||
# python3.12 --version already works on your system.
|
||||
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
sudo apt update
|
||||
sudo apt install -y python3.12 python3.12-dev python3.12-venv
|
||||
|
||||
# Install Node.js 22.x
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# Add Poetry to your PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Docker
|
||||
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
|
||||
# Quick version:
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for Docker group changes to take effect
|
||||
```
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows WSL Setup
|
||||
|
||||
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
|
||||
|
||||
### 1. Install WSL2
|
||||
|
||||
**Option A: Windows 11 (Microsoft Store)**
|
||||
The easiest way on Windows 11:
|
||||
1. Open the **Microsoft Store** app
|
||||
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
|
||||
3. Click **Install**
|
||||
4. Launch Ubuntu from the Start menu
|
||||
|
||||
**Option B: PowerShell**
|
||||
```powershell
|
||||
# Run this in PowerShell as Administrator
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
After installation, restart your computer and open Ubuntu.
|
||||
|
||||
### 2. Install Prerequisites (in WSL Ubuntu)
|
||||
|
||||
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
|
||||
|
||||
### 3. Configure Docker for WSL2
|
||||
|
||||
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||
2. Open Docker Desktop > Settings > General
|
||||
3. Enable: "Use the WSL 2 based engine"
|
||||
4. Go to Settings > Resources > WSL Integration
|
||||
5. Enable integration with your Ubuntu distribution
|
||||
|
||||
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
|
||||
|
||||
### 4. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 5. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
|
||||
|
||||
### 6. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
Access the frontend at `http://localhost:3001` from your Windows browser.
|
||||
|
||||
---
|
||||
|
||||
## Dev Container
|
||||
|
||||
There is a [dev container](https://containers.dev/) available which provides a
|
||||
pre-configured environment with all the necessary dependencies installed if you
|
||||
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
|
||||
_Dev Container: Reopen in Container_ command from the Command Palette
|
||||
(Ctrl+Shift+P).
|
||||
|
||||
#### Develop without sudo access
|
||||
---
|
||||
|
||||
## Developing in Docker
|
||||
|
||||
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
For more details, see the [dev container documentation](./containers/dev/README.md).
|
||||
|
||||
### Alternative: Docker Run
|
||||
|
||||
If you just want to run OpenHands without setting up a dev environment:
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
If you don't have `make` installed, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Develop without sudo access
|
||||
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
|
||||
`conda` or `mamba` to manage the packages for you:
|
||||
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
|
||||
mamba install conda-forge::poetry
|
||||
```
|
||||
|
||||
### 2. Build and Setup The Environment
|
||||
---
|
||||
|
||||
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
|
||||
that OpenHands is ready to run on your system:
|
||||
## Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself!
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make build
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
### 3. Configuring the Language Model
|
||||
Access the interface at:
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
For external access:
|
||||
```bash
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
To configure the LM of your choice, run:
|
||||
---
|
||||
|
||||
## LLM Debugging
|
||||
|
||||
If you encounter issues with the Language Model, enable debug logging:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
export DEBUG=1
|
||||
# Restart the backend
|
||||
make start-backend
|
||||
```
|
||||
|
||||
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
|
||||
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
|
||||
please set the model in the UI.
|
||||
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
|
||||
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
|
||||
variables in your terminal. The final configurations are set from highest to lowest priority:
|
||||
Environment variables > config.toml variables > default variables
|
||||
---
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
## Testing
|
||||
|
||||
### 4. Running the application
|
||||
|
||||
#### Option A: Run the Full Application
|
||||
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
#### Option B: Individual Server Startup
|
||||
|
||||
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
|
||||
backend-related tasks or configurations.
|
||||
|
||||
```bash
|
||||
make start-backend
|
||||
```
|
||||
|
||||
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
|
||||
components or interface enhancements.
|
||||
```bash
|
||||
make start-frontend
|
||||
```
|
||||
|
||||
### 5. Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Build and run OpenHands:**
|
||||
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
2. **Access the interface:**
|
||||
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
3. **Configure for external access (if needed):**
|
||||
```bash
|
||||
# For external access (e.g., cloud environments)
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
|
||||
### 7. Help
|
||||
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
### 8. Testing
|
||||
|
||||
To run tests, refer to the following:
|
||||
|
||||
#### Unit tests
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
poetry run pytest ./tests/unit/test_*.py
|
||||
```
|
||||
|
||||
### 9. Add or update dependency
|
||||
---
|
||||
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
## Adding Dependencies
|
||||
|
||||
### 10. Use existing Docker image
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
|
||||
2. Update the lock file: `poetry lock --no-update`
|
||||
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
---
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
|
||||
## Using Existing Docker Images
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
TL;DR
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
See more details [here](./containers/dev/README.md).
|
||||
---
|
||||
|
||||
If you are just interested in running `OpenHands` without installing all the required tools on your host.
|
||||
## Help
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
make help
|
||||
```
|
||||
|
||||
If you do not have `make` on your host, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
|
||||
---
|
||||
|
||||
## Key Documentation Resources
|
||||
|
||||
Here's a guide to the important documentation files in the repository:
|
||||
|
||||
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
|
||||
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
|
||||
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
|
||||
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
## User ID vs User Token
|
||||
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
|
||||
13
enterprise/doc/architecture/README.md
Normal file
13
enterprise/doc/architecture/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Enterprise Architecture Documentation
|
||||
|
||||
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
|
||||
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For core OpenHands architecture (applicable to all deployments), see:
|
||||
- [Core Architecture Documentation](../../../openhands/architecture/README.md)
|
||||
58
enterprise/doc/architecture/authentication.md
Normal file
58
enterprise/doc/architecture/authentication.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Authentication Flow (SaaS Deployment)
|
||||
|
||||
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant KC as Keycloak
|
||||
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
|
||||
participant DB as User Database
|
||||
|
||||
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
|
||||
|
||||
User->>App: Access OpenHands
|
||||
App->>User: Redirect to Keycloak
|
||||
User->>KC: Login request
|
||||
KC->>User: Show login options
|
||||
User->>KC: Select provider (e.g., GitHub)
|
||||
KC->>IdP: OAuth redirect
|
||||
User->>IdP: Authenticate
|
||||
IdP-->>KC: OAuth callback + tokens
|
||||
Note over KC: Create/update user session
|
||||
KC-->>User: Redirect with auth code
|
||||
User->>App: Auth code
|
||||
App->>KC: Exchange code for tokens
|
||||
KC-->>App: Access token + Refresh token
|
||||
Note over App: Create signed JWT cookie
|
||||
App->>DB: Store/update user record
|
||||
App-->>User: Set keycloak_auth cookie
|
||||
|
||||
Note over User,DB: Subsequent Requests
|
||||
|
||||
User->>App: Request with cookie
|
||||
Note over App: Verify JWT signature
|
||||
App->>KC: Validate token (if needed)
|
||||
KC-->>App: Token valid
|
||||
Note over App: Extract user context
|
||||
App-->>User: Authorized response
|
||||
```
|
||||
|
||||
### Authentication Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Keycloak** | Identity provider, SSO, token management | External service |
|
||||
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
|
||||
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
|
||||
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
|
||||
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
|
||||
|
||||
### Token Flow
|
||||
|
||||
1. **Keycloak Access Token**: Short-lived token for API access
|
||||
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
|
||||
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
|
||||
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)
|
||||
88
enterprise/doc/architecture/external-integrations.md
Normal file
88
enterprise/doc/architecture/external-integrations.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# External Integrations
|
||||
|
||||
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Ext as External Service<br/>(GitHub/Slack/Jira)
|
||||
participant App as App Server
|
||||
participant IntRouter as Integration Router
|
||||
participant Manager as Integration Manager
|
||||
participant Conv as Conversation Service
|
||||
participant Sandbox as Sandbox
|
||||
|
||||
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
|
||||
|
||||
Ext->>App: POST /api/integration/{service}/events
|
||||
App->>IntRouter: Route to service handler
|
||||
Note over IntRouter: Verify signature (HMAC)
|
||||
|
||||
IntRouter->>Manager: Parse event payload
|
||||
Note over Manager: Extract context (repo, issue, user)
|
||||
Note over Manager: Map external user → OpenHands user
|
||||
|
||||
Manager->>Conv: Create conversation (with issue context)
|
||||
Conv->>Sandbox: Provision sandbox
|
||||
Sandbox-->>Conv: Ready
|
||||
|
||||
Manager->>Sandbox: Start agent with task
|
||||
|
||||
Note over Ext,Sandbox: Agent Works on Task...
|
||||
|
||||
Sandbox-->>Manager: Task complete
|
||||
Manager->>Ext: POST result<br/>(PR, comment, etc.)
|
||||
|
||||
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
|
||||
|
||||
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
|
||||
App->>Manager: Process callback
|
||||
Manager->>Ext: Update external service
|
||||
```
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
| Integration | Trigger Events | Agent Actions |
|
||||
|-------------|----------------|---------------|
|
||||
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
|
||||
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
|
||||
| **Slack** | @mention in channel | Reply in thread, create tasks |
|
||||
| **Jira** | Issue created/updated | Update ticket, add comments |
|
||||
| **Linear** | Issue created | Update status, add comments |
|
||||
|
||||
### Integration Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
|
||||
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
|
||||
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
|
||||
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
|
||||
|
||||
### Integration Authentication
|
||||
|
||||
```
|
||||
External Service (e.g., GitHub)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ GitHub App Installation │
|
||||
│ - Webhook secret for signature │
|
||||
│ - App private key for API calls │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ User Account Linking │
|
||||
│ - Keycloak user ID │
|
||||
│ - GitHub user ID │
|
||||
│ - Stored OAuth tokens │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Agent Execution │
|
||||
│ - Uses linked tokens for API │
|
||||
│ - Can push, create PRs, comment │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
@@ -60,7 +60,9 @@ class ResolverUserContext(UserContext):
|
||||
return provider_token.token.get_secret_value()
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 099
|
||||
Create Date: 2025-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '100'
|
||||
down_revision = '099'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user_settings', 'sandbox_grouping_strategy')
|
||||
op.drop_column('org', 'sandbox_grouping_strategy')
|
||||
op.drop_column('user', 'sandbox_grouping_strategy')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Add pending_messages table for server-side message queuing
|
||||
|
||||
Revision ID: 101
|
||||
Revises: 100
|
||||
Create Date: 2025-03-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '101'
|
||||
down_revision: Union[str, None] = '100'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create pending_messages table for storing messages before conversation is ready.
|
||||
|
||||
Messages are stored temporarily until the conversation becomes ready, then
|
||||
delivered and deleted regardless of success or failure.
|
||||
"""
|
||||
op.create_table(
|
||||
'pending_messages',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('conversation_id', sa.String(), nullable=False, index=True),
|
||||
sa.Column('role', sa.String(20), nullable=False, server_default='user'),
|
||||
sa.Column('content', sa.JSON, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove pending_messages table."""
|
||||
op.drop_table('pending_messages')
|
||||
208
enterprise/poetry.lock
generated
208
enterprise/poetry.lock
generated
@@ -602,14 +602,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
|
||||
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
|
||||
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
|
||||
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.13.0"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
|
||||
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6259,11 +6259,12 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-sdk = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
openhands-agent-server = "1.14"
|
||||
openhands-sdk = "1.14"
|
||||
openhands-tools = "1.14"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
pathspec = ">=0.12.1"
|
||||
pexpect = "*"
|
||||
pg8000 = ">=1.31.5"
|
||||
@@ -6275,7 +6276,7 @@ protobuf = ">=5.29.6,<6"
|
||||
psutil = "*"
|
||||
pybase62 = ">=1"
|
||||
pygithub = ">=2.5"
|
||||
pyjwt = ">=2.9"
|
||||
pyjwt = ">=2.12.0"
|
||||
pylatexenc = "*"
|
||||
pypdf = ">=6.7.2"
|
||||
python-docx = "*"
|
||||
@@ -6315,14 +6316,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.13.0"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
|
||||
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6345,14 +6346,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.13.0"
|
||||
version = "1.14.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
|
||||
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6560,99 +6561,86 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.5"
|
||||
version = "3.11.7"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"},
|
||||
{file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"},
|
||||
{file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7929,14 +7917,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.12.1"
|
||||
description = "JSON Web Token implementation in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
|
||||
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
|
||||
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
|
||||
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7944,9 +7932,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp
|
||||
|
||||
[package.extras]
|
||||
crypto = ["cryptography (>=3.4.0)"]
|
||||
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
|
||||
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pylatexenc"
|
||||
|
||||
@@ -77,6 +77,9 @@ PERMITTED_CORS_ORIGINS = [
|
||||
)
|
||||
]
|
||||
|
||||
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
|
||||
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
|
||||
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
171
enterprise/server/sharing/aws_shared_event_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Implementation of SharedEventService for AWS S3.
|
||||
|
||||
This implementation provides read-only access to events from shared conversations:
|
||||
- Validates that the conversation is shared before returning events
|
||||
- Uses existing EventService for actual event retrieval
|
||||
- Uses SharedConversationInfoService for shared conversation validation
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
import boto3
|
||||
from fastapi import Request
|
||||
from pydantic import Field
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoService,
|
||||
)
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.aws_event_service import AwsEventService
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.sdk import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AwsSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService for AWS S3 that validates shared access.
|
||||
|
||||
Uses role-based authentication (no credentials needed).
|
||||
"""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
s3_client: Any
|
||||
bucket_name: str
|
||||
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return AwsEventService(
|
||||
s3_client=self.s3_client,
|
||||
bucket_name=self.bucket_name,
|
||||
prefix=Path('users'),
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await event_service.get_event(conversation_id, event_id)
|
||||
|
||||
async def search_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> EventPage:
|
||||
"""Search events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return EventPage(items=[], next_page_id=None)
|
||||
|
||||
# If conversation is shared, search events for this conversation
|
||||
return await event_service.search_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def count_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
)
|
||||
|
||||
|
||||
class AwsSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
bucket_name: str | None = Field(
|
||||
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
|
||||
)
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedEventService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
bucket_name = self.bucket_name
|
||||
if bucket_name is None:
|
||||
raise ValueError(
|
||||
'bucket_name is required. Set FILE_STORE_PATH environment variable.'
|
||||
)
|
||||
|
||||
# Use role-based authentication - boto3 will automatically
|
||||
# use IAM role credentials when running in AWS
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=os.getenv('AWS_S3_ENDPOINT'),
|
||||
)
|
||||
|
||||
service = AwsSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
s3_client=s3_client,
|
||||
bucket_name=bucket_name,
|
||||
)
|
||||
yield service
|
||||
@@ -5,19 +5,45 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
from server.sharing.shared_event_service import (
|
||||
SharedEventService,
|
||||
SharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_service import SharedEventService
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.sdk import Event
|
||||
from openhands.utils.environment import StorageProvider, get_storage_provider
|
||||
|
||||
|
||||
def get_shared_event_service_injector() -> SharedEventServiceInjector:
|
||||
"""Get the appropriate SharedEventServiceInjector based on configuration.
|
||||
|
||||
Uses get_storage_provider() to determine the storage backend.
|
||||
See openhands.utils.environment for supported environment variables.
|
||||
|
||||
Note: Shared events only support AWS and GCP storage. Filesystem storage
|
||||
falls back to GCP for shared events.
|
||||
"""
|
||||
provider = get_storage_provider()
|
||||
|
||||
if provider == StorageProvider.AWS:
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return AwsSharedEventServiceInjector()
|
||||
else:
|
||||
# GCP is the default for shared events (including filesystem fallback)
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
|
||||
return GoogleCloudSharedEventServiceInjector()
|
||||
|
||||
|
||||
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
|
||||
shared_event_service_dependency = Depends(
|
||||
GoogleCloudSharedEventServiceInjector().depends
|
||||
)
|
||||
shared_event_service_dependency = Depends(get_shared_event_service_injector().depends)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
@@ -119,6 +119,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
@@ -141,6 +142,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
@@ -198,6 +200,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
) -> int:
|
||||
"""Count conversations matching the given filters with SAAS metadata."""
|
||||
query = (
|
||||
@@ -220,6 +223,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sandbox_id__eq=sandbox_id__eq,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
@@ -234,6 +238,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sandbox_id__eq: str | None = None,
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
@@ -259,6 +264,9 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
if sandbox_id__eq is not None:
|
||||
conditions.append(StoredConversationMetadata.sandbox_id == sandbox_id__eq)
|
||||
|
||||
if conditions:
|
||||
query = query.where(*conditions)
|
||||
return query
|
||||
|
||||
172
enterprise/server/utils/saas_pending_message_injector.py
Normal file
172
enterprise/server/utils/saas_pending_message_injector.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
|
||||
from openhands.agent_server.models import ImageContent, TextContent
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.pending_messages.pending_message_models import (
|
||||
PendingMessageResponse,
|
||||
)
|
||||
from openhands.app_server.pending_messages.pending_message_service import (
|
||||
PendingMessageService,
|
||||
PendingMessageServiceInjector,
|
||||
SQLPendingMessageService,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
|
||||
|
||||
class SaasSQLPendingMessageService(SQLPendingMessageService):
|
||||
"""Extended SQLPendingMessageService with user and organization-based filtering.
|
||||
|
||||
This enterprise version ensures that:
|
||||
- Users can only queue messages for conversations they own
|
||||
- Organization isolation is enforced for multi-tenant deployments
|
||||
"""
|
||||
|
||||
def __init__(self, db_session, user_context: UserContext):
|
||||
super().__init__(db_session=db_session)
|
||||
self.user_context = user_context
|
||||
|
||||
async def _get_current_user(self) -> User | None:
|
||||
"""Get the current user using the existing db_session.
|
||||
|
||||
Returns:
|
||||
User object or None if no user_id is available
|
||||
"""
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
return None
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
result = await self.db_session.execute(
|
||||
select(User).where(User.id == user_id_uuid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def _validate_conversation_ownership(self, conversation_id: str) -> None:
|
||||
"""Validate that the current user owns the conversation.
|
||||
|
||||
This ensures multi-tenant isolation by checking:
|
||||
- The conversation belongs to the current user
|
||||
- The conversation belongs to the user's current organization
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to validate (can be task-id or UUID)
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation or authentication fails
|
||||
"""
|
||||
# For internal operations (e.g., processing pending messages during startup)
|
||||
# we need a mode that bypasses filtering. The ADMIN context enables this.
|
||||
if self.user_context == ADMIN:
|
||||
return
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
raise AuthError('User authentication required')
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
# Check conversation ownership via SAAS metadata
|
||||
query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
# If no SAAS metadata exists, the conversation might be a new task-id
|
||||
# that hasn't been linked to a conversation yet. Allow access in this case
|
||||
# as the message will be validated when the conversation is created.
|
||||
if saas_metadata is None:
|
||||
return
|
||||
|
||||
# Verify user ownership
|
||||
if saas_metadata.user_id != user_id_uuid:
|
||||
raise AuthError('You do not have access to this conversation')
|
||||
|
||||
# Verify organization ownership if applicable
|
||||
user = await self._get_current_user()
|
||||
if user and user.current_org_id is not None:
|
||||
if saas_metadata.org_id != user.current_org_id:
|
||||
raise AuthError('Conversation belongs to a different organization')
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: list[TextContent | ImageContent],
|
||||
role: str = 'user',
|
||||
) -> PendingMessageResponse:
|
||||
"""Queue a message with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to queue the message for
|
||||
content: Message content
|
||||
role: Message role (default: 'user')
|
||||
|
||||
Returns:
|
||||
PendingMessageResponse with the queued message info
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().add_message(conversation_id, content, role)
|
||||
|
||||
async def get_pending_messages(self, conversation_id: str):
|
||||
"""Get pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to get messages for
|
||||
|
||||
Returns:
|
||||
List of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().get_pending_messages(conversation_id)
|
||||
|
||||
async def count_pending_messages(self, conversation_id: str) -> int:
|
||||
"""Count pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to count messages for
|
||||
|
||||
Returns:
|
||||
Number of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().count_pending_messages(conversation_id)
|
||||
|
||||
|
||||
class SaasPendingMessageServiceInjector(PendingMessageServiceInjector):
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[PendingMessageService, None]:
|
||||
from openhands.app_server.config import (
|
||||
get_db_session,
|
||||
get_user_context,
|
||||
)
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
service = SaasSQLPendingMessageService(
|
||||
db_session=db_session, user_context=user_context
|
||||
)
|
||||
yield service
|
||||
@@ -29,6 +29,15 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
try:
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if DEFAULT_INITIAL_BUDGET < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
"""Generate the key alias for OpenHands Cloud managed keys."""
|
||||
@@ -101,7 +110,7 @@ class LiteLlmManager:
|
||||
) as client:
|
||||
# Check if team already exists and get its budget
|
||||
# New users joining existing orgs should inherit the team's budget
|
||||
team_budget = 0.0
|
||||
team_budget: float = DEFAULT_INITIAL_BUDGET
|
||||
try:
|
||||
existing_team = await LiteLlmManager._get_team(client, org_id)
|
||||
if existing_team:
|
||||
|
||||
@@ -47,6 +47,7 @@ class Org(Base): # type: ignore
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
@@ -36,6 +37,8 @@ class OrgStore:
|
||||
org = Org(**kwargs)
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
|
||||
@@ -117,6 +117,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
|
||||
@@ -25,10 +25,10 @@ class SlackConversationStore:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_slack_conversation(
|
||||
self, slack_converstion: SlackConversation
|
||||
self, slack_conversation: SlackConversation
|
||||
) -> None:
|
||||
async with a_session_maker() as session:
|
||||
session.merge(slack_converstion)
|
||||
await session.merge(slack_conversation)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -33,6 +33,7 @@ class User(Base): # type: ignore
|
||||
email_verified = Column(Boolean, nullable=True)
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -27,6 +27,7 @@ class UserSettings(Base): # type: ignore
|
||||
)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
PERSONAL_WORKSPACE_VERSION_TO_MODEL,
|
||||
@@ -241,6 +242,10 @@ class UserStore:
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
# Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
user_kwargs = UserStore.get_kwargs_from_user_settings(
|
||||
decrypted_user_settings
|
||||
)
|
||||
@@ -892,6 +897,8 @@ class UserStore:
|
||||
language='en', enable_proactive_conversation_starters=True
|
||||
)
|
||||
|
||||
default_settings.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
settings = await LiteLlmManager.create_entries(
|
||||
|
||||
@@ -28,6 +28,7 @@ from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.slack_conversation import SlackConversation # noqa: F401
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
|
||||
@@ -791,3 +791,202 @@ class TestSaasSQLAppConversationInfoServiceWebhookFallback:
|
||||
assert len(user1_page.items) == 1
|
||||
assert user1_page.items[0].id == conv_id
|
||||
assert user1_page.items[0].title == 'E2E Webhook Conversation'
|
||||
|
||||
|
||||
class TestSandboxIdFilterSaas:
|
||||
"""Test suite for sandbox_id__eq filter parameter in SAAS service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test searching conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs for user1
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Alpha',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_beta',
|
||||
title='Conversation Beta',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_alpha',
|
||||
title='Conversation Gamma',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Search for sandbox_alpha - should return 2 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_alpha'
|
||||
)
|
||||
assert len(page.items) == 2
|
||||
sandbox_ids = {item.sandbox_id for item in page.items}
|
||||
assert sandbox_ids == {'sandbox_alpha'}
|
||||
conversation_ids = {item.id for item in page.items}
|
||||
assert conv1.id in conversation_ids
|
||||
assert conv3.id in conversation_ids
|
||||
|
||||
# Search for sandbox_beta - should return 1 conversation
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_beta'
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv2.id
|
||||
|
||||
# Search for non-existent sandbox - should return 0 conversations
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert len(page.items) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_by_sandbox_id(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test counting conversations by exact sandbox_id match with SAAS user filtering."""
|
||||
# Create service for user1
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
|
||||
# Create conversations with different sandbox IDs
|
||||
conv1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X1',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_y',
|
||||
title='Conversation Y1',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv3 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_x',
|
||||
title='Conversation X2',
|
||||
created_at=datetime(2024, 1, 1, 14, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 14, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save all conversations
|
||||
await user1_service.save_app_conversation_info(conv1)
|
||||
await user1_service.save_app_conversation_info(conv2)
|
||||
await user1_service.save_app_conversation_info(conv3)
|
||||
|
||||
# Count for sandbox_x - should be 2
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_x'
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
# Count for sandbox_y - should be 1
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_y'
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Count for non-existent sandbox - should be 0
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq='sandbox_nonexistent'
|
||||
)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandbox_id_filter_respects_user_isolation(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that sandbox_id filter respects user isolation in SAAS environment."""
|
||||
# Create services for both users
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user2_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER2_ID)),
|
||||
)
|
||||
|
||||
# Create conversation with same sandbox_id for both users
|
||||
shared_sandbox_id = 'sandbox_shared'
|
||||
|
||||
conv_user1 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User1 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
conv_user2 = AppConversationInfo(
|
||||
id=uuid4(),
|
||||
created_by_user_id=str(USER2_ID),
|
||||
sandbox_id=shared_sandbox_id,
|
||||
title='User2 Conversation',
|
||||
created_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2024, 1, 1, 13, 30, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
# Save conversations
|
||||
await user1_service.save_app_conversation_info(conv_user1)
|
||||
await user2_service.save_app_conversation_info(conv_user2)
|
||||
|
||||
# User1 should only see their own conversation with this sandbox_id
|
||||
page = await user1_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user1.id
|
||||
assert page.items[0].title == 'User1 Conversation'
|
||||
|
||||
# User2 should only see their own conversation with this sandbox_id
|
||||
page = await user2_service.search_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].id == conv_user2.id
|
||||
assert page.items[0].title == 'User2 Conversation'
|
||||
|
||||
# Count should also respect user isolation
|
||||
count = await user1_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
count = await user2_service.count_app_conversation_info(
|
||||
sandbox_id__eq=shared_sandbox_id
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Unit tests for SlackConversationStore."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_conversation_store():
|
||||
"""Create SlackConversationStore instance."""
|
||||
return SlackConversationStore()
|
||||
|
||||
|
||||
class TestSlackConversationStore:
|
||||
"""Test cases for SlackConversationStore."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_slack_conversation_persists_to_database(
|
||||
self, slack_conversation_store, async_session_maker
|
||||
):
|
||||
"""Test that create_slack_conversation actually stores data in the database.
|
||||
|
||||
This test verifies that the await statement is present before session.merge().
|
||||
Without the await, the data won't be persisted and subsequent lookups will
|
||||
return None even though we just created the conversation.
|
||||
"""
|
||||
channel_id = 'C123456'
|
||||
parent_id = '1234567890.123456'
|
||||
conversation_id = 'conv-test-123'
|
||||
keycloak_user_id = 'user-123'
|
||||
|
||||
slack_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id=channel_id,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
'storage.slack_conversation_store.a_session_maker', async_session_maker
|
||||
):
|
||||
# Create the slack conversation
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
# Verify we can retrieve the conversation using the store method
|
||||
result = await slack_conversation_store.get_slack_conversation(
|
||||
channel_id=channel_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
# This assertion would fail if the await was missing before session.merge()
|
||||
# because the data wouldn't be persisted to the database
|
||||
assert result is not None, (
|
||||
'Slack conversation was not persisted to the database. '
|
||||
'Ensure await is used before session.merge() in create_slack_conversation.'
|
||||
)
|
||||
assert result.conversation_id == conversation_id
|
||||
assert result.channel_id == channel_id
|
||||
assert result.parent_id == parent_id
|
||||
assert result.keycloak_user_id == keycloak_user_id
|
||||
|
||||
# Also verify directly in the database
|
||||
async with async_session_maker() as session:
|
||||
db_result = await session.execute(
|
||||
select(SlackConversation).where(
|
||||
SlackConversation.channel_id == channel_id,
|
||||
SlackConversation.parent_id == parent_id,
|
||||
)
|
||||
)
|
||||
db_conversation = db_result.scalar_one_or_none()
|
||||
assert db_conversation is not None
|
||||
assert db_conversation.conversation_id == conversation_id
|
||||
@@ -2,7 +2,9 @@
|
||||
Unit tests for LiteLlmManager class.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -21,6 +23,71 @@ from storage.user_settings import UserSettings
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
class TestDefaultInitialBudget:
|
||||
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_module_state(self):
|
||||
"""Ensure module is properly restored after each test."""
|
||||
# Save original module if it exists
|
||||
original_module = sys.modules.get('storage.lite_llm_manager')
|
||||
|
||||
yield
|
||||
|
||||
# Restore module state after each test
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
sys.modules['storage.lite_llm_manager'] = original_module
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var and reimport
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value)
|
||||
|
||||
def test_default_initial_budget_rejects_negative_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'must be non-negative' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestLiteLlmManager:
|
||||
"""Test cases for LiteLlmManager class."""
|
||||
|
||||
@@ -242,10 +309,10 @@ class TestLiteLlmManager:
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_zero_budget(
|
||||
async def test_create_entries_new_org_uses_default_initial_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses budget=0 for new org (team doesn't exist)."""
|
||||
"""Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
@@ -273,6 +340,7 @@ class TestLiteLlmManager:
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
@@ -280,16 +348,67 @@ class TestLiteLlmManager:
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with budget=0
|
||||
# Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == 0.0
|
||||
|
||||
# Verify _add_user_to_team was called with budget=0
|
||||
# Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_custom_default_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
custom_budget = 50.0
|
||||
with (
|
||||
patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == custom_budget
|
||||
|
||||
# Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_propagates_non_404_errors(self, mock_settings):
|
||||
"""Test that create_entries propagates non-404 errors from _get_team."""
|
||||
|
||||
@@ -144,6 +144,86 @@ async def test_create_org(async_session_maker, mock_litellm_api):
|
||||
assert org.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to True
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to False
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_false_overrides_default_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay False (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_true_overrides_default_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay True (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_by_name(async_session_maker, mock_litellm_api):
|
||||
# Test getting org by name
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
"""Tests for AwsSharedEventService."""
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from server.sharing.aws_shared_event_service import (
|
||||
AwsSharedEventService,
|
||||
AwsSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import SharedConversation
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_shared_conversation_info_service():
|
||||
"""Create a mock SharedConversationInfoService."""
|
||||
return AsyncMock(spec=SharedConversationInfoService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_client():
|
||||
"""Create a mock S3 client."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_service():
|
||||
"""Create a mock EventService for returned by get_event_service."""
|
||||
return AsyncMock(spec=EventService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aws_shared_event_service(mock_shared_conversation_info_service, mock_s3_client):
|
||||
"""Create an AwsSharedEventService for testing."""
|
||||
return AwsSharedEventService(
|
||||
shared_conversation_info_service=mock_shared_conversation_info_service,
|
||||
s3_client=mock_s3_client,
|
||||
bucket_name='test-bucket',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_public_conversation():
|
||||
"""Create a sample public conversation."""
|
||||
return SharedConversation(
|
||||
id=uuid4(),
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test_sandbox',
|
||||
title='Test Public Conversation',
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
metrics=MetricsSnapshot(
|
||||
accumulated_cost=0.0,
|
||||
max_budget_per_task=10.0,
|
||||
accumulated_token_usage=TokenUsage(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_event():
|
||||
"""Create a sample event."""
|
||||
# For testing purposes, we'll just use a mock that the EventPage can accept
|
||||
# The actual event creation is complex and not the focus of these tests
|
||||
return None
|
||||
|
||||
|
||||
class TestAwsSharedEventService:
|
||||
"""Test cases for AwsSharedEventService."""
|
||||
|
||||
async def test_get_shared_event_returns_event_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that get_shared_event returns an event for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock the public conversation service to return a public conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return an event
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == sample_event
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.get_event.assert_called_once_with(conversation_id, event_id)
|
||||
|
||||
async def test_get_shared_event_returns_none_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that get_shared_event returns None for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
event_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_shared_event(
|
||||
conversation_id, event_id
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called since get_event_service returns None
|
||||
mock_event_service.get_event.assert_not_called()
|
||||
|
||||
async def test_search_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that search_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_page = EventPage(items=[], next_page_id=None)
|
||||
mock_event_service.search_events.return_value = mock_event_page
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == mock_event_page
|
||||
assert len(result.items) == 0 # Empty list as we mocked
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.search_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
sort_order=EventSortOrder.TIMESTAMP,
|
||||
page_id=None,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
async def test_search_shared_events_returns_empty_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that search_shared_events returns empty page for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.search_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, EventPage)
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.search_events.assert_not_called()
|
||||
|
||||
async def test_count_shared_events_returns_count_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that count_shared_events returns count for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return a count
|
||||
mock_event_service.count_events.return_value = 5
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 5
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
mock_event_service.count_events.assert_called_once_with(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq='ActionEvent',
|
||||
timestamp__gte=None,
|
||||
timestamp__lt=None,
|
||||
)
|
||||
|
||||
async def test_count_shared_events_returns_zero_for_private_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
):
|
||||
"""Test that count_shared_events returns 0 for a private conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock get_event_service to return None (private conversation)
|
||||
aws_shared_event_service.get_event_service = AsyncMock(return_value=None)
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.count_shared_events(
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result == 0
|
||||
|
||||
aws_shared_event_service.get_event_service.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
# Event service should not be called
|
||||
mock_event_service.count_events.assert_not_called()
|
||||
|
||||
async def test_batch_get_shared_events_returns_events_for_public_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
mock_event_service,
|
||||
sample_public_conversation,
|
||||
sample_event,
|
||||
):
|
||||
"""Test that batch_get_shared_events returns events for a public conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
event_ids = [uuid4() for _ in range(3)]
|
||||
|
||||
# Mock get_event_service to return our mock event service
|
||||
aws_shared_event_service.get_event_service = AsyncMock(
|
||||
return_value=mock_event_service
|
||||
)
|
||||
|
||||
# Mock the event service to return events
|
||||
mock_event_service.get_event.return_value = sample_event
|
||||
|
||||
# Call the method
|
||||
results = await aws_shared_event_service.batch_get_shared_events(
|
||||
conversation_id, event_ids
|
||||
)
|
||||
|
||||
# Verify the results
|
||||
assert len(results) == 3
|
||||
assert all(result == sample_event for result in results)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceGetEventService:
|
||||
"""Test cases for AwsSharedEventService.get_event_service method."""
|
||||
|
||||
async def test_get_event_service_returns_event_service_for_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
sample_public_conversation,
|
||||
):
|
||||
"""Test that get_event_service returns an EventService for a shared conversation."""
|
||||
conversation_id = sample_public_conversation.id
|
||||
|
||||
# Mock the shared conversation info service to return a shared conversation
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = sample_public_conversation
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is not None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
async def test_get_event_service_returns_none_for_non_shared_conversation(
|
||||
self,
|
||||
aws_shared_event_service,
|
||||
mock_shared_conversation_info_service,
|
||||
):
|
||||
"""Test that get_event_service returns None for a non-shared conversation."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Mock the shared conversation info service to return None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.return_value = None
|
||||
|
||||
# Call the method
|
||||
result = await aws_shared_event_service.get_event_service(conversation_id)
|
||||
|
||||
# Verify the result
|
||||
assert result is None
|
||||
mock_shared_conversation_info_service.get_shared_conversation_info.assert_called_once_with(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
|
||||
class TestAwsSharedEventServiceInjector:
|
||||
"""Test cases for AwsSharedEventServiceInjector."""
|
||||
|
||||
def test_bucket_name_from_environment_variable(self):
|
||||
"""Test that bucket_name is read from FILE_STORE_PATH environment variable."""
|
||||
test_bucket_name = 'test-bucket-name'
|
||||
with patch.dict(os.environ, {'FILE_STORE_PATH': test_bucket_name}):
|
||||
# Create a new injector instance to pick up the environment variable
|
||||
# Note: The class attribute is evaluated at class definition time,
|
||||
# so we need to test that the attribute exists and can be overridden
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = os.environ.get('FILE_STORE_PATH')
|
||||
assert injector.bucket_name == test_bucket_name
|
||||
|
||||
def test_bucket_name_default_value_when_env_not_set(self):
|
||||
"""Test that bucket_name is None when FILE_STORE_PATH is not set."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove FILE_STORE_PATH if it exists
|
||||
os.environ.pop('FILE_STORE_PATH', None)
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
# The bucket_name will be whatever was set at class definition time
|
||||
# or None if FILE_STORE_PATH was not set when the class was defined
|
||||
assert hasattr(injector, 'bucket_name')
|
||||
|
||||
async def test_injector_yields_aws_shared_event_service(self):
|
||||
"""Test that the injector yields an AwsSharedEventService instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service is an instance of AwsSharedEventService
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
assert service.s3_client == mock_s3_client
|
||||
assert service.bucket_name == 'test-bucket'
|
||||
|
||||
async def test_injector_uses_bucket_name_from_instance(self):
|
||||
"""Test that the injector uses the bucket_name from the instance."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector with a specific bucket name
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'my-custom-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
assert service.bucket_name == 'my-custom-bucket'
|
||||
|
||||
async def test_injector_creates_sql_shared_conversation_info_service(self):
|
||||
"""Test that the injector creates SQLSharedConversationInfoService with db_session."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.SQLSharedConversationInfoService'
|
||||
) as mock_sql_service_class,
|
||||
):
|
||||
mock_sql_service = MagicMock()
|
||||
mock_sql_service_class.return_value = mock_sql_service
|
||||
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
# Verify the service has the correct shared_conversation_info_service
|
||||
assert service.shared_conversation_info_service == mock_sql_service
|
||||
|
||||
# Verify SQLSharedConversationInfoService was created with db_session
|
||||
mock_sql_service_class.assert_called_once_with(db_session=mock_db_session)
|
||||
|
||||
async def test_injector_works_without_request(self):
|
||||
"""Test that the injector works when request is None."""
|
||||
mock_state = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
):
|
||||
# Call the inject method with request=None
|
||||
async for service in injector.inject(mock_state, request=None):
|
||||
assert isinstance(service, AwsSharedEventService)
|
||||
|
||||
async def test_injector_uses_role_based_authentication(self):
|
||||
"""Test that the injector uses role-based authentication (no explicit credentials)."""
|
||||
mock_state = MagicMock()
|
||||
mock_request = MagicMock()
|
||||
mock_db_session = AsyncMock()
|
||||
|
||||
# Create the injector
|
||||
injector = AwsSharedEventServiceInjector()
|
||||
injector.bucket_name = 'test-bucket'
|
||||
|
||||
# Mock the get_db_session context manager
|
||||
mock_db_context = AsyncMock()
|
||||
mock_db_context.__aenter__.return_value = mock_db_session
|
||||
mock_db_context.__aexit__.return_value = None
|
||||
|
||||
# Mock boto3.client
|
||||
mock_s3_client = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.sharing.aws_shared_event_service.boto3.client',
|
||||
return_value=mock_s3_client,
|
||||
) as mock_boto3_client,
|
||||
patch(
|
||||
'openhands.app_server.config.get_db_session',
|
||||
return_value=mock_db_context,
|
||||
),
|
||||
patch.dict(os.environ, {'AWS_S3_ENDPOINT': 'https://s3.example.com'}),
|
||||
):
|
||||
# Call the inject method
|
||||
async for service in injector.inject(mock_state, mock_request):
|
||||
pass
|
||||
|
||||
# Verify boto3.client was called with 's3' and endpoint_url
|
||||
# but without explicit credentials (role-based auth)
|
||||
mock_boto3_client.assert_called_once_with(
|
||||
's3',
|
||||
endpoint_url='https://s3.example.com',
|
||||
)
|
||||
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
171
enterprise/tests/unit/test_sharing/test_shared_event_router.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for shared_event_router provider selection.
|
||||
|
||||
This module tests the get_shared_event_service_injector function which
|
||||
determines which SharedEventServiceInjector to use based on environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from server.sharing.aws_shared_event_service import AwsSharedEventServiceInjector
|
||||
from server.sharing.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_event_router import get_shared_event_service_injector
|
||||
|
||||
|
||||
class TestGetSharedEventServiceInjector:
|
||||
"""Test cases for get_shared_event_service_injector function."""
|
||||
|
||||
def test_defaults_to_google_cloud_when_no_env_set(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when no env is set."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
os.environ.pop('FILE_STORE', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_google_cloud_when_file_store_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when FILE_STORE=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
os.environ.pop('SHARED_EVENT_STORAGE_PROVIDER', None)
|
||||
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_aws_when_provider_aws(self):
|
||||
"""Test that AwsSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=aws."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_gcp(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=gcp."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_uses_gcp_when_provider_google_cloud(self):
|
||||
"""Test that GoogleCloudSharedEventServiceInjector is used when SHARED_EVENT_STORAGE_PROVIDER=google_cloud."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_takes_precedence_over_file_store(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER takes precedence over FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'aws',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use AWS because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_gcp_takes_precedence_over_file_store_s3(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER=gcp takes precedence over FILE_STORE=s3."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'gcp',
|
||||
'FILE_STORE': 's3',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should use GCP because SHARED_EVENT_STORAGE_PROVIDER takes precedence
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_aws(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for AWS."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'AWS',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, AwsSharedEventServiceInjector)
|
||||
|
||||
def test_provider_is_case_insensitive_gcp(self):
|
||||
"""Test that SHARED_EVENT_STORAGE_PROVIDER is case insensitive for GCP."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'GCP',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_unknown_provider_defaults_to_google_cloud(self):
|
||||
"""Test that unknown provider defaults to GoogleCloudSharedEventServiceInjector."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': 'unknown_provider',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
|
||||
def test_empty_provider_falls_back_to_file_store(self):
|
||||
"""Test that empty SHARED_EVENT_STORAGE_PROVIDER falls back to FILE_STORE."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'SHARED_EVENT_STORAGE_PROVIDER': '',
|
||||
'FILE_STORE': 'google_cloud',
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
injector = get_shared_event_service_injector()
|
||||
|
||||
# Should default to GCP for unknown providers
|
||||
assert isinstance(injector, GoogleCloudSharedEventServiceInjector)
|
||||
@@ -101,6 +101,72 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
|
||||
assert settings.llm_base_url == 'http://test.url'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_true_when_default_is_true(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to True
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', True),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_false_when_default_is_false(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to False
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', False),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is False
|
||||
|
||||
|
||||
# --- Tests for get_user_by_id ---
|
||||
|
||||
|
||||
@@ -1243,3 +1309,19 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
|
||||
assert (
|
||||
row.org_id == user_uuid_str
|
||||
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
|
||||
|
||||
|
||||
# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org:
|
||||
# if org.v1_enabled is None:
|
||||
# org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
#
|
||||
# This behavior is tested in test_org_store.py via:
|
||||
# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true
|
||||
# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false
|
||||
# - test_create_org_v1_enabled_explicit_false_overrides_default_true
|
||||
# - test_create_org_v1_enabled_explicit_true_overrides_default_false
|
||||
#
|
||||
# Testing migrate_user directly is impractical due to its complex raw SQL migration
|
||||
# statements that have SQLite/UUID compatibility issues in the test environment.
|
||||
# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify
|
||||
# the SQL operations work correctly with proper type handling.
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -8,3 +8,4 @@ node_modules/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.react-router/
|
||||
ralph/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
|
||||
import axios from "axios";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
@@ -6,6 +7,8 @@ vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
describe("V1ConversationService", () => {
|
||||
describe("readConversationFile", () => {
|
||||
it("uses default plan path when filePath is not provided", async () => {
|
||||
@@ -24,4 +27,91 @@ describe("V1ConversationService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.post as Mock).mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("uses query params for file upload path", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
const uploadPath = "/workspace/custom/path.txt";
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
uploadPath,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Verify URL uses query params format
|
||||
expect(callUrl).toContain("/api/file/upload?");
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
|
||||
|
||||
// Verify it's NOT using path params format
|
||||
expect(callUrl).not.toContain("/api/file/upload/%2F");
|
||||
});
|
||||
|
||||
it("uses default workspace path when no path provided", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Default path should be /workspace/{filename}
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
|
||||
});
|
||||
|
||||
it("sends file as FormData with correct headers", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (axios.post as Mock).mock.calls[0];
|
||||
|
||||
// Verify FormData is sent
|
||||
const formData = callArgs[1];
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get("file")).toBe(file);
|
||||
|
||||
// Verify headers include session API key and content type
|
||||
const headers = callArgs[2].headers;
|
||||
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
|
||||
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
|
||||
|
||||
it("should render the out of credits message when the user is out of credits", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - We only care about the app_mode and feature_flags fields
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { renderWithProviders } from "../../../test-utils";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createMockWebClientConfig } from "../../helpers/mock-config";
|
||||
|
||||
const mockTrackAddTeamMembersButtonClick = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackAddTeamMembersButtonClick: mockTrackAddTeamMembersButtonClick,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock posthog feature flag
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
useFeatureFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module to get access to the mock
|
||||
import * as posthog from "posthog-js/react";
|
||||
|
||||
describe("AccountSettingsContextMenu", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// Set default feature flag to false
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: React.ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
|
||||
const renderWithSaasConfig = (ui: React.ReactElement, options?: { analyticsConsent?: boolean }) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "saas" }));
|
||||
queryClient.setQueryData(["settings"], { user_consents_to_analytics: options?.analyticsConsent ?? true });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWithOssConfig = (ui: React.ReactElement) => {
|
||||
queryClient.setQueryData(["web-client-config"], createMockWebClientConfig({ app_mode: "oss" }));
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
mockTrackAddTeamMembersButtonClick.mockClear();
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Documentation link with correct attributes", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(documentationLink).toHaveAttribute("target", "_blank");
|
||||
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should show Add Team Members button in SaaS mode when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-team-members-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in SaaS mode when feature flag is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(false);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button in OSS mode even when feature flag is enabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithOssConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Add Team Members button when analytics consent is disabled", () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
{ analyticsConsent: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("add-team-members-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAV_ADD_TEAM_MEMBERS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call tracking function and onClose when Add Team Members button is clicked", async () => {
|
||||
vi.mocked(posthog.useFeatureFlagEnabled).mockReturnValue(true);
|
||||
renderWithSaasConfig(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addTeamMembersButton = screen.getByTestId("add-team-members-button");
|
||||
await user.click(addTeamMembersButton);
|
||||
|
||||
expect(mockTrackAddTeamMembersButtonClick).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ describe("SystemMessage UI Rendering", () => {
|
||||
<ToolsContextMenu
|
||||
onClose={() => {}}
|
||||
onShowSkills={() => {}}
|
||||
onShowHooks={() => {}}
|
||||
onShowAgentTools={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
bitbucket: "https://bitbucket.org/site/oauth2/authorize",
|
||||
bitbucket_data_center:
|
||||
"https://bitbucket-dc.example.com/site/oauth2/authorize",
|
||||
enterprise_sso: "https://auth.example.com/realms/test/protocol/openid-connect/auth",
|
||||
};
|
||||
if (config.appMode === "saas") {
|
||||
return urls[config.identityProvider] || null;
|
||||
@@ -117,6 +118,74 @@ describe("LoginContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO button when configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Enterprise SSO alongside other providers when all configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["github", "gitlab", "bitbucket", "enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /BITBUCKET\$CONNECT_TO_BITBUCKET/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to Enterprise SSO auth URL when Enterprise SSO button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUrl = "https://auth.example.com/realms/test/protocol/openid-connect/auth";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
authUrl="https://auth.example.com"
|
||||
providersConfigured={["enterprise_sso"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const enterpriseSsoButton = screen.getByRole("button", {
|
||||
name: /ENTERPRISE_SSO\$CONNECT_TO_ENTERPRISE_SSO/i,
|
||||
});
|
||||
await user.click(enterpriseSsoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toContain(mockUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display message when no providers are configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
|
||||
import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
|
||||
import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
|
||||
import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
HOOKS_MODAL$TITLE: "Available Hooks",
|
||||
HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`,
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
|
||||
HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
|
||||
HOOKS_MODAL$EVENT_SESSION_END: "Session End",
|
||||
HOOKS_MODAL$EVENT_STOP: "Stop",
|
||||
HOOKS_MODAL$MATCHER: "Matcher",
|
||||
HOOKS_MODAL$COMMANDS: "Commands",
|
||||
HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
|
||||
HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
|
||||
HOOKS_MODAL$ASYNC: "Async",
|
||||
COMMON$FETCH_ERROR: "Failed to fetch data",
|
||||
CONVERSATION$NO_HOOKS: "No hooks configured",
|
||||
BUTTON$REFRESH: "Refresh",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("HooksLoadingState", () => {
|
||||
it("should render loading spinner", () => {
|
||||
render(<HooksLoadingState />);
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksEmptyState", () => {
|
||||
it("should render no hooks message when not error", () => {
|
||||
render(<HooksEmptyState isError={false} />);
|
||||
expect(screen.getByText("No hooks configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error message when isError is true", () => {
|
||||
render(<HooksEmptyState isError={true} />);
|
||||
expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksModalHeader", () => {
|
||||
const defaultProps = {
|
||||
isAgentReady: true,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render title", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByText("Available Hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render refresh button when agent is ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render refresh button when agent is not ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isAgentReady={false} />);
|
||||
expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRefresh when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefresh = vi.fn();
|
||||
render(<HooksModalHeader {...defaultProps} onRefresh={onRefresh} />);
|
||||
|
||||
await user.click(screen.getByTestId("refresh-hooks"));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should disable refresh button when loading", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable refresh button when refetching", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isRefetching={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HookEventItem", () => {
|
||||
const mockHookEvent: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: ".openhands/hooks/on_stop.sh",
|
||||
timeout: 30,
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
hookEvent: mockHookEvent,
|
||||
isExpanded: false,
|
||||
onToggle: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render event type label using i18n", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render hook count", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("1 hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onToggle when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<HookEventItem {...defaultProps} onToggle={onToggle} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("should show collapsed state by default", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={false} />);
|
||||
// Matcher content should not be visible when collapsed
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show expanded state with matcher content", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
// Matcher content should be visible when expanded
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render async badge for async hooks", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
expect(screen.getByText("Async")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render different event types with correct i18n labels", () => {
|
||||
const eventTypes = [
|
||||
{ type: "pre_tool_use", label: "Pre Tool Use" },
|
||||
{ type: "post_tool_use", label: "Post Tool Use" },
|
||||
{ type: "user_prompt_submit", label: "User Prompt Submit" },
|
||||
{ type: "session_start", label: "Session Start" },
|
||||
{ type: "session_end", label: "Session End" },
|
||||
{ type: "stop", label: "Stop" },
|
||||
];
|
||||
|
||||
eventTypes.forEach(({ type, label }) => {
|
||||
const { unmount } = render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: type }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to event_type when no i18n key exists", () => {
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: "unknown_event" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("unknown_event")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ vi.mock("react-i18next", async () => {
|
||||
CONVERSATION$SHOW_SKILLS: "Show Skills",
|
||||
BUTTON$DISPLAY_COST: "Display Cost",
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation (Stop Runtime)",
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
COMMON$DELETE_CONVERSATION: "Delete Conversation",
|
||||
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
|
||||
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
|
||||
@@ -565,7 +565,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
"Delete Conversation",
|
||||
);
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"Close Conversation (Stop Runtime)",
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
);
|
||||
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
|
||||
"Display Cost",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
|
||||
const mockCapture = vi.fn();
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: mockCapture,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the self-hosted label", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise title", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise description", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all four enterprise feature items", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_SUPPORT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE");
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More Link Interaction", () => {
|
||||
it("should capture PostHog event when learn more link is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
await user.click(link);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry");
|
||||
});
|
||||
|
||||
it("should have correct href attribute for opening in new tab", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmRemoveMemberModal } from "#/components/features/org/confirm-remove-member-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string };
|
||||
components: { email: React.ReactElement };
|
||||
}) => React.cloneElement(components.email, {}, values.email),
|
||||
}));
|
||||
|
||||
describe("ConfirmRemoveMemberModal", () => {
|
||||
it("should display the member email in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmRemoveMemberModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConfirmUpdateRoleModal } from "#/components/features/org/confirm-update-role-modal";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
Trans: ({
|
||||
values,
|
||||
components,
|
||||
}: {
|
||||
values: { email: string; role: string };
|
||||
components: { email: React.ReactElement; role: React.ReactElement };
|
||||
}) => (
|
||||
<>
|
||||
{React.cloneElement(components.email, {}, values.email)}
|
||||
{React.cloneElement(components.role, {}, values.role)}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ConfirmUpdateRoleModal", () => {
|
||||
it("should display the member email and new role in the confirmation message", () => {
|
||||
// Arrange
|
||||
const memberEmail = "test@example.com";
|
||||
const newRole = "admin";
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail={memberEmail}
|
||||
newRole={newRole}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(memberEmail)).toBeInTheDocument();
|
||||
expect(screen.getByText(newRole)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onConfirm when the confirm button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onConfirmMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={onConfirmMock}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Assert
|
||||
expect(onConfirmMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onCancel when the cancel button is clicked", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
const onCancelMock = vi.fn();
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancelMock}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Assert
|
||||
expect(onCancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should disable buttons and show loading spinner when isLoading is true", () => {
|
||||
// Arrange & Act
|
||||
renderWithProviders(
|
||||
<ConfirmUpdateRoleModal
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
memberEmail="test@example.com"
|
||||
newRole="admin"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId("confirm-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("cancel-button")).toBeDisabled();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { within, screen, render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { InviteOrganizationMemberModal } from "#/components/features/org/invite-organization-member-modal";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: vi.fn(() => ({ revalidate: vi.fn() })),
|
||||
}));
|
||||
|
||||
const renderInviteOrganizationMemberModal = (config?: {
|
||||
onClose: () => void;
|
||||
}) =>
|
||||
render(
|
||||
<InviteOrganizationMemberModal onClose={config?.onClose || vi.fn()} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
describe("InviteOrganizationMemberModal", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should call onClose the modal when the close button is clicked", async () => {
|
||||
const onCloseMock = vi.fn();
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
const closeButton = within(modal).getByRole("button", {
|
||||
name: /close/i,
|
||||
});
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call the batch API to invite a single team member when the form is submitted", async () => {
|
||||
const inviteMembersBatchSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"inviteMembers",
|
||||
);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
|
||||
const badgeInput = within(modal).getByTestId("emails-badge-input");
|
||||
await userEvent.type(badgeInput, "someone@acme.org ");
|
||||
|
||||
// Verify badge is displayed
|
||||
expect(screen.getByText("someone@acme.org")).toBeInTheDocument();
|
||||
|
||||
const submitButton = within(modal).getByRole("button", {
|
||||
name: /add/i,
|
||||
});
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
orgId: "1",
|
||||
emails: ["someone@acme.org"],
|
||||
});
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should allow adding multiple emails using badge input and make a batch POST request", async () => {
|
||||
const inviteMembersBatchSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"inviteMembers",
|
||||
);
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
renderInviteOrganizationMemberModal({ onClose: onCloseMock });
|
||||
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
|
||||
// Should have badge input instead of regular input
|
||||
const badgeInput = within(modal).getByTestId("emails-badge-input");
|
||||
expect(badgeInput).toBeInTheDocument();
|
||||
|
||||
// Add first email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user1@acme.org ");
|
||||
|
||||
// Add second email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user2@acme.org ");
|
||||
|
||||
// Add third email by typing and pressing space
|
||||
await userEvent.type(badgeInput, "user3@acme.org ");
|
||||
|
||||
// Verify badges are displayed
|
||||
expect(screen.getByText("user1@acme.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("user2@acme.org")).toBeInTheDocument();
|
||||
expect(screen.getByText("user3@acme.org")).toBeInTheDocument();
|
||||
|
||||
const submitButton = within(modal).getByRole("button", {
|
||||
name: /add/i,
|
||||
});
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should call batch invite API with all emails
|
||||
expect(inviteMembersBatchSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
orgId: "1",
|
||||
emails: ["user1@acme.org", "user2@acme.org", "user3@acme.org"],
|
||||
});
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display an error toast when clicking add button with no emails added", async () => {
|
||||
// Arrange
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
const inviteMembersSpy = vi.spyOn(organizationService, "inviteMembers");
|
||||
renderInviteOrganizationMemberModal();
|
||||
|
||||
// Act
|
||||
const modal = screen.getByTestId("invite-modal");
|
||||
const submitButton = within(modal).getByRole("button", { name: /add/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Assert
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith(
|
||||
"ORG$NO_EMAILS_ADDED_HINT",
|
||||
);
|
||||
expect(inviteMembersSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
203
frontend/__tests__/components/features/org/org-selector.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { screen, render, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { OrgSelector } from "#/components/features/org/org-selector";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import {
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
createMockOrganization,
|
||||
} from "#/mocks/org-handlers";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
useNavigate: () => vi.fn(),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useMatch: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({ data: { app_mode: "saas" } }),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"ORG$SELECT_ORGANIZATION_PLACEHOLDER": "Please select an organization",
|
||||
"ORG$PERSONAL_WORKSPACE": "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderOrgSelector = () =>
|
||||
render(<OrgSelector />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("OrgSelector", () => {
|
||||
it("should not render when user only has a personal workspace", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
const { container } = renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render when user only has a team organization", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const { container } = renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
});
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading indicator when fetching organizations", () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockImplementation(
|
||||
() => new Promise(() => {}), // never resolves
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// The dropdown trigger should be disabled while loading
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(trigger).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should select the first organization after orgs are loaded", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// The combobox input should show the first org name
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Personal Workspace");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show all options when dropdown is opened", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
createMockOrganization("3", "Test Organization", 500),
|
||||
],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
// Wait for the selector to be populated with the first organization
|
||||
await waitFor(() => {
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Click the trigger to open dropdown
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Verify all 3 options are visible
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const options = within(listbox).getAllByRole("option");
|
||||
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options[0]).toHaveTextContent("Personal Workspace");
|
||||
expect(options[1]).toHaveTextContent("Acme Corp");
|
||||
expect(options[2]).toHaveTextContent("Test Organization");
|
||||
});
|
||||
|
||||
it("should call switchOrganization API when selecting a different organization", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
const switchOrgSpy = vi
|
||||
.spyOn(organizationService, "switchOrganization")
|
||||
.mockResolvedValue(MOCK_TEAM_ORG_ACME);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const acmeOption = within(listbox).getByText("Acme Corp");
|
||||
await user.click(acmeOption);
|
||||
|
||||
// Assert
|
||||
expect(switchOrgSpy).toHaveBeenCalledWith({ orgId: MOCK_TEAM_ORG_ACME.id });
|
||||
});
|
||||
|
||||
it("should show loading state while switching organizations", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "switchOrganization").mockImplementation(
|
||||
() => new Promise(() => {}), // never resolves to keep loading state
|
||||
);
|
||||
|
||||
renderOrgSelector();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Act
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const acmeOption = within(listbox).getByText("Acme Corp");
|
||||
await user.click(acmeOption);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { SettingsNavigation } from "#/components/features/settings/settings-navigation";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { SAAS_NAV_ITEMS, SettingsNavItem } from "#/constants/settings-nav";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
...(await vi.importActual("react-router")),
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockConfig = () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
const ITEMS_WITHOUT_ORG = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org" && item.to !== "/settings/org-members",
|
||||
);
|
||||
|
||||
const renderSettingsNavigation = (
|
||||
items: SettingsNavItem[] = SAAS_NAV_ITEMS,
|
||||
) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<SettingsNavigation
|
||||
isMobileMenuOpen={false}
|
||||
onCloseMobileMenu={vi.fn()}
|
||||
navigationItems={items}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("SettingsNavigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConfig();
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
});
|
||||
|
||||
describe("renders navigation items passed via props", () => {
|
||||
it("should render org routes when included in navigation items", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = await screen.findByText("Organization Members");
|
||||
const orgLink = await screen.findByText("Organization");
|
||||
|
||||
expect(orgMembersLink).toBeInTheDocument();
|
||||
expect(orgLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render org routes when excluded from navigation items", async () => {
|
||||
renderSettingsNavigation(ITEMS_WITHOUT_ORG);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all non-org SAAS items regardless of which items are passed", async () => {
|
||||
renderSettingsNavigation(SAAS_NAV_ITEMS);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Verify non-org items are rendered (using their i18n keys as text since
|
||||
// react-i18next returns the key when no translation is loaded)
|
||||
const secretsLink = await screen.findByText("SETTINGS$NAV_SECRETS");
|
||||
const apiKeysLink = await screen.findByText("SETTINGS$NAV_API_KEYS");
|
||||
|
||||
expect(secretsLink).toBeInTheDocument();
|
||||
expect(apiKeysLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render empty nav when given an empty items list", async () => {
|
||||
renderSettingsNavigation([]);
|
||||
|
||||
await screen.findByTestId("settings-navbar");
|
||||
|
||||
// No nav links should be rendered
|
||||
const orgMembersLink = screen.queryByText("Organization Members");
|
||||
const orgLink = screen.queryByText("Organization");
|
||||
|
||||
expect(orgMembersLink).not.toBeInTheDocument();
|
||||
expect(orgLink).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { UserContextMenu } from "#/components/features/user/user-context-menu";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { GetComponentPropTypes } from "#/utils/get-component-prop-types";
|
||||
import {
|
||||
INITIAL_MOCK_ORGS,
|
||||
MOCK_PERSONAL_ORG,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
} from "#/mocks/org-handlers";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
|
||||
|
||||
function UserContextMenuWithRootOutlet({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) {
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
<UserContextMenu
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderUserContextMenu = ({
|
||||
type,
|
||||
onClose,
|
||||
onOpenInviteModal,
|
||||
}: UserContextMenuProps) =>
|
||||
render(
|
||||
<UserContextMenuWithRootOutlet
|
||||
type={type}
|
||||
onClose={onClose}
|
||||
onOpenInviteModal={onOpenInviteModal}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: () => navigateMock,
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed to return authenticated state
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("UserContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
// Ensure clean state at the start of each test
|
||||
vi.restoreAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
navigateMock.mockClear();
|
||||
// Reset Zustand store to ensure clean state between tests
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should render the default context items for a user", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
|
||||
expect(
|
||||
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("COMMON$ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render navigation items from SAAS_NAV_ITEMS (except organization-members/org)", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
||||
const expectedItems = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org-members" &&
|
||||
item.to !== "/settings/org" &&
|
||||
item.to !== "/settings/billing",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expectedItems.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render navigation items from SAAS_NAV_ITEMS when user role is admin (except organization-members/org)", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and verify that navigation items are rendered (except organization-members/org which are filtered out)
|
||||
const expectedItems = SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/org-members" && item.to !== "/settings/org",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expectedItems.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not display Organization Members menu item for regular users (filtered out)", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Organization Members is filtered out from nav items for all users
|
||||
expect(screen.queryByText("Organization Members")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a documentation link", () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
const docsLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(docsLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
describe("OSS mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "oss",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render OSS_NAV_ITEMS when in OSS mode", async () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for the config to load and OSS nav items to appear
|
||||
await waitFor(() => {
|
||||
OSS_NAV_ITEMS.forEach((item) => {
|
||||
expect(screen.getByText(item.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Verify SAAS-only items are NOT rendered (e.g., Billing)
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$NAV_BILLING"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display Organization Members menu item in OSS mode", async () => {
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for the config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify Organization Members is NOT rendered in OSS mode
|
||||
expect(
|
||||
screen.queryByText("Organization Members"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HIDE_LLM_SETTINGS feature flag", () => {
|
||||
it("should hide LLM settings link when HIDE_LLM_SETTINGS is true", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: true,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
await waitFor(() => {
|
||||
// Other nav items should still be visible
|
||||
expect(screen.getByText("SETTINGS$NAV_USER")).toBeInTheDocument();
|
||||
// LLM settings (to: "/settings") should NOT be visible
|
||||
expect(
|
||||
screen.queryByText("COMMON$LANGUAGE_MODEL_LLM"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show LLM settings link when HIDE_LLM_SETTINGS is false", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("COMMON$LANGUAGE_MODEL_LLM"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an admin", () => {
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
});
|
||||
|
||||
it("should render additional context items when user is an owner", () => {
|
||||
renderUserContextMenu({ type: "owner", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
screen.getByTestId("org-selector");
|
||||
screen.getByText("ORG$INVITE_ORG_MEMBERS");
|
||||
screen.getByText("ORG$ORGANIZATION_MEMBERS");
|
||||
screen.getByText("COMMON$ORGANIZATION");
|
||||
});
|
||||
|
||||
it("should call the logout handler when Logout is clicked", async () => {
|
||||
const logoutSpy = vi.spyOn(AuthService, "logout");
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should have correct navigation links for nav items", async () => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // Enable billing so billing link is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load and test a few representative nav items have the correct href
|
||||
await waitFor(() => {
|
||||
const userLink = screen.getByText("SETTINGS$NAV_USER").closest("a");
|
||||
expect(userLink).toHaveAttribute("href", "/settings/user");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const billingLink = screen.getByText("SETTINGS$NAV_BILLING").closest("a");
|
||||
expect(billingLink).toHaveAttribute("href", "/settings/billing");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const integrationsLink = screen
|
||||
.getByText("SETTINGS$NAV_INTEGRATIONS")
|
||||
.closest("a");
|
||||
expect(integrationsLink).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org-members when Manage Organization Members is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith(
|
||||
"/settings/org-members",
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate to /settings/org when Manage Account is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageAccountButton = await screen.findByText(
|
||||
"COMMON$ORGANIZATION",
|
||||
);
|
||||
await userEvent.click(manageAccountButton);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/settings/org");
|
||||
});
|
||||
|
||||
it("should call the onClose handler when clicking outside the context menu", async () => {
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
await userEvent.click(contextMenu);
|
||||
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate clicking outside the context menu
|
||||
await userEvent.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call the onClose handler after each action", async () => {
|
||||
// Mock a team org so org management buttons are visible
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await userEvent.click(logoutButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const manageOrganizationMembersButton = await screen.findByText(
|
||||
"ORG$ORGANIZATION_MEMBERS",
|
||||
);
|
||||
await userEvent.click(manageOrganizationMembersButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const manageAccountButton = screen.getByText("COMMON$ORGANIZATION");
|
||||
await userEvent.click(manageAccountButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
describe("Personal org vs team org visibility", () => {
|
||||
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Pre-select the personal org in the Zustand store
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to load and org management buttons to disappear
|
||||
// (they disappear when personal org is selected)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("ORG$ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("COMMON$ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Billing settings item when team org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderUserContextMenu({ type: "admin", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to load and billing to disappear
|
||||
// (billing disappears when team org is selected)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$NAV_BILLING"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onOpenInviteModal and onClose when Invite Organization Member is clicked", async () => {
|
||||
// Mock a team org so org management buttons are visible (not personal org)
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const onCloseMock = vi.fn();
|
||||
const onOpenInviteModalMock = vi.fn();
|
||||
renderUserContextMenu({
|
||||
type: "admin",
|
||||
onClose: onCloseMock,
|
||||
onOpenInviteModal: onOpenInviteModalMock,
|
||||
});
|
||||
|
||||
// Wait for orgs to load so org management buttons are visible
|
||||
const inviteButton = await screen.findByText("ORG$INVITE_ORG_MEMBERS");
|
||||
await userEvent.click(inviteButton);
|
||||
|
||||
expect(onOpenInviteModalMock).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("the user can change orgs", async () => {
|
||||
// Mock SaaS mode and organizations for this test
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: INITIAL_MOCK_ORGS,
|
||||
currentOrgId: INITIAL_MOCK_ORGS[0].id,
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
renderUserContextMenu({ type: "member", onClose: onCloseMock, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for org selector to appear (it may take a moment for config to load)
|
||||
const orgSelector = await screen.findByTestId("org-selector");
|
||||
expect(orgSelector).toBeInTheDocument();
|
||||
|
||||
// Wait for organizations to load (indicated by org name appearing in the dropdown)
|
||||
// INITIAL_MOCK_ORGS[0] is a personal org, so it displays "Personal Workspace"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Personal Workspace");
|
||||
});
|
||||
|
||||
// Open the dropdown by clicking the trigger
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Select a different organization
|
||||
const orgOption = screen.getByRole("option", {
|
||||
name: INITIAL_MOCK_ORGS[1].name,
|
||||
});
|
||||
await user.click(orgOption);
|
||||
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the dropdown shows the selected organization
|
||||
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
|
||||
});
|
||||
});
|
||||
@@ -198,9 +198,9 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []);
|
||||
});
|
||||
|
||||
it("should disable the submit button when agent is loading", async () => {
|
||||
it("should disable the submit button when awaiting user confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.LOADING);
|
||||
mockStores(AgentState.AWAITING_USER_CONFIRMATION);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import translations from "../../src/i18n/translation.json";
|
||||
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
|
||||
|
||||
vi.mock("@heroui/react", () => ({
|
||||
Tooltip: ({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{children}
|
||||
<div>{content}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const supportedLanguages = [
|
||||
"en",
|
||||
"ja",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"ko-KR",
|
||||
"de",
|
||||
"no",
|
||||
"it",
|
||||
"pt",
|
||||
"es",
|
||||
"ar",
|
||||
"fr",
|
||||
"tr",
|
||||
];
|
||||
|
||||
// Helper function to check if a translation exists for all supported languages
|
||||
function checkTranslationExists(key: string) {
|
||||
const missingTranslations: string[] = [];
|
||||
|
||||
const translationEntry = (
|
||||
translations as Record<string, Record<string, string>>
|
||||
)[key];
|
||||
if (!translationEntry) {
|
||||
throw new Error(
|
||||
`Translation key "${key}" does not exist in translation.json`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const lang of supportedLanguages) {
|
||||
if (!translationEntry[lang]) {
|
||||
missingTranslations.push(lang);
|
||||
}
|
||||
}
|
||||
|
||||
return missingTranslations;
|
||||
}
|
||||
|
||||
// Helper function to find duplicate translation keys
|
||||
function findDuplicateKeys(obj: Record<string, any>) {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
|
||||
// Only check top-level keys as these are our translation keys
|
||||
for (const key in obj) {
|
||||
if (seen.has(key)) {
|
||||
duplicates.add(key);
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicates);
|
||||
}
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translationEntry = (
|
||||
translations as Record<string, Record<string, string>>
|
||||
)[key];
|
||||
return translationEntry?.ja || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Landing page translations", () => {
|
||||
test("should render Japanese translations correctly", () => {
|
||||
// Mock a simple component that uses the translations
|
||||
const TestComponent = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<UserAvatar onClick={() => {}} />
|
||||
<div data-testid="main-content">
|
||||
<h1>{t("LANDING$TITLE")}</h1>
|
||||
<button>{t("VSCODE$OPEN")}</button>
|
||||
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
|
||||
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
|
||||
<button>{t("SUGGESTIONS$FIX_README")}</button>
|
||||
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
|
||||
</div>
|
||||
<div data-testid="tabs">
|
||||
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
|
||||
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
|
||||
</div>
|
||||
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
|
||||
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
|
||||
<div data-testid="status">
|
||||
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
|
||||
<span>{t("STATUS$CONNECTED")}</span>
|
||||
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
|
||||
</div>
|
||||
<div data-testid="time">
|
||||
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
|
||||
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
|
||||
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Check main content translations
|
||||
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
|
||||
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("テストカバレッジを向上させる"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
|
||||
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
|
||||
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
|
||||
|
||||
// Check tab labels
|
||||
const tabs = screen.getByTestId("tabs");
|
||||
expect(tabs).toHaveTextContent("ターミナル");
|
||||
expect(tabs).toHaveTextContent("ブラウザ");
|
||||
expect(tabs).toHaveTextContent("Jupyter");
|
||||
expect(tabs).toHaveTextContent("コードエディタ");
|
||||
|
||||
// Check workspace label and new project button
|
||||
expect(screen.getByTestId("workspace-label")).toHaveTextContent(
|
||||
"ワークスペース",
|
||||
);
|
||||
expect(screen.getByTestId("new-project")).toHaveTextContent(
|
||||
"新規プロジェクト",
|
||||
);
|
||||
|
||||
// Check status messages
|
||||
const status = screen.getByTestId("status");
|
||||
expect(status).toHaveTextContent("クライアントの準備を待機中");
|
||||
expect(status).toHaveTextContent("接続済み");
|
||||
expect(status).toHaveTextContent("サーバーに接続済み");
|
||||
|
||||
// Check time-related translations
|
||||
const time = screen.getByTestId("time");
|
||||
expect(time).toHaveTextContent("5 分前");
|
||||
expect(time).toHaveTextContent("2 時間前");
|
||||
expect(time).toHaveTextContent("3 日前");
|
||||
});
|
||||
|
||||
test("all translation keys should have translations for all supported languages", () => {
|
||||
// Test all translation keys used in the component
|
||||
const translationKeys = [
|
||||
"LANDING$TITLE",
|
||||
"VSCODE$OPEN",
|
||||
"SUGGESTIONS$INCREASE_TEST_COVERAGE",
|
||||
"SUGGESTIONS$AUTO_MERGE_PRS",
|
||||
"SUGGESTIONS$FIX_README",
|
||||
"SUGGESTIONS$CLEAN_DEPENDENCIES",
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
"WORKSPACE$BROWSER_TAB_LABEL",
|
||||
"WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
"WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
"WORKSPACE$TITLE",
|
||||
"PROJECT$NEW_PROJECT",
|
||||
"TERMINAL$WAITING_FOR_CLIENT",
|
||||
"STATUS$CONNECTED",
|
||||
"STATUS$CONNECTED_TO_SERVER",
|
||||
"TIME$MINUTES_AGO",
|
||||
"TIME$HOURS_AGO",
|
||||
"TIME$DAYS_AGO",
|
||||
];
|
||||
|
||||
// Check all keys and collect missing translations
|
||||
const missingTranslationsMap = new Map<string, string[]>();
|
||||
translationKeys.forEach((key) => {
|
||||
const missing = checkTranslationExists(key);
|
||||
if (missing.length > 0) {
|
||||
missingTranslationsMap.set(key, missing);
|
||||
}
|
||||
});
|
||||
|
||||
// If any translations are missing, throw an error with all missing translations
|
||||
if (missingTranslationsMap.size > 0) {
|
||||
const errorMessage = Array.from(missingTranslationsMap.entries())
|
||||
.map(
|
||||
([key, langs]) =>
|
||||
`\n- "${key}" is missing translations for: ${langs.join(", ")}`,
|
||||
)
|
||||
.join("");
|
||||
throw new Error(`Missing translations:${errorMessage}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("translation file should not have duplicate keys", () => {
|
||||
const duplicates = findDuplicateKeys(translations);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(
|
||||
`Found duplicate translation keys: ${duplicates.join(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
429
frontend/__tests__/components/ui/dropdown.test.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Dropdown } from "#/ui/dropdown/dropdown";
|
||||
|
||||
const mockOptions = [
|
||||
{ value: "1", label: "Option 1" },
|
||||
{ value: "2", label: "Option 2" },
|
||||
{ value: "3", label: "Option 3" },
|
||||
];
|
||||
|
||||
describe("Dropdown", () => {
|
||||
describe("Trigger", () => {
|
||||
it("should render a custom trigger button", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
|
||||
expect(trigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open dropdown on trigger click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByText("Option 1")).not.toBeInTheDocument();
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Type-ahead / Search", () => {
|
||||
it("should filter options based on input text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be case-insensitive by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all options when search is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
await user.clear(input);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty state", () => {
|
||||
it("should display empty state when no options provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={[]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByText("No options")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render custom empty state message", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={[]} emptyMessage="Nothing found" />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(screen.getByText("Nothing found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Single selection", () => {
|
||||
it("should select an option on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const option = screen.getByText("Option 1");
|
||||
await user.click(option);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should close dropdown after selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display selected option in input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByText("Option 1"));
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should highlight currently selected option in list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const selectedOption = screen.getByRole("option", { name: "Option 1" });
|
||||
expect(selectedOption).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("should preserve selected value in input and show all options when reopening dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
// Reopen the dropdown
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Option 1");
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 3" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clear button", () => {
|
||||
it("should not render clear button by default", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render clear button when clearable prop is true and has value", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clear selection and search input when clear button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should not render clear button when there is no selection", () => {
|
||||
render(<Dropdown options={mockOptions} clearable />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show placeholder after clearing selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
placeholder="Select an option"
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading state", () => {
|
||||
it("should not display loading indicator by default", () => {
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading indicator when loading prop is true", () => {
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable interaction while loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} loading />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled state", () => {
|
||||
it("should not open dropdown when disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("should have disabled attribute on trigger", () => {
|
||||
render(<Dropdown options={mockOptions} disabled />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(trigger).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placeholder", () => {
|
||||
it("should display placeholder text when no value selected", () => {
|
||||
render(<Dropdown options={mockOptions} placeholder="Select an option" />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveAttribute("placeholder", "Select an option");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default value", () => {
|
||||
it("should display defaultValue in input on mount", () => {
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input).toHaveValue("Option 1");
|
||||
});
|
||||
|
||||
it("should show all options when opened with defaultValue", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 1" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 2" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "Option 3" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restore input value when closed with Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} defaultValue={mockOptions[0]} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "test");
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
expect(input).toHaveValue("Option 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onChange", () => {
|
||||
it("should call onChange with selected item when option is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
render(<Dropdown options={mockOptions} onChange={onChangeMock} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
await user.click(screen.getByRole("option", { name: "Option 1" }));
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]);
|
||||
});
|
||||
|
||||
it("should call onChange with null when selection is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeMock = vi.fn();
|
||||
render(
|
||||
<Dropdown
|
||||
options={mockOptions}
|
||||
clearable
|
||||
defaultValue={mockOptions[0]}
|
||||
onChange={onChangeMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId("dropdown-clear");
|
||||
await user.click(clearButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Controlled mode", () => {
|
||||
it.todo("should reflect external value changes");
|
||||
it.todo("should call onChange when selection changes");
|
||||
it.todo("should not update internal state when controlled");
|
||||
});
|
||||
|
||||
describe("Uncontrolled mode", () => {
|
||||
it.todo("should manage selection state internally");
|
||||
it.todo("should call onChange when selection changes");
|
||||
it.todo("should support defaultValue prop");
|
||||
});
|
||||
|
||||
describe("testId prop", () => {
|
||||
it("should apply custom testId to the root container", () => {
|
||||
render(<Dropdown options={mockOptions} testId="org-dropdown" />);
|
||||
|
||||
expect(screen.getByTestId("org-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor position preservation", () => {
|
||||
it("should keep menu open when clicking the input while dropdown is open", async () => {
|
||||
// Without a stateReducer, Downshift's default InputClick behavior
|
||||
// toggles the menu (closes it if already open). The stateReducer
|
||||
// should override this to keep the menu open so users can click
|
||||
// to reposition their cursor without losing the dropdown.
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
|
||||
// Click the input itself (simulates clicking to reposition cursor)
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
|
||||
// Menu should still be open — not toggled closed
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should still filter options correctly after typing with cursor fix", async () => {
|
||||
// Verifies that the direct onChange handler (which bypasses Downshift's
|
||||
// default onInputValueChange for cursor preservation) still updates
|
||||
// the search/filter state correctly.
|
||||
const user = userEvent.setup();
|
||||
render(<Dropdown options={mockOptions} />);
|
||||
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.type(input, "Option 1");
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Option 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,64 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, afterEach, beforeEach, test } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ReactElement } from "react";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: () => vi.fn(),
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderUserActions = (props = { hasAvatar: true }) => {
|
||||
render(
|
||||
<UserActions
|
||||
user={
|
||||
props.hasAvatar
|
||||
? { avatar_url: "https://example.com/avatar.png" }
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Create mocks for all the hooks we need
|
||||
const useIsAuthedMock = vi
|
||||
.fn()
|
||||
@@ -38,9 +91,8 @@ describe("UserActions", () => {
|
||||
const onLogoutMock = vi.fn();
|
||||
|
||||
// Create a wrapper with MemoryRouter and renderWithProviders
|
||||
const renderWithRouter = (ui: ReactElement) => {
|
||||
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
};
|
||||
const renderWithRouter = (ui: ReactElement) =>
|
||||
renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to default values before each test
|
||||
@@ -61,29 +113,11 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
renderUserActions();
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
@@ -96,29 +130,31 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
renderUserActions();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is undefined and avatar is hovered", async () => {
|
||||
renderUserActions({ hasAvatar: false });
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu should NOT appear because user is undefined
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
renderWithRouter(
|
||||
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||
@@ -133,15 +169,13 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
|
||||
renderWithRouter(<UserActions />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should NOT be accessible when user is not authenticated
|
||||
expect(
|
||||
@@ -161,16 +195,12 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
const { unmount } = renderWithRouter(
|
||||
<UserActions onLogout={onLogoutMock} />,
|
||||
);
|
||||
const { unmount } = renderWithRouter(<UserActions />);
|
||||
|
||||
// Initially no user and not authenticated - menu should not appear
|
||||
let userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Unmount the first component
|
||||
unmount();
|
||||
@@ -188,10 +218,7 @@ describe("UserActions", () => {
|
||||
|
||||
// Render a new component with user prop and authentication
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Component should render correctly
|
||||
@@ -199,12 +226,10 @@ describe("UserActions", () => {
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
|
||||
// Menu should now work with user defined and authenticated
|
||||
userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
const userActionsEl = screen.getByTestId("user-actions");
|
||||
await user.hover(userActionsEl);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
@@ -219,18 +244,13 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
const { rerender } = renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<UserActions user={{ avatar_url: "https://example.com/avatar.png" }} />,
|
||||
);
|
||||
|
||||
// Click to open menu
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
// Hover to open menu
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
|
||||
// Set authentication to false for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
@@ -246,14 +266,12 @@ describe("UserActions", () => {
|
||||
// Remove user prop - menu should disappear because user is no longer authenticated
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<UserActions onLogout={onLogoutMock} />
|
||||
<UserActions />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Context menu should NOT be visible when user becomes unauthenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("user-context-menu")).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should not be accessible
|
||||
expect(
|
||||
@@ -272,20 +290,168 @@ describe("UserActions", () => {
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
isLoading={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Context menu should still appear even when loading
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("context menu should default to user role", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Verify logout is present
|
||||
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
|
||||
"ACCOUNT_SETTINGS$LOGOUT",
|
||||
);
|
||||
// Verify nav items are present (e.g., settings nav items)
|
||||
expect(screen.getByTestId("user-context-menu")).toHaveTextContent(
|
||||
"SETTINGS$NAV_USER",
|
||||
);
|
||||
// Verify admin-only items are NOT present for user role
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText("ORG$MANAGE_ORGANIZATION_MEMBERS"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("ORG$MANAGE_ORGANIZATION"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should NOT show Team and Organization nav items when personal workspace is selected", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
// Team and Organization nav links should NOT be visible when no org is selected (personal workspace)
|
||||
expect(screen.queryByText("Team")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Organization")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu on hover", async () => {
|
||||
renderUserActions();
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
|
||||
// Menu is in DOM but hidden via CSS (opacity-0, pointer-events-none)
|
||||
expect(contextMenu.parentElement).toHaveClass("opacity-0");
|
||||
expect(contextMenu.parentElement).toHaveClass("pointer-events-none");
|
||||
|
||||
// Hover over the user actions area
|
||||
await user.hover(userActions);
|
||||
|
||||
// Menu should be visible on hover (CSS classes change via group-hover)
|
||||
expect(contextMenu).toBeVisible();
|
||||
});
|
||||
|
||||
it("should have pointer-events-none on hover bridge pseudo-element to allow menu item clicks", async () => {
|
||||
renderUserActions();
|
||||
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
await user.hover(userActions);
|
||||
|
||||
const contextMenu = screen.getByTestId("user-context-menu");
|
||||
const hoverBridgeContainer = contextMenu.parentElement;
|
||||
|
||||
// The hover bridge uses a ::before pseudo-element for diagonal mouse movement
|
||||
// This pseudo-element MUST have pointer-events-none to allow clicks through to menu items
|
||||
// The class should include "before:pointer-events-none" to prevent the hover bridge from blocking clicks
|
||||
expect(hoverBridgeContainer?.className).toContain(
|
||||
"before:pointer-events-none",
|
||||
);
|
||||
});
|
||||
|
||||
describe("Org selector dropdown state reset when context menu hides", () => {
|
||||
// These tests verify that the org selector dropdown resets its internal
|
||||
// state (search text, open/closed) when the context menu hides and
|
||||
// reappears. Without this, stale state persists because the context
|
||||
// menu is hidden via CSS (opacity/pointer-events) rather than unmounted.
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should reset org selector search text when context menu hides and reappears", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Hover to show context menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for orgs to load and auto-select
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Open dropdown and type search text
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "search text");
|
||||
expect(input).toHaveValue("search text");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
await user.unhover(userActions);
|
||||
await user.hover(userActions);
|
||||
|
||||
// Org selector should be reset — showing selected org name, not search text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset dropdown to collapsed state when context menu hides and reappears", async () => {
|
||||
renderUserActions();
|
||||
const userActions = screen.getByTestId("user-actions");
|
||||
|
||||
// Hover to show context menu
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for orgs to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Open dropdown and type to change its state
|
||||
const trigger = screen.getByTestId("dropdown-trigger");
|
||||
await user.click(trigger);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Acme");
|
||||
expect(input).toHaveValue("Acme");
|
||||
|
||||
// Unhover to hide context menu, then hover again
|
||||
await user.unhover(userActions);
|
||||
await user.hover(userActions);
|
||||
|
||||
// Wait for fresh component with org data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox")).toHaveValue(
|
||||
MOCK_PERSONAL_ORG.name,
|
||||
);
|
||||
});
|
||||
|
||||
// Dropdown should be collapsed (closed) after reset
|
||||
expect(screen.getByTestId("dropdown-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
// No option elements should be rendered
|
||||
expect(screen.queryAllByRole("option")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UserAvatar } from "#/components/features/sidebar/user-avatar";
|
||||
|
||||
describe("UserAvatar", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
onClickMock.mockClear();
|
||||
});
|
||||
|
||||
it("(default) should render the placeholder avatar when the user is logged out", () => {
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
render(<UserAvatar />);
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
|
||||
const userAvatarContainer = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatarContainer);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display the user's avatar when available", () => {
|
||||
render(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
/>,
|
||||
);
|
||||
render(<UserAvatar avatarUrl="https://example.com/avatar.png" />);
|
||||
|
||||
expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -43,24 +21,20 @@ describe("UserAvatar", () => {
|
||||
});
|
||||
|
||||
it("should display a loading spinner instead of an avatar when isLoading is true", () => {
|
||||
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
|
||||
const { rerender } = render(<UserAvatar />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
rerender(<UserAvatar isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
isLoading
|
||||
/>,
|
||||
<UserAvatar avatarUrl="https://example.com/avatar.png" isLoading />,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { BrowserObservation } from "#/types/v1/core/base/observation";
|
||||
import {
|
||||
BrowserObservation,
|
||||
GlobObservation,
|
||||
GrepObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
describe("getObservationContent - BrowserObservation", () => {
|
||||
it("should return output content when available", () => {
|
||||
@@ -90,3 +94,212 @@ describe("getObservationContent - BrowserObservation", () => {
|
||||
expect(result).toBe("**Output:**\nPage loaded successfully");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GlobObservation", () => {
|
||||
it("should display files found when glob matches files", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found 2 files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/src/index.ts", "/workspace/src/app.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.ts`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Files Found (2):**");
|
||||
expect(result).toContain("- `/workspace/src/index.ts`");
|
||||
expect(result).toContain("- `/workspace/src/app.ts`");
|
||||
});
|
||||
|
||||
it("should display no files found message when glob matches nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "No files found", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: [],
|
||||
pattern: "**/*.xyz",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.xyz`");
|
||||
expect(result).toContain("**Result:** No files found.");
|
||||
});
|
||||
|
||||
it("should display error when glob operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Permission denied", cache_prompt: false }],
|
||||
is_error: true,
|
||||
files: [],
|
||||
pattern: "**/*",
|
||||
search_path: "/restricted",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("should indicate truncation when results exceed limit", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/file1.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Files Found (1+, truncated):**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GrepObservation", () => {
|
||||
it("should display matches found when grep finds results", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"],
|
||||
pattern: "fetchData",
|
||||
search_path: "/workspace",
|
||||
include_pattern: "*.ts",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `fetchData`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Include:** `*.ts`");
|
||||
expect(result).toContain("**Matches (2):**");
|
||||
expect(result).toContain("- `/workspace/src/api.ts`");
|
||||
});
|
||||
|
||||
it("should display no matches found when grep finds nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "No matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: [],
|
||||
pattern: "nonExistentFunction",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `nonExistentFunction`");
|
||||
expect(result).toContain("**Result:** No matches found.");
|
||||
expect(result).not.toContain("**Include:**");
|
||||
});
|
||||
|
||||
it("should display error when grep operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }],
|
||||
is_error: true,
|
||||
matches: [],
|
||||
pattern: "[invalid",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Invalid regex pattern");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ const createUserMessageEvent = (id: string): MessageEvent => ({
|
||||
const createPlanningObservationEvent = (
|
||||
id: string,
|
||||
actionId: string = "action-1",
|
||||
path: string = "/workspace/PLAN.md",
|
||||
): ObservationEvent<PlanningFileEditorObservation> => ({
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -40,7 +41,7 @@ const createPlanningObservationEvent = (
|
||||
content: [{ type: "text", text: "Plan content" }],
|
||||
is_error: false,
|
||||
command: "create",
|
||||
path: "/workspace/PLAN.md",
|
||||
path,
|
||||
prev_exist: false,
|
||||
old_content: null,
|
||||
new_content: "Plan content",
|
||||
@@ -172,6 +173,31 @@ describe("usePlanPreviewEvents", () => {
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.has("plan-obs-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
|
||||
const events: OpenHandsEvent[] = [
|
||||
createUserMessageEvent("user-1"),
|
||||
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
|
||||
createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => usePlanPreviewEvents(events));
|
||||
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should include only Plan.md observations when mixed with other file edits", () => {
|
||||
const events: OpenHandsEvent[] = [
|
||||
createUserMessageEvent("user-1"),
|
||||
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
|
||||
createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => usePlanPreviewEvents(events));
|
||||
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.has("plan-obs-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowPlanPreview", () => {
|
||||
|
||||
@@ -229,4 +229,231 @@ describe("conversation localStorage utilities", () => {
|
||||
expect(parsed.subConversationTaskId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftMessage persistence", () => {
|
||||
describe("getConversationState", () => {
|
||||
it("returns default draftMessage as null when no state exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-1";
|
||||
|
||||
// Act
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
// Assert
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("retrieves draftMessage from localStorage when it exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-2";
|
||||
const draftText = "This is my saved draft message";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: draftText,
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
// Assert
|
||||
expect(state.draftMessage).toBe(draftText);
|
||||
});
|
||||
|
||||
it("returns null draftMessage for task conversation IDs (not persisted)", () => {
|
||||
// Arrange
|
||||
const taskId = "task-uuid-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
|
||||
|
||||
// Even if somehow there's data in localStorage for a task ID
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Should not be returned",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
const state = getConversationState(taskId);
|
||||
|
||||
// Assert - should return default state, not the stored value
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConversationState", () => {
|
||||
it("persists draftMessage to localStorage", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-3";
|
||||
const draftText = "New draft message to save";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: draftText,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBe(draftText);
|
||||
});
|
||||
|
||||
it("does not persist draftMessage for task conversation IDs", () => {
|
||||
// Arrange
|
||||
const taskId = "task-draft-xyz";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
|
||||
|
||||
// Act
|
||||
setConversationState(taskId, {
|
||||
draftMessage: "Draft for task ID",
|
||||
});
|
||||
|
||||
// Assert - nothing should be stored
|
||||
expect(localStorage.getItem(consolidatedKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("merges draftMessage with existing state without overwriting other fields", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-4";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "terminal",
|
||||
rightPanelShown: false,
|
||||
unpinnedTabs: ["tab-1", "tab-2"],
|
||||
conversationMode: "plan",
|
||||
subConversationTaskId: "task-123",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "Updated draft",
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
|
||||
expect(parsed.draftMessage).toBe("Updated draft");
|
||||
expect(parsed.selectedTab).toBe("terminal");
|
||||
expect(parsed.rightPanelShown).toBe(false);
|
||||
expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]);
|
||||
expect(parsed.conversationMode).toBe("plan");
|
||||
expect(parsed.subConversationTaskId).toBe("task-123");
|
||||
});
|
||||
|
||||
it("clears draftMessage when set to null", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-5";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Existing draft",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("clears draftMessage when set to empty string (stored as empty string)", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-6";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Existing draft",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "",
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation-specific draft isolation", () => {
|
||||
it("stores drafts separately for different conversations", () => {
|
||||
// Arrange
|
||||
const convA = "conv-A";
|
||||
const convB = "conv-B";
|
||||
const draftA = "Draft for conversation A";
|
||||
const draftB = "Draft for conversation B";
|
||||
|
||||
// Act
|
||||
setConversationState(convA, { draftMessage: draftA });
|
||||
setConversationState(convB, { draftMessage: draftB });
|
||||
|
||||
// Assert
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBe(draftA);
|
||||
expect(stateB.draftMessage).toBe(draftB);
|
||||
});
|
||||
|
||||
it("updating one conversation draft does not affect another", () => {
|
||||
// Arrange
|
||||
const convA = "conv-isolated-A";
|
||||
const convB = "conv-isolated-B";
|
||||
|
||||
setConversationState(convA, { draftMessage: "Original draft A" });
|
||||
setConversationState(convB, { draftMessage: "Original draft B" });
|
||||
|
||||
// Act - update only conversation A
|
||||
setConversationState(convA, { draftMessage: "Updated draft A" });
|
||||
|
||||
// Assert - conversation B should be unchanged
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBe("Updated draft A");
|
||||
expect(stateB.draftMessage).toBe("Original draft B");
|
||||
});
|
||||
|
||||
it("clearing one conversation draft does not affect another", () => {
|
||||
// Arrange
|
||||
const convA = "conv-clear-A";
|
||||
const convB = "conv-clear-B";
|
||||
|
||||
setConversationState(convA, { draftMessage: "Draft A" });
|
||||
setConversationState(convB, { draftMessage: "Draft B" });
|
||||
|
||||
// Act - clear draft for conversation A
|
||||
setConversationState(convA, { draftMessage: null });
|
||||
|
||||
// Assert
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBeNull();
|
||||
expect(stateB.draftMessage).toBe("Draft B");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { screen, waitFor, render, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
@@ -682,8 +683,242 @@ describe("Conversation WebSocket Handler", () => {
|
||||
|
||||
// 7. Message Sending Tests
|
||||
describe("Message Sending", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
it("should send user actions through WebSocket when connected", async () => {
|
||||
// Arrange
|
||||
const conversationId = "test-conversation-send";
|
||||
let receivedMessage: unknown = null;
|
||||
|
||||
// Set up MSW to capture sent messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Capture messages sent from client
|
||||
client.addEventListener("message", (event) => {
|
||||
receivedMessage = JSON.parse(event.data as string);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Send a message
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello from test" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - message should have been received by mock server
|
||||
await waitFor(() => {
|
||||
expect(receivedMessage).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello from test" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not throw error when sendMessage is called with WebSocket connected", async () => {
|
||||
// This test verifies that sendMessage doesn't throw an error
|
||||
// when the WebSocket is connected.
|
||||
const conversationId = "test-conversation-no-throw";
|
||||
let sendError: Error | null = null;
|
||||
|
||||
// Set up MSW to connect and receive messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the context to be available
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
// Try to send a message
|
||||
await act(async () => {
|
||||
try {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Test message" }],
|
||||
});
|
||||
} catch (error) {
|
||||
sendError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert - should NOT throw an error
|
||||
expect(sendError).toBeNull();
|
||||
});
|
||||
|
||||
it("should send multiple messages through WebSocket in order", async () => {
|
||||
// Arrange
|
||||
const conversationId = "test-conversation-multi";
|
||||
const receivedMessages: unknown[] = [];
|
||||
|
||||
// Set up MSW to capture sent messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Capture messages sent from client
|
||||
client.addEventListener("message", (event) => {
|
||||
receivedMessages.push(JSON.parse(event.data as string));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
// Send multiple messages
|
||||
await act(async () => {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 1" }],
|
||||
});
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 2" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - both messages should have been received in order
|
||||
await waitFor(() => {
|
||||
expect(receivedMessages.length).toBe(2);
|
||||
});
|
||||
|
||||
expect(receivedMessages[0]).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 1" }],
|
||||
});
|
||||
expect(receivedMessages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 2" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 8. History Loading State Tests
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
/**
|
||||
* Creates a mock WebClientConfig with all required fields.
|
||||
* Use this helper to create test config objects with sensible defaults.
|
||||
*/
|
||||
export const createMockWebClientConfig = (
|
||||
overrides: Partial<WebClientConfig> = {},
|
||||
): WebClientConfig => ({
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
...overrides.feature_flags,
|
||||
},
|
||||
providers_configured: [],
|
||||
maintenance_start_time: null,
|
||||
auth_url: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useInviteMembersBatch } from "#/hooks/mutation/use-invite-members-batch";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the useRevalidator hook from react-router
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useInviteMembersBatch", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should throw an error when organizationId is null", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useInviteMembersBatch(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Attempt to mutate without organizationId
|
||||
result.current.mutate({ emails: ["test@example.com"] });
|
||||
|
||||
// Should fail with an error about missing organizationId
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("Organization ID is required");
|
||||
});
|
||||
});
|
||||
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
45
frontend/__tests__/hooks/mutation/use-remove-member.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRemoveMember } from "#/hooks/mutation/use-remove-member";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
// Mock the useRevalidator hook from react-router
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("useRemoveMember", () => {
|
||||
beforeEach(() => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
it("should throw an error when organizationId is null", async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRemoveMember(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Attempt to mutate without organizationId
|
||||
result.current.mutate({ userId: "user-123" });
|
||||
|
||||
// Should fail with an error about missing organizationId
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("Organization ID is required");
|
||||
});
|
||||
});
|
||||
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
174
frontend/__tests__/hooks/query/use-organizations.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useOrganizations } from "#/hooks/query/use-organizations";
|
||||
import type { Organization } from "#/types/org";
|
||||
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
getOrganizations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed to return authenticated
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
// Mock useConfig to return SaaS mode (organizations are a SaaS-only feature)
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({ data: { app_mode: "saas" } }),
|
||||
}));
|
||||
|
||||
const mockGetOrganizations = vi.mocked(organizationService.getOrganizations);
|
||||
|
||||
function createMinimalOrg(
|
||||
id: string,
|
||||
name: string,
|
||||
is_personal?: boolean,
|
||||
): Organization {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
is_personal,
|
||||
contact_name: "",
|
||||
contact_email: "",
|
||||
conversation_expiration: 0,
|
||||
agent: "",
|
||||
default_max_iterations: 0,
|
||||
security_analyzer: "",
|
||||
confirmation_mode: false,
|
||||
default_llm_model: "",
|
||||
default_llm_api_key_for_byor: "",
|
||||
default_llm_base_url: "",
|
||||
remote_runtime_resource_factor: 0,
|
||||
enable_default_condenser: false,
|
||||
billing_margin: 0,
|
||||
enable_proactive_conversation_starters: false,
|
||||
sandbox_base_container_image: "",
|
||||
sandbox_runtime_container_image: "",
|
||||
org_version: 0,
|
||||
mcp_config: { tools: [], settings: {} },
|
||||
search_api_key: null,
|
||||
sandbox_api_key: null,
|
||||
max_budget_per_task: 0,
|
||||
enable_solvability_analysis: false,
|
||||
v1_enabled: false,
|
||||
credits: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useOrganizations", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sorts personal workspace first, then non-personal alphabetically by name", async () => {
|
||||
// API returns unsorted: Beta, Personal, Acme, All Hands
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [
|
||||
createMinimalOrg("3", "Beta LLC", false),
|
||||
createMinimalOrg("1", "Personal Workspace", true),
|
||||
createMinimalOrg("2", "Acme Corp", false),
|
||||
createMinimalOrg("4", "All Hands AI", false),
|
||||
],
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
expect(organizations).toHaveLength(4);
|
||||
expect(organizations[0].id).toBe("1");
|
||||
expect(organizations[0].is_personal).toBe(true);
|
||||
expect(organizations[0].name).toBe("Personal Workspace");
|
||||
expect(organizations[1].name).toBe("Acme Corp");
|
||||
expect(organizations[2].name).toBe("All Hands AI");
|
||||
expect(organizations[3].name).toBe("Beta LLC");
|
||||
});
|
||||
|
||||
it("treats missing is_personal as false and sorts by name", async () => {
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [
|
||||
createMinimalOrg("1", "Zebra Org"), // no is_personal
|
||||
createMinimalOrg("2", "Alpha Org", true), // personal first
|
||||
createMinimalOrg("3", "Mango Org"), // no is_personal
|
||||
],
|
||||
currentOrgId: "2",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
expect(organizations[0].id).toBe("2");
|
||||
expect(organizations[0].is_personal).toBe(true);
|
||||
expect(organizations[1].name).toBe("Mango Org");
|
||||
expect(organizations[2].name).toBe("Zebra Org");
|
||||
});
|
||||
|
||||
it("handles missing name by treating as empty string for sort", async () => {
|
||||
const orgWithName = createMinimalOrg("2", "Beta", false);
|
||||
const orgNoName = { ...createMinimalOrg("1", "Alpha", false) };
|
||||
delete (orgNoName as Record<string, unknown>).name;
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: [orgWithName, orgNoName] as Organization[],
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
const { organizations } = result.current.data!;
|
||||
// undefined name is coerced to ""; "" sorts before "Beta"
|
||||
expect(organizations[0].id).toBe("1");
|
||||
expect(organizations[1].id).toBe("2");
|
||||
expect(organizations[1].name).toBe("Beta");
|
||||
});
|
||||
|
||||
it("does not mutate the original array from the API", async () => {
|
||||
const apiOrgs = [
|
||||
createMinimalOrg("2", "Acme", false),
|
||||
createMinimalOrg("1", "Personal", true),
|
||||
];
|
||||
mockGetOrganizations.mockResolvedValue({
|
||||
items: apiOrgs,
|
||||
currentOrgId: "1",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOrganizations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Hook sorts a copy ([...data]), so API order unchanged
|
||||
expect(apiOrgs[0].id).toBe("2");
|
||||
expect(apiOrgs[1].id).toBe("1");
|
||||
// Returned data is sorted
|
||||
expect(result.current.data!.organizations[0].id).toBe("1");
|
||||
expect(result.current.data!.organizations[1].id).toBe("2");
|
||||
});
|
||||
});
|
||||
594
frontend/__tests__/hooks/use-draft-persistence.test.tsx
Normal file
594
frontend/__tests__/hooks/use-draft-persistence.test.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence";
|
||||
import * as conversationLocalStorage from "#/utils/conversation-local-storage";
|
||||
|
||||
// Mock the entire module
|
||||
vi.mock("#/utils/conversation-local-storage", () => ({
|
||||
useConversationLocalStorageState: vi.fn(),
|
||||
getConversationState: vi.fn(),
|
||||
setConversationState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the getTextContent utility
|
||||
vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({
|
||||
getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""),
|
||||
}));
|
||||
|
||||
describe("useDraftPersistence", () => {
|
||||
let mockSetDraftMessage: (message: string | null) => void;
|
||||
|
||||
// Create a mock ref to contentEditable div
|
||||
const createMockChatInputRef = (initialContent = "") => {
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("contenteditable", "true");
|
||||
div.textContent = initialContent;
|
||||
return { current: div };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
|
||||
mockSetDraftMessage = vi.fn<(message: string | null) => void>();
|
||||
|
||||
// Default mock for useConversationLocalStorageState
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
// Default mock for getConversationState
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("draft restoration on mount", () => {
|
||||
it("restores draft from localStorage when mounting with existing draft", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restore-1";
|
||||
const savedDraft = "Previously saved draft message";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - draft should be restored to the DOM element
|
||||
expect(chatInputRef.current?.textContent).toBe(savedDraft);
|
||||
});
|
||||
|
||||
it("clears input on mount then restores draft if exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restore-2";
|
||||
const existingContent = "Stale content from previous conversation";
|
||||
const savedDraft = "Saved draft";
|
||||
const chatInputRef = createMockChatInputRef(existingContent);
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - input cleared then draft restored
|
||||
expect(chatInputRef.current?.textContent).toBe(savedDraft);
|
||||
});
|
||||
|
||||
it("clears input when no draft exists for conversation", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-draft";
|
||||
const chatInputRef = createMockChatInputRef("Some stale content");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - content should be cleared since there's no draft
|
||||
expect(chatInputRef.current?.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("debounced saving", () => {
|
||||
it("saves draft after debounce period", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-debounce-1";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - simulate user typing
|
||||
chatInputRef.current!.textContent = "New draft content";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Assert - should not save immediately
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
|
||||
// Fast forward past debounce period (500ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should save after debounce
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content");
|
||||
});
|
||||
|
||||
it("cancels pending save when new input arrives before debounce", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-debounce-2";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - first input
|
||||
chatInputRef.current!.textContent = "First";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Wait 200ms (less than debounce)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Second input before debounce completes
|
||||
chatInputRef.current!.textContent = "First Second";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Complete the second debounce
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should only save the final value once
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second");
|
||||
});
|
||||
|
||||
it("does not save if content matches existing draft", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-change";
|
||||
const existingDraft = "Existing draft";
|
||||
const chatInputRef = createMockChatInputRef(existingDraft);
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - try to save same content
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should not save since content is the same
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearDraft", () => {
|
||||
it("clears the draft from localStorage", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-clear-1";
|
||||
const chatInputRef = createMockChatInputRef("Some content");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("cancels any pending debounced save when clearing", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-clear-2";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Start a save
|
||||
chatInputRef.current!.textContent = "Pending draft";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Clear before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
// Complete the original debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - only the clear should have been called (the pending save should be cancelled)
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation switching", () => {
|
||||
it("clears input when switching to a new conversation without a draft", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Draft from conv A");
|
||||
|
||||
// First conversation has a draft
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft from conv A",
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - switch to conversation B
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - input should be cleared (no draft for conv-B)
|
||||
expect(chatInputRef.current?.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("restores draft when switching to a conversation with an existing draft", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
const draftForConvB = "Saved draft for conversation B";
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: draftForConvB,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - switch to conversation B
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - draft for conv-B should be restored
|
||||
expect(chatInputRef.current?.textContent).toBe(draftForConvB);
|
||||
});
|
||||
|
||||
it("cancels pending save when switching conversations", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Start typing in conv-A
|
||||
chatInputRef.current!.textContent = "Draft for conv-A";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Switch conversation before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Complete the debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - the save should NOT have happened because conversation changed
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("task ID to real conversation ID transition", () => {
|
||||
it("transfers draft from task ID to real conversation ID during transition", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Draft typed during init");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "task-abc-123" } },
|
||||
);
|
||||
|
||||
// Simulate user typing during task initialization
|
||||
chatInputRef.current!.textContent = "Draft typed during init";
|
||||
|
||||
// Act - transition to real conversation ID
|
||||
rerender({ conversationId: "conv-real-123" });
|
||||
|
||||
// Assert - draft should be saved to the new real conversation ID
|
||||
expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith(
|
||||
"conv-real-123",
|
||||
{ draftMessage: "Draft typed during init" },
|
||||
);
|
||||
|
||||
// And the draft should remain visible in the input
|
||||
expect(chatInputRef.current?.textContent).toBe("Draft typed during init");
|
||||
});
|
||||
|
||||
it("does not transfer empty draft during task-to-real transition", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "task-abc-123" } },
|
||||
);
|
||||
|
||||
// Act - transition to real conversation ID with empty input
|
||||
rerender({ conversationId: "conv-real-123" });
|
||||
|
||||
// Assert - no draft should be saved (input is cleared, checked by hook)
|
||||
// The setConversationState should not be called with draftMessage
|
||||
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not transfer draft for non-task ID transitions", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Some draft");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - normal conversation switch (not task-to-real)
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - should not use setConversationState directly
|
||||
// (the normal path uses setDraftMessage from the hook)
|
||||
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDraft and isRestored state", () => {
|
||||
it("returns hasDraft true when draft exists in hook state", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-has-draft";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Existing draft",
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.hasDraft).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasDraft false when no draft exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-draft";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.hasDraft).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isRestored to true after restoration completes", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restored";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft to restore",
|
||||
});
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.isRestored).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup on unmount", () => {
|
||||
it("clears pending timeout on unmount", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-unmount";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Start a save
|
||||
chatInputRef.current!.textContent = "Draft";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Unmount before debounce completes
|
||||
unmount();
|
||||
|
||||
// Complete the debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - save should not have been called after unmount
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,7 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: null,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +118,7 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
@@ -155,6 +157,7 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
|
||||
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
134
frontend/__tests__/hooks/use-org-type-and-access.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("#/context/use-selected-organization", () => ({
|
||||
useSelectedOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-organizations", () => ({
|
||||
useOrganizations: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { useOrganizations } from "#/hooks/query/use-organizations";
|
||||
|
||||
const mockUseSelectedOrganizationId = vi.mocked(useSelectedOrganizationId);
|
||||
const mockUseOrganizations = vi.mocked(useOrganizations);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("useOrgTypeAndAccess", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return false for all booleans when no organization is selected", async () => {
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: null,
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [], currentOrgId: null },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toBeUndefined();
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(false);
|
||||
expect(result.current.canViewOrgRoutes).toBe(false);
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return isPersonalOrg=true and isTeamOrg=false for personal org", async () => {
|
||||
const personalOrg = { id: "org-1", is_personal: true, name: "Personal" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-1",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [personalOrg], currentOrgId: "org-1" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(personalOrg);
|
||||
expect(result.current.isPersonalOrg).toBe(true);
|
||||
expect(result.current.isTeamOrg).toBe(false);
|
||||
expect(result.current.canViewOrgRoutes).toBe(false);
|
||||
expect(result.current.organizationId).toBe("org-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return isPersonalOrg=false and isTeamOrg=true for team org", async () => {
|
||||
const teamOrg = { id: "org-2", is_personal: false, name: "Team" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-2",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [teamOrg], currentOrgId: "org-2" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(teamOrg);
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
expect(result.current.organizationId).toBe("org-2");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return canViewOrgRoutes=true only when isTeamOrg AND organizationId is truthy", async () => {
|
||||
const teamOrg = { id: "org-3", is_personal: false, name: "Team" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-3",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [teamOrg], currentOrgId: "org-3" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.organizationId).toBe("org-3");
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should treat undefined is_personal field as team org", async () => {
|
||||
// Organization without is_personal field (undefined)
|
||||
const orgWithoutPersonalField = { id: "org-4", name: "Unknown Type" };
|
||||
mockUseSelectedOrganizationId.mockReturnValue({
|
||||
organizationId: "org-4",
|
||||
setOrganizationId: vi.fn(),
|
||||
});
|
||||
mockUseOrganizations.mockReturnValue({
|
||||
data: { organizations: [orgWithoutPersonalField], currentOrgId: "org-4" },
|
||||
} as unknown as ReturnType<typeof useOrganizations>);
|
||||
|
||||
const { result } = renderHook(() => useOrgTypeAndAccess(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedOrg).toEqual(orgWithoutPersonalField);
|
||||
expect(result.current.isPersonalOrg).toBe(false);
|
||||
expect(result.current.isTeamOrg).toBe(true);
|
||||
expect(result.current.canViewOrgRoutes).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
98
frontend/__tests__/hooks/use-permission.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
import { rolePermissions } from "#/utils/org/permissions";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
|
||||
describe("usePermission", () => {
|
||||
const setup = (role: OrganizationUserRole) =>
|
||||
renderHook(() => usePermission(role)).result.current;
|
||||
|
||||
describe("hasPermission", () => {
|
||||
it("returns true when the role has the permission", () => {
|
||||
const { hasPermission } = setup("admin");
|
||||
|
||||
expect(hasPermission("invite_user_to_organization")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the role does not have the permission", () => {
|
||||
const { hasPermission } = setup("member");
|
||||
|
||||
expect(hasPermission("invite_user_to_organization")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rolePermissions integration", () => {
|
||||
it("matches the permissions defined for the role", () => {
|
||||
const { hasPermission } = setup("member");
|
||||
|
||||
rolePermissions.member.forEach((permission) => {
|
||||
expect(hasPermission(permission)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("change_user_role permission behavior", () => {
|
||||
const run = (
|
||||
activeUserRole: OrganizationUserRole,
|
||||
targetUserId: string,
|
||||
targetRole: OrganizationUserRole,
|
||||
activeUserId = "123",
|
||||
) => {
|
||||
const { hasPermission } = renderHook(() =>
|
||||
usePermission(activeUserRole),
|
||||
).result.current;
|
||||
|
||||
// users can't change their own roles
|
||||
if (activeUserId === targetUserId) return false;
|
||||
|
||||
return hasPermission(`change_user_role:${targetRole}`);
|
||||
};
|
||||
|
||||
describe("member role", () => {
|
||||
it("cannot change any roles", () => {
|
||||
expect(run("member", "u2", "member")).toBe(false);
|
||||
expect(run("member", "u2", "admin")).toBe(false);
|
||||
expect(run("member", "u2", "owner")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin role", () => {
|
||||
it("cannot change owner role", () => {
|
||||
expect(run("admin", "u2", "owner")).toBe(false);
|
||||
});
|
||||
|
||||
it("can change member or admin roles", () => {
|
||||
expect(run("admin", "u2", "member")).toBe(
|
||||
rolePermissions.admin.includes("change_user_role:member")
|
||||
);
|
||||
expect(run("admin", "u2", "admin")).toBe(
|
||||
rolePermissions.admin.includes("change_user_role:admin")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("owner role", () => {
|
||||
it("can change owner, admin, and member roles", () => {
|
||||
expect(run("owner", "u2", "admin")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:admin"),
|
||||
);
|
||||
|
||||
expect(run("owner", "u2", "member")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:member"),
|
||||
);
|
||||
|
||||
expect(run("owner", "u2", "owner")).toBe(
|
||||
rolePermissions.owner.includes("change_user_role:owner"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("self role change", () => {
|
||||
it("is always disallowed", () => {
|
||||
expect(run("owner", "u2", "member", "u2")).toBe(false);
|
||||
expect(run("admin", "u2", "member", "u2")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
577
frontend/__tests__/hooks/use-sandbox-recovery.test.tsx
Normal file
577
frontend/__tests__/hooks/use-sandbox-recovery.test.tsx
Normal file
@@ -0,0 +1,577 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import * as customToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [{ provider: "github", token: "test-token" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers");
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation");
|
||||
|
||||
describe("useSandboxRecovery", () => {
|
||||
let mockMutate: ReturnType<typeof vi.fn>;
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockMutate = vi.fn();
|
||||
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "idle",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
// Reset document.visibilityState
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial load recovery", () => {
|
||||
it("should call resumeSandbox on initial load when conversation is STOPPED", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: "conv-123",
|
||||
providers: [{ provider: "github", token: "test-token" }],
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox on initial load when conversation is RUNNING", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when conversationId is undefined", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: undefined,
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when conversationStatus is undefined", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: undefined,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only call resumeSandbox once per conversation on initial load", () => {
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with same props - should not trigger again
|
||||
rerender();
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call resumeSandbox for a new conversation after navigating", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useSandboxRecovery({
|
||||
conversationId,
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
initialProps: { conversationId: "conv-123" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: "conv-123" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Navigate to a different conversation
|
||||
rerender({ conversationId: "conv-456" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(mockMutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: "conv-456" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab focus recovery", () => {
|
||||
it("should call resumeSandbox when tab becomes visible and refetch returns STOPPED", async () => {
|
||||
// Start with tab hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING", // Cached status is RUNNING
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// No initial recovery for RUNNING
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch should be called to get fresh status
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// Recovery should trigger because fresh status is STOPPED
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible and refetch returns RUNNING", async () => {
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "RUNNING" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// No initial recovery for RUNNING
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch was called but status is still RUNNING
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible but refetchConversation is not provided", async () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
// No refetchConversation provided
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Initial load triggers recovery
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
mockMutate.mockClear();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// No recovery on tab focus without refetchConversation
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes hidden", async () => {
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Initial load triggers recovery
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
mockMutate.mockClear();
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch should NOT be called when tab is hidden
|
||||
expect(mockRefetch).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clean up visibility event listener on unmount", () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible while isPending is true", async () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch will be called when isPending is true
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// resumeSandbox should NOT be called
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle refetch errors gracefully without crashing", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRefetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch was called
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// Error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to refetch conversation on visibility change:",
|
||||
expect.any(Error),
|
||||
);
|
||||
// No recovery attempt was made (due to error)
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery callbacks", () => {
|
||||
it("should return isResuming=false when no recovery is in progress", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(result.current.isResuming).toBe(false);
|
||||
});
|
||||
|
||||
it("should return isResuming=true when mutation is pending", () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(result.current.isResuming).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onSuccess callback when recovery succeeds", () => {
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
onSuccess,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Get the onSuccess callback passed to mutate
|
||||
const mutateCall = mockMutate.mock.calls[0];
|
||||
const options = mutateCall[1];
|
||||
|
||||
// Simulate successful mutation
|
||||
act(() => {
|
||||
options.onSuccess();
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onError callback and display toast when recovery fails", () => {
|
||||
const onError = vi.fn();
|
||||
const testError = new Error("Resume failed");
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
onError,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Get the onError callback passed to mutate
|
||||
const mutateCall = mockMutate.mock.calls[0];
|
||||
const options = mutateCall[1];
|
||||
|
||||
// Simulate failed mutation
|
||||
act(() => {
|
||||
options.onError(testError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(testError);
|
||||
expect(vi.mocked(customToastHandlers.displayErrorToast)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when isPending is true", () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Should not call mutate because isPending is true
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket disconnect (negative test)", () => {
|
||||
it("should NOT have any mechanism to auto-resume on WebSocket disconnect", () => {
|
||||
// This test documents the intended behavior: the hook does NOT
|
||||
// listen for WebSocket disconnects. Recovery only happens on:
|
||||
// 1. Initial page load (STOPPED status)
|
||||
// 2. Tab focus (visibilitychange event)
|
||||
//
|
||||
// There is intentionally NO onDisconnect handler or WebSocket listener.
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// The hook should only expose isResuming - no disconnect-related functionality
|
||||
expect(result.current).toEqual({
|
||||
isResuming: expect.any(Boolean),
|
||||
});
|
||||
|
||||
// No calls should have been made for RUNNING status
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,18 +6,54 @@ import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
|
||||
|
||||
// Mock useOrgTypeAndAccess
|
||||
const mockOrgTypeAndAccess = vi.hoisted(() => ({
|
||||
isPersonalOrg: false,
|
||||
isTeamOrg: false,
|
||||
organizationId: null as string | null,
|
||||
selectedOrg: null,
|
||||
canViewOrgRoutes: false,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-org-type-and-access", () => ({
|
||||
useOrgTypeAndAccess: () => mockOrgTypeAndAccess,
|
||||
}));
|
||||
|
||||
// Mock useMe
|
||||
const mockMe = vi.hoisted(() => ({
|
||||
data: null as { role: string } | null | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-me", () => ({
|
||||
useMe: () => mockMe,
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const mockConfig = (appMode: "saas" | "oss", hideLlmSettings = false) => {
|
||||
const mockConfig = (
|
||||
appMode: "saas" | "oss",
|
||||
hideLlmSettings = false,
|
||||
enableBilling = true,
|
||||
) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: appMode,
|
||||
feature_flags: { hide_llm_settings: hideLlmSettings },
|
||||
feature_flags: {
|
||||
hide_llm_settings: hideLlmSettings,
|
||||
enable_billing: enableBilling,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
} as Awaited<ReturnType<typeof OptionService.getConfig>>);
|
||||
};
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useRevalidator: () => ({ revalidate: vi.fn() }),
|
||||
}));
|
||||
|
||||
const mockConfigWithFeatureFlags = (
|
||||
appMode: "saas" | "oss",
|
||||
featureFlags: Partial<WebClientFeatureFlags>,
|
||||
@@ -25,7 +61,7 @@ const mockConfigWithFeatureFlags = (
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
|
||||
app_mode: appMode,
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true, // Enable billing by default so it's not hidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -41,19 +77,38 @@ const mockConfigWithFeatureFlags = (
|
||||
describe("useSettingsNavItems", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.restoreAllMocks();
|
||||
// Reset mock state
|
||||
mockOrgTypeAndAccess.isPersonalOrg = false;
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = null;
|
||||
mockOrgTypeAndAccess.selectedOrg = null;
|
||||
mockOrgTypeAndAccess.canViewOrgRoutes = false;
|
||||
mockMe.data = null;
|
||||
});
|
||||
|
||||
it("should return SAAS_NAV_ITEMS when app_mode is 'saas'", async () => {
|
||||
it("should return SAAS_NAV_ITEMS minus billing/org/org-members when userRole is 'member'", async () => {
|
||||
mockConfig("saas");
|
||||
mockMe.data = { role: "member" };
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(SAAS_NAV_ITEMS);
|
||||
expect(result.current).toEqual(
|
||||
SAAS_NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.to !== "/settings/billing" &&
|
||||
item.to !== "/settings/org" &&
|
||||
item.to !== "/settings/org-members",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return OSS_NAV_ITEMS when app_mode is 'oss'", async () => {
|
||||
mockConfig("oss");
|
||||
mockMe.data = { role: "admin" };
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -63,6 +118,8 @@ describe("useSettingsNavItems", () => {
|
||||
|
||||
it("should filter out '/settings' item when hide_llm_settings feature flag is enabled", async () => {
|
||||
mockConfig("saas", true);
|
||||
mockMe.data = { role: "admin" };
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -72,7 +129,163 @@ describe("useSettingsNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("org-type and role-based filtering", () => {
|
||||
it("should include org routes by default for team org admin", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load (check that any SAAS item is present)
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be included for team org admin
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when isPersonalOrg is true", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load (check that any SAAS item is present)
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be filtered out for personal orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when user role is member", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "member" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be hidden for members
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide org routes when no organization is selected", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.isPersonalOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = null;
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Org routes should be hidden when no org is selected
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/org-members"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should hide billing route when isTeamOrg is true", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isTeamOrg = true;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Billing should be hidden for team orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/billing"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show billing route for personal org", async () => {
|
||||
mockConfig("saas");
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true;
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = "org-123";
|
||||
mockMe.data = { role: "admin" };
|
||||
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/user"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Billing should be visible for personal orgs
|
||||
expect(
|
||||
result.current.find((item) => item.to === "/settings/billing"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hide page feature flags", () => {
|
||||
beforeEach(() => {
|
||||
// Set up user as admin with org context so billing is accessible
|
||||
mockMe.data = { role: "admin" };
|
||||
mockOrgTypeAndAccess.isPersonalOrg = true; // Personal org shows billing
|
||||
mockOrgTypeAndAccess.isTeamOrg = false;
|
||||
mockOrgTypeAndAccess.organizationId = "org-1";
|
||||
});
|
||||
|
||||
it("should filter out '/settings/user' when hide_users_page is true", async () => {
|
||||
mockConfigWithFeatureFlags("saas", { hide_users_page: true });
|
||||
const { result } = renderHook(() => useSettingsNavItems(), { wrapper });
|
||||
|
||||
286
frontend/__tests__/hooks/use-visibility-recovery.test.ts
Normal file
286
frontend/__tests__/hooks/use-visibility-recovery.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useVisibilityChange } from "#/hooks/use-visibility-change";
|
||||
|
||||
describe("useVisibilityChange", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document.visibilityState to visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should return isVisible=true when document is visible", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("should return isVisible=false when document is hidden", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility change events", () => {
|
||||
it("should update isVisible when visibility changes to hidden", () => {
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("should update isVisible when visibility changes to visible", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callbacks", () => {
|
||||
it("should call onVisibilityChange with the new state", () => {
|
||||
const onVisibilityChange = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisibilityChange }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("hidden");
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("visible");
|
||||
});
|
||||
|
||||
it("should call onVisible only when tab becomes visible", () => {
|
||||
const onVisible = vi.fn();
|
||||
const onHidden = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisible, onHidden }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).toHaveBeenCalledTimes(1);
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onHidden only when tab becomes hidden", () => {
|
||||
const onHidden = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onHidden }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate tab becoming visible (should not call onHidden)
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enabled option", () => {
|
||||
it("should not listen for events when enabled=false", () => {
|
||||
const onVisible = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisible, enabled: false }));
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should start listening when enabled changes from false to true", () => {
|
||||
const onVisible = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useVisibilityChange({ onVisible, enabled }),
|
||||
{ initialProps: { enabled: false } },
|
||||
);
|
||||
|
||||
// Simulate event while disabled
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
|
||||
// Enable the hook
|
||||
rerender({ enabled: true });
|
||||
|
||||
// Now events should be captured
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should remove event listener on unmount", () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove event listener when enabled changes to false", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useVisibilityChange({ enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callback stability", () => {
|
||||
it("should handle callback updates without missing events", () => {
|
||||
const onVisible1 = vi.fn();
|
||||
const onVisible2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ onVisible }) => useVisibilityChange({ onVisible }),
|
||||
{ initialProps: { onVisible: onVisible1 } },
|
||||
);
|
||||
|
||||
// Update callback
|
||||
rerender({ onVisible: onVisible2 });
|
||||
|
||||
// Simulate visibility change
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible1).not.toHaveBeenCalled();
|
||||
expect(onVisible2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import i18n from "../../src/i18n";
|
||||
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
describe("Translations", () => {
|
||||
it("should render translated text", () => {
|
||||
i18n.changeLanguage("en");
|
||||
renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<AccountSettingsContextMenu onLogout={() => {}} onClose={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not attempt to load unsupported language codes", async () => {
|
||||
// Test that the configuration prevents 404 errors by not attempting to load
|
||||
// unsupported language codes like 'en-US@posix'
|
||||
const originalLanguage = i18n.language;
|
||||
|
||||
try {
|
||||
// With nonExplicitSupportedLngs: false, i18next will not attempt to load
|
||||
// unsupported language codes, preventing 404 errors
|
||||
|
||||
// Test with a language code that includes region but is not in supportedLngs
|
||||
await i18n.changeLanguage("en-US@posix");
|
||||
|
||||
// Since "en-US@posix" is not in supportedLngs and nonExplicitSupportedLngs is false,
|
||||
// i18next should fall back to the fallbackLng ("en")
|
||||
expect(i18n.language).toBe("en");
|
||||
|
||||
// Test another unsupported region code
|
||||
await i18n.changeLanguage("ja-JP");
|
||||
|
||||
// Even with nonExplicitSupportedLngs: false, i18next still falls back to base language
|
||||
// if it exists in supportedLngs, but importantly, it won't make a 404 request first
|
||||
expect(i18n.language).toBe("ja");
|
||||
|
||||
// Test that supported languages still work
|
||||
await i18n.changeLanguage("ja");
|
||||
expect(i18n.language).toBe("ja");
|
||||
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
expect(i18n.language).toBe("zh-CN");
|
||||
|
||||
} finally {
|
||||
// Restore the original language
|
||||
await i18n.changeLanguage(originalLanguage);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have proper i18n configuration", () => {
|
||||
// Test that the i18n instance has the expected configuration
|
||||
expect(i18n.options.supportedLngs).toBeDefined();
|
||||
|
||||
// nonExplicitSupportedLngs should be false to prevent 404 errors
|
||||
expect(i18n.options.nonExplicitSupportedLngs).toBe(false);
|
||||
|
||||
// fallbackLng can be a string or array, check if it includes "en"
|
||||
const fallbackLng = i18n.options.fallbackLng;
|
||||
if (Array.isArray(fallbackLng)) {
|
||||
expect(fallbackLng).toContain("en");
|
||||
} else {
|
||||
expect(fallbackLng).toBe("en");
|
||||
}
|
||||
|
||||
// Test that supported languages include both base and region-specific codes
|
||||
const supportedLngs = i18n.options.supportedLngs as string[];
|
||||
expect(supportedLngs).toContain("en");
|
||||
expect(supportedLngs).toContain("zh-CN");
|
||||
expect(supportedLngs).toContain("zh-TW");
|
||||
expect(supportedLngs).toContain("ko-KR");
|
||||
});
|
||||
});
|
||||
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
10
frontend/__tests__/routes/api-keys.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientLoader } from "#/routes/api-keys";
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
@@ -18,6 +18,14 @@ const renderAppSettingsScreen = () =>
|
||||
),
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the screen", () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
367
frontend/__tests__/routes/billing.test.tsx
Normal file
367
frontend/__tests__/routes/billing.test.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import BillingSettingsScreen, { clientLoader } from "#/routes/billing";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import * as orgStore from "#/stores/selected-organization-store";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackCreditsPurchased: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useBalance hook
|
||||
const mockUseBalance = vi.fn();
|
||||
vi.mock("#/hooks/query/use-balance", () => ({
|
||||
useBalance: () => mockUseBalance(),
|
||||
}));
|
||||
|
||||
// Mock useCreateStripeCheckoutSession hook
|
||||
vi.mock(
|
||||
"#/hooks/mutation/stripe/use-create-stripe-checkout-session",
|
||||
() => ({
|
||||
useCreateStripeCheckoutSession: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
describe("Billing Route", () => {
|
||||
const { mockQueryClient } = vi.hoisted(() => ({
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
})(),
|
||||
}));
|
||||
|
||||
// Mock queryClient to use our test instance
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
const setupSaasMode = (featureFlags = {}) => {
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
...featureFlags,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockQueryClient.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("clientLoader cache key", () => {
|
||||
it("should use the 'web-client-config' query key to read cached config", async () => {
|
||||
// Arrange: pre-populate the cache under the canonical key
|
||||
seedActiveUser({ role: "admin" });
|
||||
const cachedConfig = {
|
||||
app_mode: "saas" as const,
|
||||
posthog_client_key: "test",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
};
|
||||
mockQueryClient.setQueryData(["web-client-config"], cachedConfig);
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
// Act: invoke the clientLoader directly
|
||||
const result = await clientLoader();
|
||||
|
||||
// Assert: the loader should have found the cached config and NOT called getConfig
|
||||
expect(getConfigSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull(); // admin with billing enabled = no redirect
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should redirect members to /settings/user when accessing billing directly", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "member" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow admins to access billing route", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on billing page (component renders PaymentForm)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("user-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow owners to access billing route", async () => {
|
||||
// Arrange
|
||||
setupSaasMode();
|
||||
seedActiveUser({ role: "owner" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on billing page
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("user-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined (no org selected)", async () => {
|
||||
// Arrange: no org selected, so getActiveOrganizationUser returns undefined
|
||||
setupSaasMode();
|
||||
// Explicitly clear org store so getActiveOrganizationUser returns undefined
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect all users when enable_billing is false", async () => {
|
||||
// Arrange: enable_billing=false means billing is hidden for everyone
|
||||
setupSaasMode({ enable_billing: false });
|
||||
seedActiveUser({ role: "owner" }); // Even owners should be redirected
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: BillingSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/billing"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should be redirected to user settings
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentForm permission behavior", () => {
|
||||
beforeEach(() => {
|
||||
mockUseBalance.mockReturnValue({
|
||||
data: "150.00",
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable input and button when isDisabled is true, but show balance", async () => {
|
||||
// Arrange & Act
|
||||
render(<PaymentForm isDisabled />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - balance is visible
|
||||
const balance = screen.getByTestId("user-balance");
|
||||
expect(balance).toBeInTheDocument();
|
||||
expect(balance).toHaveTextContent("$150.00");
|
||||
|
||||
// Assert - input is disabled
|
||||
const topUpInput = screen.getByTestId("top-up-input");
|
||||
expect(topUpInput).toBeDisabled();
|
||||
|
||||
// Assert - button is disabled
|
||||
const submitButton = screen.getByRole("button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable input and button when isDisabled is false", async () => {
|
||||
// Arrange & Act
|
||||
render(<PaymentForm isDisabled={false} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - input is enabled
|
||||
const topUpInput = screen.getByTestId("top-up-input");
|
||||
expect(topUpInput).not.toBeDisabled();
|
||||
|
||||
// Assert - button starts disabled (no amount entered) but is NOT
|
||||
// permanently disabled by the isDisabled prop
|
||||
const submitButton = screen.getByRole("button");
|
||||
// The button is disabled because no valid amount is entered, not because of isDisabled
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
659
frontend/__tests__/routes/device-verify.test.tsx
Normal file
659
frontend/__tests__/routes/device-verify.test.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import DeviceVerify from "#/routes/device-verify";
|
||||
|
||||
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
useIsAuthedMock: vi.fn(() => ({
|
||||
data: false as boolean | undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => useIsAuthedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: DeviceVerify,
|
||||
path: "/device-verify",
|
||||
},
|
||||
]);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe("DeviceVerify", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("close", vi.fn());
|
||||
// Mock fetch for API calls
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Enable feature flag by default
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("should show loading spinner while checking authentication", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not Authenticated State", () => {
|
||||
it("should show authentication required message when not authenticated", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$AUTH_REQUIRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SIGN_IN_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated without User Code", () => {
|
||||
it("should show manual code entry form when authenticated but no code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ENTER_CODE_PROMPT")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CONTINUE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should submit manually entered code", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText("DEVICE$CODE_INPUT_LABEL");
|
||||
await user.type(input, "TESTCODE");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "DEVICE$CONTINUE",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=TESTCODE",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated with User Code", () => {
|
||||
it("should show authorization confirmation when authenticated with code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$CODE_LABEL")).toBeInTheDocument();
|
||||
expect(screen.getByText("ABC-123")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_NOTICE")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_WARNING")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$CONFIRM_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show cancel and authorize buttons", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should include the EnterpriseBanner component when feature flag is enabled", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Banner should not be rendered
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
|
||||
// Container should use max-w-md (centered layout) instead of max-w-4xl
|
||||
const container = document.querySelector(".max-w-md");
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(document.querySelector(".max-w-4xl")).not.toBeInTheDocument();
|
||||
|
||||
// Authorization card should have mx-auto for centering
|
||||
const authCard = container?.querySelector(".mx-auto");
|
||||
expect(authCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call window.close when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "DEVICE$CANCEL" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(window.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should submit device verification when authorize button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=ABC-123",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processing State", () => {
|
||||
it("should show processing spinner during verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Make fetch hang to show processing state
|
||||
const mockFetch = vi.fn(() => new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success State", () => {
|
||||
it("should show success message after successful verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SUCCESS_MESSAGE")).toBeInTheDocument();
|
||||
// Should show success icon (checkmark)
|
||||
const successIcon = document.querySelector(".text-green-600");
|
||||
expect(successIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show try again button on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
it("should show error message when verification fails with non-ok response", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: "invalid_code" }),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_FAILED")).toBeInTheDocument();
|
||||
// Should show error icon (X)
|
||||
const errorIcon = document.querySelector(".text-red-600");
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error message when fetch throws an exception", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() => Promise.reject(new Error("Network error")));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_OCCURRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show try again button on error", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reload page when try again button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
vi.stubGlobal("location", { reload: reloadMock });
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tryAgainButton = screen.getByRole("button", {
|
||||
name: "DEVICE$TRY_AGAIN",
|
||||
});
|
||||
await user.click(tryAgainButton);
|
||||
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import GitSettingsScreen, { clientLoader } from "#/routes/git-settings";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
@@ -13,7 +13,6 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { integrationService } from "#/api/integration-service/integration-service.api";
|
||||
|
||||
const VALID_OSS_CONFIG: WebClientConfig = {
|
||||
app_mode: "oss",
|
||||
@@ -657,3 +656,10 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,7 +541,7 @@ describe("Settings 404", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Setup Payment modal", () => {
|
||||
describe("New user welcome toast", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
@@ -593,7 +593,7 @@ describe("Setup Payment modal", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should only render if SaaS mode and is new user", async () => {
|
||||
it("should not show the setup payment modal (removed) in SaaS mode for new users", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
is_new_user: true,
|
||||
@@ -603,9 +603,9 @@ describe("Setup Payment modal", () => {
|
||||
|
||||
await screen.findByTestId("root-layout");
|
||||
|
||||
const setupPaymentModal = await screen.findByTestId(
|
||||
"proceed-to-stripe-button",
|
||||
);
|
||||
expect(setupPaymentModal).toBeInTheDocument();
|
||||
// SetupPaymentModal was removed; verify it no longer renders
|
||||
expect(
|
||||
screen.queryByTestId("proceed-to-stripe-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,23 @@ import {
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import type { OrganizationMember } from "#/types/org";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
vi.mock("react-router", () => ({
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
}));
|
||||
vi.mock("react-router", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
useRevalidator: () => ({
|
||||
revalidate: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useIsAuthed hook
|
||||
const mockUseIsAuthed = vi.fn();
|
||||
@@ -24,14 +35,63 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
// Mock useConfig hook
|
||||
const mockUseConfig = vi.fn();
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = (
|
||||
orgId: string | null = null,
|
||||
meData?: {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
llm_api_key: string;
|
||||
max_iterations: number;
|
||||
llm_model: string;
|
||||
llm_api_key_for_byor: string | null;
|
||||
llm_base_url: string;
|
||||
},
|
||||
) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Default to orgId "1" if not provided (for backward compatibility)
|
||||
const finalOrgId = orgId ?? "1";
|
||||
useSelectedOrganizationStore.setState({ organizationId: finalOrgId });
|
||||
|
||||
// Pre-populate React Query cache with me data
|
||||
// If meData is provided, use it; otherwise use default owner data
|
||||
const defaultMeData = {
|
||||
org_id: finalOrgId,
|
||||
user_id: "99",
|
||||
email: "owner@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
queryClient.setQueryData(
|
||||
["organizations", finalOrgId, "me"],
|
||||
meData || defaultMeData,
|
||||
);
|
||||
|
||||
return render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -47,22 +107,58 @@ beforeEach(() => {
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
// Default mock for useConfig - returns SaaS mode by default
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Default mock for organizationService.getMe - returns owner role by default (full access)
|
||||
const defaultMeData: OrganizationMember = {
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "owner@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(defaultMeData);
|
||||
|
||||
// Reset organization store
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should render the basic form by default", async () => {
|
||||
// Use OSS mode so API key input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicFom = screen.getByTestId("llm-settings-form-basic");
|
||||
within(basicFom).getByTestId("llm-provider-input");
|
||||
within(basicFom).getByTestId("llm-model-input");
|
||||
within(basicFom).getByTestId("llm-api-key-input");
|
||||
within(basicFom).getByTestId("llm-api-key-help-anchor");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
within(basicForm).getByTestId("llm-provider-input");
|
||||
within(basicForm).getByTestId("llm-model-input");
|
||||
within(basicForm).getByTestId("llm-api-key-input");
|
||||
within(basicForm).getByTestId("llm-api-key-help-anchor");
|
||||
});
|
||||
|
||||
it("should render the default values if non exist", async () => {
|
||||
// Use OSS mode so API key input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
@@ -142,6 +238,12 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the advanced form if the switch is toggled", async () => {
|
||||
// Use OSS mode so agent-input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
@@ -176,6 +278,12 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the default advanced settings", async () => {
|
||||
// Use OSS mode so agent-input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
@@ -215,6 +323,12 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render existing advanced settings correctly", async () => {
|
||||
// Use OSS mode so agent-input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -336,10 +450,10 @@ describe("Content", () => {
|
||||
|
||||
describe("API key visibility in Basic Settings", () => {
|
||||
it("should hide API key input when SaaS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
// SaaS mode is already the default from beforeEach, but let's be explicit
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -363,10 +477,10 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should show API key input when SaaS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
// SaaS mode is already the default from beforeEach, but let's be explicit
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -394,10 +508,9 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "oss",
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -421,10 +534,9 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should show API key input when OSS mode is enabled and non-OpenHands provider is selected", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "oss",
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -452,10 +564,10 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should hide API key input when switching from non-OpenHands to OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
// SaaS mode is already the default from beforeEach, but let's be explicit
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -497,10 +609,10 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should show API key input when switching from OpenHands to non-OpenHands provider in SaaS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - only return app_mode for these tests
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
// SaaS mode is already the default from beforeEach, but let's be explicit
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -548,15 +660,17 @@ describe("Form submission", () => {
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
// select provider
|
||||
// select provider (switch to OpenAI so API key input becomes visible)
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// enter api key
|
||||
// enter api key (now visible after switching provider)
|
||||
const apiKey = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
// select model
|
||||
@@ -577,6 +691,12 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should submit the advanced form with the correct values", async () => {
|
||||
// Use OSS mode so agent-input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
@@ -685,6 +805,12 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the advanced form", async () => {
|
||||
// Use OSS mode so agent-input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -818,8 +944,17 @@ describe("Form submission", () => {
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Switch to a non-OpenHands provider first so API key input is visible
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// dirty the basic form
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const apiKey = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
@@ -1009,21 +1144,9 @@ describe("View persistence after saving advanced settings", () => {
|
||||
|
||||
it("should remain on Advanced view after saving when search API key is set", async () => {
|
||||
// Arrange: Start with default settings (non-SaaS mode to show search API key field)
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
// @ts-expect-error - partial mock for testing
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "fake-posthog-client-key",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
@@ -1080,12 +1203,37 @@ describe("Status toasts", () => {
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Toggle setting to change
|
||||
// Switch to a non-OpenHands provider so API key input is visible
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// Wait for API key input to appear
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
|
||||
// Also change the model to ensure form is dirty
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
await waitFor(() => {
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
// Enter API key
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
// Wait for submit button to be enabled
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
@@ -1100,12 +1248,37 @@ describe("Status toasts", () => {
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Toggle setting to change
|
||||
// Switch to a non-OpenHands provider so API key input is visible
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// Wait for API key input to appear
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
|
||||
// Also change the model to ensure form is dirty
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
await waitFor(() => {
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
// Enter API key
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
// Wait for submit button to be enabled
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
@@ -1115,6 +1288,12 @@ describe("Status toasts", () => {
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
// Use OSS mode to ensure API key input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
@@ -1133,7 +1312,11 @@ describe("Status toasts", () => {
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
// Wait for submit button to be enabled
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
@@ -1141,6 +1324,12 @@ describe("Status toasts", () => {
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
// Use OSS mode to ensure API key input is visible
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
@@ -1158,7 +1347,11 @@ describe("Status toasts", () => {
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
// Wait for submit button to be enabled
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
@@ -1166,3 +1359,411 @@ describe("Status toasts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Role-based permissions", () => {
|
||||
const getMeSpy = vi.spyOn(organizationService, "getMe");
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("User role (read-only)", () => {
|
||||
const memberData: OrganizationMember = {
|
||||
org_id: "2",
|
||||
user_id: "99",
|
||||
email: "user@example.com",
|
||||
role: "member",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock user role
|
||||
getMeSpy.mockResolvedValue(memberData);
|
||||
});
|
||||
|
||||
it("should disable all input fields in basic view", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("2", memberData); // orgId "2" returns user role
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
// Assert
|
||||
const providerInput = within(basicForm).getByTestId("llm-provider-input");
|
||||
const modelInput = within(basicForm).getByTestId("llm-model-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
// API key input may be hidden if OpenHands provider is selected in SaaS mode
|
||||
// If it exists, it should be disabled
|
||||
const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input");
|
||||
if (apiKeyInput) {
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// Note: No "should disable all input fields in advanced view" test for members
|
||||
// because members cannot access the advanced view (the toggle is disabled).
|
||||
|
||||
it("should not render submit button", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("2", memberData);
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const submitButton = screen.queryByTestId("submit-button");
|
||||
|
||||
// Assert
|
||||
expect(submitButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the advanced/basic toggle for read-only users", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("2", memberData);
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
// Assert - toggle should be disabled for members who lack edit_llm_settings
|
||||
await waitFor(() => {
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
});
|
||||
|
||||
// Basic form should remain visible (members can't switch to advanced)
|
||||
expect(
|
||||
screen.getByTestId("llm-settings-form-basic"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("Owner role (full access)", () => {
|
||||
beforeEach(() => {
|
||||
// Mock owner role
|
||||
getMeSpy.mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "owner@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable all input fields in basic view", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("1"); // orgId "1" returns owner role
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
// Assert
|
||||
const providerInput = within(basicForm).getByTestId("llm-provider-input");
|
||||
const modelInput = within(basicForm).getByTestId("llm-model-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providerInput).not.toBeDisabled();
|
||||
expect(modelInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// API key input may be hidden if OpenHands provider is selected in SaaS mode
|
||||
// If it exists, it should be enabled
|
||||
const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input");
|
||||
if (apiKeyInput) {
|
||||
expect(apiKeyInput).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should enable all input fields in advanced view", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("1");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
// Assert - owners can toggle between views
|
||||
expect(advancedSwitch).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
const advancedForm = await screen.findByTestId(
|
||||
"llm-settings-form-advanced",
|
||||
);
|
||||
|
||||
// Assert
|
||||
const modelInput = within(advancedForm).getByTestId(
|
||||
"llm-custom-model-input",
|
||||
);
|
||||
const baseUrlInput = within(advancedForm).getByTestId("base-url-input");
|
||||
const condenserSwitch = within(advancedForm).getByTestId(
|
||||
"enable-memory-condenser-switch",
|
||||
);
|
||||
const confirmationSwitch = within(advancedForm).getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(modelInput).not.toBeDisabled();
|
||||
expect(baseUrlInput).not.toBeDisabled();
|
||||
expect(condenserSwitch).not.toBeDisabled();
|
||||
expect(confirmationSwitch).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// API key input may be hidden if OpenHands provider is selected in SaaS mode
|
||||
// If it exists, it should be enabled
|
||||
const apiKeyInput =
|
||||
within(advancedForm).queryByTestId("llm-api-key-input");
|
||||
if (apiKeyInput) {
|
||||
expect(apiKeyInput).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should enable submit button when form is dirty", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("1");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
|
||||
// Assert - initially disabled (no changes)
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Act - make a change by selecting a different provider
|
||||
await userEvent.click(providerInput);
|
||||
const openAIOption = await screen.findByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
// Assert - button should be enabled
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow submitting form changes", async () => {
|
||||
// Arrange
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
renderLlmSettingsScreen("1");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
|
||||
// Select a different provider to make form dirty
|
||||
await userEvent.click(providerInput);
|
||||
const openAIOption = await screen.findByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
await waitFor(() => {
|
||||
expect(providerInput).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// Select a different model to ensure form is dirty
|
||||
await userEvent.click(modelInput);
|
||||
const modelOption = await screen.findByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
await waitFor(() => {
|
||||
expect(modelInput).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
// Wait for form to be marked as dirty
|
||||
const submitButton = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Note: The former "should disable security analyzer dropdown when confirmation mode
|
||||
// is enabled" test was removed. It was in the member block and only passed because
|
||||
// members have isReadOnly=true (all fields disabled), not because confirmation mode
|
||||
// disables the analyzer. For owners/admins, the security analyzer is enabled
|
||||
// regardless of confirmation mode.
|
||||
});
|
||||
|
||||
describe("Admin role (full access)", () => {
|
||||
beforeEach(() => {
|
||||
// Mock admin role
|
||||
getMeSpy.mockResolvedValue({
|
||||
org_id: "3",
|
||||
user_id: "99",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable all input fields in basic view", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("3"); // orgId "3" returns admin role
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
// Assert
|
||||
const providerInput = within(basicForm).getByTestId("llm-provider-input");
|
||||
const modelInput = within(basicForm).getByTestId("llm-model-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(providerInput).not.toBeDisabled();
|
||||
expect(modelInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// API key input may be hidden if OpenHands provider is selected in SaaS mode
|
||||
// If it exists, it should be enabled
|
||||
const apiKeyInput = within(basicForm).queryByTestId("llm-api-key-input");
|
||||
if (apiKeyInput) {
|
||||
expect(apiKeyInput).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should enable all input fields in advanced view", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("3");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
// Assert - admins can toggle between views
|
||||
expect(advancedSwitch).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
const advancedForm = await screen.findByTestId(
|
||||
"llm-settings-form-advanced",
|
||||
);
|
||||
|
||||
// Assert
|
||||
const modelInput = within(advancedForm).getByTestId(
|
||||
"llm-custom-model-input",
|
||||
);
|
||||
const baseUrlInput = within(advancedForm).getByTestId("base-url-input");
|
||||
const condenserSwitch = within(advancedForm).getByTestId(
|
||||
"enable-memory-condenser-switch",
|
||||
);
|
||||
const confirmationSwitch = within(advancedForm).getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(modelInput).not.toBeDisabled();
|
||||
expect(baseUrlInput).not.toBeDisabled();
|
||||
expect(condenserSwitch).not.toBeDisabled();
|
||||
expect(confirmationSwitch).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// API key input may be hidden if OpenHands provider is selected in SaaS mode
|
||||
// If it exists, it should be enabled
|
||||
const apiKeyInput =
|
||||
within(advancedForm).queryByTestId("llm-api-key-input");
|
||||
if (apiKeyInput) {
|
||||
expect(apiKeyInput).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should enable submit button when form is dirty", async () => {
|
||||
// Arrange
|
||||
renderLlmSettingsScreen("3");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
|
||||
// Assert - initially disabled (no changes)
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Act - make a change by selecting a different provider
|
||||
await userEvent.click(providerInput);
|
||||
const openAIOption = await screen.findByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
|
||||
// Assert - button should be enabled
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow submitting form changes", async () => {
|
||||
// Arrange
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
renderLlmSettingsScreen("3");
|
||||
|
||||
// Act
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
|
||||
// Select a different provider to make form dirty
|
||||
await userEvent.click(providerInput);
|
||||
const openAIOption = await screen.findByText("OpenAI");
|
||||
await userEvent.click(openAIOption);
|
||||
await waitFor(() => {
|
||||
expect(providerInput).toHaveValue("OpenAI");
|
||||
});
|
||||
|
||||
// Select a different model to ensure form is dirty
|
||||
await userEvent.click(modelInput);
|
||||
const modelOption = await screen.findByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
await waitFor(() => {
|
||||
expect(modelInput).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
// Wait for form to be marked as dirty
|
||||
const submitButton = await screen.findByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", async () => {
|
||||
// This test verifies the clientLoader is exported for consistency with other routes
|
||||
// Note: All roles have view_llm_settings permission, so this guard ensures
|
||||
// the route is protected and can be restricted in the future if needed
|
||||
const { clientLoader } = await import("#/routes/llm-settings");
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
954
frontend/__tests__/routes/manage-org.test.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { selectOrganization } from "test-utils";
|
||||
import ManageOrg from "#/routes/manage-org";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import {
|
||||
resetOrgMockData,
|
||||
MOCK_TEAM_ORG_ACME,
|
||||
INITIAL_MOCK_ORGS,
|
||||
} from "#/mocks/org-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
const mockQueryClient = vi.hoisted(() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
});
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
function ManageOrgWithPortalRoot() {
|
||||
return (
|
||||
<div>
|
||||
<ManageOrg />
|
||||
<div data-testid="portal-root" id="portal-root" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
Component: () => <div data-testid="home-screen" />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
// @ts-expect-error - type mismatch
|
||||
loader: clientLoader,
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
HydrateFallback: () => <div>Loading...</div>,
|
||||
children: [
|
||||
{
|
||||
Component: ManageOrgWithPortalRoot,
|
||||
path: "/settings/org",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderManageOrg = () =>
|
||||
render(<RouteStub initialEntries={["/settings/org"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ORG$SELECT_ORGANIZATION_PLACEHOLDER: "Please select an organization",
|
||||
ORG$PERSONAL_WORKSPACE: "Personal Workspace",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
...(await vi.importActual("react-router")),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({ data: true }),
|
||||
}));
|
||||
|
||||
describe("Manage Org Route", () => {
|
||||
const getMeSpy = vi.spyOn(organizationService, "getMe");
|
||||
|
||||
// Test data constants
|
||||
const TEST_USERS: Record<"OWNER" | "ADMIN", OrganizationMember> = {
|
||||
OWNER: {
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "owner",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
},
|
||||
ADMIN: {
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to set up user mock
|
||||
const setupUserMock = (userData: {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
llm_api_key: string;
|
||||
max_iterations: number;
|
||||
llm_model: string;
|
||||
llm_api_key_for_byor: string | null;
|
||||
llm_base_url: string;
|
||||
status: "active" | "invited" | "inactive";
|
||||
}) => {
|
||||
getMeSpy.mockResolvedValue(userData);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Set Zustand store to a team org so clientLoader's org route protection allows access
|
||||
useSelectedOrganizationStore.setState({
|
||||
organizationId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
// Seed organizations into the module-level queryClient used by clientLoader
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
queryClient = new QueryClient();
|
||||
// Pre-seed organizations so org selector renders immediately (avoids flaky race with API fetch)
|
||||
queryClient.setQueryData(["organizations"], {
|
||||
items: INITIAL_MOCK_ORGS,
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // Enable billing by default so billing UI is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Set default mock for user (owner role has all permissions)
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset organization mock data to ensure clean state between tests
|
||||
resetOrgMockData();
|
||||
// Reset Zustand store to ensure clean state between tests
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
// Clear module-level queryClient used by clientLoader
|
||||
mockQueryClient.clear();
|
||||
// Clear test queryClient
|
||||
queryClient?.clear();
|
||||
});
|
||||
|
||||
it("should render the available credits", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
const credits = screen.getByTestId("available-credits");
|
||||
expect(credits).toHaveTextContent("100");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render account details", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
await waitFor(() => {
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
expect(orgName).toHaveTextContent("Personal Workspace");
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to add credits", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
// Simulate adding credits — wait for permissions-dependent button
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await userEvent.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
const amountInput = within(addCreditsForm).getByTestId("amount-input");
|
||||
const nextButton = within(addCreditsForm).getByRole("button", {
|
||||
name: /next/i,
|
||||
});
|
||||
|
||||
await userEvent.type(amountInput, "1000");
|
||||
await userEvent.click(nextButton);
|
||||
|
||||
// expect redirect to payment page
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should close the modal when clicking cancel", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
// Simulate adding credits — wait for permissions-dependent button
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await userEvent.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(addCreditsForm).getByRole("button", {
|
||||
name: /close/i,
|
||||
});
|
||||
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByTestId("add-credits-form")).not.toBeInTheDocument();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("AddCreditsModal", () => {
|
||||
const openAddCreditsModal = async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 }); // user is owner in org 1
|
||||
|
||||
const addCreditsButton = await waitFor(() => screen.getByText(/add/i));
|
||||
await user.click(addCreditsButton);
|
||||
|
||||
const addCreditsForm = screen.getByTestId("add-credits-form");
|
||||
expect(addCreditsForm).toBeInTheDocument();
|
||||
|
||||
return { user, addCreditsForm };
|
||||
};
|
||||
|
||||
describe("Button State Management", () => {
|
||||
it("should enable submit button initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button when input contains valid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "100");
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable submit button after validation error is shown", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Attributes & Placeholder", () => {
|
||||
it("should have min attribute set to 10", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("min", "10");
|
||||
});
|
||||
|
||||
it("should have max attribute set to 25000", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("max", "25000");
|
||||
});
|
||||
|
||||
it("should have step attribute set to 1", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
expect(amountInput).toHaveAttribute("step", "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Message Display", () => {
|
||||
it("should not display error message initially when modal opens", async () => {
|
||||
await openAddCreditsModal();
|
||||
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message after submitting amount above maximum", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display error message after submitting decimal value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "50.5");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should replace error message when submitting different invalid value", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "25001");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MAXIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Submission Behavior", () => {
|
||||
it("should prevent submission when amount is invalid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call createCheckoutSession with correct amount when valid", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call createCheckoutSession when validation fails", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "-50");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Verify mutation was not called
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_NEGATIVE_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should close modal on successful submission", async () => {
|
||||
const createCheckoutSessionSpy = vi
|
||||
.spyOn(BillingService, "createCheckoutSession")
|
||||
.mockResolvedValue("https://checkout.stripe.com/test-session");
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "1000");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("add-credits-form"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow API call when validation passes and clear any previous errors", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// First submit invalid value
|
||||
await user.type(amountInput, "9");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("amount-error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Then submit valid value
|
||||
await user.clear(amountInput);
|
||||
await user.type(amountInput, "100");
|
||||
await user.click(nextButton);
|
||||
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(100);
|
||||
const errorMessage = screen.queryByTestId("amount-error");
|
||||
expect(errorMessage).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle zero value correctly", async () => {
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
await user.type(amountInput, "0");
|
||||
await user.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByTestId("amount-error");
|
||||
expect(errorMessage).toHaveTextContent(
|
||||
"PAYMENT$ERROR_MINIMUM_AMOUNT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input correctly", async () => {
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const { user } = await openAddCreditsModal();
|
||||
const amountInput = screen.getByTestId("amount-input");
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
|
||||
// Number inputs typically don't accept spaces, but test the behavior
|
||||
await user.type(amountInput, " ");
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should not call API (empty/invalid input)
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show add credits option for ADMIN role", async () => {
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
// Verify credits are shown
|
||||
await waitFor(() => {
|
||||
const credits = screen.getByTestId("available-credits");
|
||||
expect(credits).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify add credits button is present (admins can add credits)
|
||||
const addButton = screen.getByText(/add/i);
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("actions", () => {
|
||||
it("should be able to update the organization name", async () => {
|
||||
const updateOrgNameSpy = vi.spyOn(
|
||||
organizationService,
|
||||
"updateOrganization",
|
||||
);
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas", // required to enable getMe
|
||||
}),
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
await waitFor(() =>
|
||||
expect(orgName).toHaveTextContent("Personal Workspace"),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("update-org-name-form"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const changeOrgNameButton = within(orgName).getByRole("button", {
|
||||
name: /change/i,
|
||||
});
|
||||
await userEvent.click(changeOrgNameButton);
|
||||
|
||||
const orgNameForm = screen.getByTestId("update-org-name-form");
|
||||
const orgNameInput = within(orgNameForm).getByRole("textbox");
|
||||
const saveButton = within(orgNameForm).getByRole("button", {
|
||||
name: /save/i,
|
||||
});
|
||||
|
||||
await userEvent.type(orgNameInput, "New Org Name");
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(updateOrgNameSpy).toHaveBeenCalledWith({
|
||||
orgId: "1",
|
||||
name: "New Org Name",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("update-org-name-form"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(orgName).toHaveTextContent("New Org Name");
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT allow roles other than owners to change org name", async () => {
|
||||
// Set admin role before rendering
|
||||
setupUserMock(TEST_USERS.ADMIN);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
const orgName = screen.getByTestId("org-name");
|
||||
const changeOrgNameButton = within(orgName).queryByRole("button", {
|
||||
name: /change/i,
|
||||
});
|
||||
expect(changeOrgNameButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT allow roles other than owners to delete an organization", async () => {
|
||||
setupUserMock(TEST_USERS.ADMIN);
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas", // required to enable getMe
|
||||
}),
|
||||
);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 3 }); // user is admin in org 4 (All Hands AI)
|
||||
|
||||
const deleteOrgButton = screen.queryByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
expect(deleteOrgButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be able to delete an organization", async () => {
|
||||
const deleteOrgSpy = vi.spyOn(organizationService, "deleteOrganization");
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const deleteOrgButton = await waitFor(() =>
|
||||
screen.getByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
}),
|
||||
);
|
||||
await userEvent.click(deleteOrgButton);
|
||||
|
||||
const deleteConfirmation = screen.getByTestId("delete-org-confirmation");
|
||||
const confirmButton = within(deleteConfirmation).getByRole("button", {
|
||||
name: /BUTTON\$CONFIRM/i,
|
||||
});
|
||||
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(deleteOrgSpy).toHaveBeenCalledWith({ orgId: "1" });
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// expect to have navigated to home screen
|
||||
await screen.findByTestId("home-screen");
|
||||
});
|
||||
|
||||
it.todo("should be able to update the organization billing info");
|
||||
});
|
||||
|
||||
describe("Role-based delete organization permission behavior", () => {
|
||||
it("should show delete organization button when user has canDeleteOrganization permission (Owner role)", async () => {
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const deleteButton = await screen.findByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
expect(deleteButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not show delete organization button when user lacks canDeleteOrganization permission ('Admin' role)", async () => {
|
||||
setupUserMock({
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
const deleteButton = screen.queryByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
|
||||
expect(deleteButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show delete organization button when user lacks canDeleteOrganization permission ('Member' role)", async () => {
|
||||
setupUserMock({
|
||||
org_id: "1",
|
||||
user_id: "1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Members lack view_billing permission, so the clientLoader redirects away from /settings/org
|
||||
renderManageOrg();
|
||||
|
||||
// The manage-org screen should NOT be accessible — clientLoader redirects
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("manage-org-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open delete confirmation modal when delete button is clicked (with permission)", async () => {
|
||||
setupUserMock(TEST_USERS.OWNER);
|
||||
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("delete-org-confirmation"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const deleteButton = await screen.findByRole("button", {
|
||||
name: /ORG\$DELETE_ORGANIZATION/i,
|
||||
});
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
expect(screen.getByTestId("delete-org-confirmation")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable_billing feature flag", () => {
|
||||
it("should show credits section when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("available-credits")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should show organization name section when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("org-name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should show Add Credits button when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const addButton = screen.getByText(/add/i);
|
||||
expect(addButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should hide all billing-related elements when enable_billing is false", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
renderManageOrg();
|
||||
await screen.findByTestId("manage-org-screen");
|
||||
await selectOrganization({ orgIndex: 0 });
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("available-credits"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/add/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
1062
frontend/__tests__/routes/manage-organization-members.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
10
frontend/__tests__/routes/mcp-settings.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clientLoader } from "#/routes/mcp-settings";
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { screen, act } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import PlannerTab from "#/routes/planner-tab";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
@@ -12,8 +12,15 @@ vi.mock("#/hooks/use-handle-plan-click", () => ({
|
||||
}));
|
||||
|
||||
describe("PlannerTab", () => {
|
||||
const originalRAF = global.requestAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Make requestAnimationFrame execute synchronously for testing
|
||||
global.requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
};
|
||||
// Reset store state to defaults
|
||||
useConversationStore.setState({
|
||||
planContent: null,
|
||||
@@ -21,6 +28,10 @@ describe("PlannerTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.requestAnimationFrame = originalRAF;
|
||||
});
|
||||
|
||||
describe("Create a plan button", () => {
|
||||
it("should be enabled when conversation mode is 'code'", () => {
|
||||
// Arrange
|
||||
@@ -52,4 +63,71 @@ describe("PlannerTab", () => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auto-scroll behavior", () => {
|
||||
it("should scroll to bottom when plan content is updated", () => {
|
||||
// Arrange
|
||||
const scrollTopSetter = vi.fn();
|
||||
const mockScrollHeight = 500;
|
||||
|
||||
// Mock scroll properties on HTMLElement prototype
|
||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollHeight",
|
||||
);
|
||||
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"scrollTop",
|
||||
);
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||
get: () => mockScrollHeight,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
|
||||
get: () => 0,
|
||||
set: scrollTopSetter,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Render with initial plan content
|
||||
useConversationStore.setState({
|
||||
planContent: "# Initial Plan",
|
||||
conversationMode: "plan",
|
||||
});
|
||||
|
||||
renderWithProviders(<PlannerTab />);
|
||||
|
||||
// Clear calls from initial render
|
||||
scrollTopSetter.mockClear();
|
||||
|
||||
// Act - Update plan content which should trigger auto-scroll
|
||||
act(() => {
|
||||
useConversationStore.setState({
|
||||
planContent: "# Updated Plan\n\nMore content added here.",
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - scrollTop should be set to scrollHeight
|
||||
expect(scrollTopSetter).toHaveBeenCalledWith(mockScrollHeight);
|
||||
} finally {
|
||||
// Restore original descriptors
|
||||
if (originalScrollHeightDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollHeight",
|
||||
originalScrollHeightDescriptor,
|
||||
);
|
||||
}
|
||||
if (originalScrollTopDescriptor) {
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"scrollTop",
|
||||
originalScrollTopDescriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import SecretsSettingsScreen, { clientLoader } from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import * as orgStore from "#/stores/selected-organization-store";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
|
||||
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
|
||||
{
|
||||
@@ -66,6 +69,75 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
orgStore.useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
it("should export a clientLoader for route protection", () => {
|
||||
// This test verifies the clientLoader is exported (for consistency with other routes)
|
||||
expect(clientLoader).toBeDefined();
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
|
||||
it("should allow members to access secrets settings (all roles have manage_secrets)", async () => {
|
||||
// Arrange
|
||||
seedActiveUser({ role: "member" });
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SecretsSettingsScreen,
|
||||
loader: clientLoader,
|
||||
path: "/settings/secrets",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Assert - should stay on secrets settings page (not redirected)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("secrets-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId("user-settings-screen")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the secrets settings screen", () => {
|
||||
renderSecretsSettings();
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/hooks/query/use-settings")
|
||||
>("#/hooks/query/use-settings");
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>(
|
||||
"#/hooks/query/use-settings"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
data: {
|
||||
EMAIL_VERIFIED: true, // Mock email as verified to prevent redirection
|
||||
},
|
||||
data: { EMAIL_VERIFIED: true },
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
@@ -52,21 +52,36 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
// Mock useConfig hook
|
||||
const { mockUseConfig } = vi.hoisted(() => ({
|
||||
const { mockUseConfig, mockUseMe, mockUsePermission } = vi.hoisted(() => ({
|
||||
mockUseConfig: vi.fn(),
|
||||
mockUseMe: vi.fn(),
|
||||
mockUsePermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: mockUseConfig,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-me", () => ({
|
||||
useMe: mockUseMe,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/organizations/use-permissions", () => ({
|
||||
usePermission: () => ({
|
||||
hasPermission: mockUsePermission,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
beforeEach(() => {
|
||||
// Set default config to OSS mode
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
// Set default config to OSS mode with lowercase keys
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "oss",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
hide_llm_settings: false,
|
||||
@@ -80,6 +95,13 @@ describe("Settings Billing", () => {
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(false); // default: no billing access
|
||||
});
|
||||
|
||||
const RoutesStub = createRoutesStub([
|
||||
@@ -104,14 +126,38 @@ describe("Settings Billing", () => {
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
|
||||
render(<RoutesStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("should not render the billing tab if OSS mode", async () => {
|
||||
// OSS mode is set by default in beforeEach
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "oss",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -119,12 +165,10 @@ describe("Settings Billing", () => {
|
||||
expect(credits).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
|
||||
it("should render the billing tab if: SaaS mode, billing enabled, admin user", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
@@ -139,19 +183,23 @@ describe("Settings Billing", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
within(navbar).getByText("Billing");
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the billing item", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("should NOT render the billing tab if: SaaS mode, billing is enabled, and member user", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
github_client_id: "123",
|
||||
posthog_client_key: "456",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
@@ -166,6 +214,43 @@ describe("Settings Billing", () => {
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "member" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(false);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the billing item", async () => {
|
||||
const user = userEvent.setup();
|
||||
// When enable_billing is true, the billing nav item is shown
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseMe.mockReturnValue({
|
||||
data: { role: "admin" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUsePermission.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, {
|
||||
clientLoader,
|
||||
getFirstAvailablePath,
|
||||
} from "#/routes/settings";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import { getFirstAvailablePath } from "#/utils/settings-utils";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { OrganizationMember } from "#/types/org";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { MOCK_PERSONAL_ORG, MOCK_TEAM_ORG_ACME } from "#/mocks/org-handlers";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Module-level mocks using vi.hoisted
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
@@ -57,17 +60,44 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const createMockUser = (
|
||||
overrides: Partial<OrganizationMember> = {},
|
||||
): OrganizationMember => ({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const seedActiveUser = (user: Partial<OrganizationMember>) => {
|
||||
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser(user),
|
||||
);
|
||||
};
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
loader: clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
Component: () => <div data-testid="llm-settings-screen" />,
|
||||
path: "/settings",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="user-settings-screen" />,
|
||||
path: "/settings/user",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/integrations",
|
||||
@@ -84,6 +114,15 @@ describe("Settings Screen", () => {
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
path: "/settings/api-keys",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="org-members-settings-screen" />,
|
||||
path: "/settings/org-members",
|
||||
handle: { hideTitle: true },
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="organization-settings-screen" />,
|
||||
path: "/settings/org",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -129,11 +168,21 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
const saasConfig = { app_mode: "saas" };
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Clear any existing query data and set the config
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
seedActiveUser({ role: "admin" });
|
||||
|
||||
const sectionsToInclude = [
|
||||
"llm", // LLM settings are now always shown in SaaS mode
|
||||
@@ -149,6 +198,9 @@ describe("Settings Screen", () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
await waitFor(() => {
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
sectionsToInclude.forEach((section) => {
|
||||
const sectionElement = within(navbar).getByText(section, {
|
||||
exact: false, // case insensitive
|
||||
@@ -200,12 +252,367 @@ describe("Settings Screen", () => {
|
||||
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
|
||||
describe("Personal org vs team org visibility", () => {
|
||||
it("should not show Organization and Organization Members settings items when personal org is selected", async () => {
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Organization and Organization Members should NOT be visible for personal org
|
||||
expect(
|
||||
within(navbar).queryByText("Organization Members"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(navbar).queryByText("Organization"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Billing settings item when team org is selected", async () => {
|
||||
// Set up SaaS mode (which has Billing in nav items)
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
// Pre-select the team org in the query client and Zustand store
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "2",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
|
||||
// Wait for orgs to load, then verify Billing is hidden for team orgs
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(navbar).queryByText("Billing", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/org when personal org is selected", async () => {
|
||||
// Set up orgs in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
// Use Zustand store instead of query client for selected org ID
|
||||
// This is the correct pattern - the query client key ["selected_organization"] is never set in production
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen("/settings/org");
|
||||
|
||||
// Should redirect away from org settings for personal org
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("organization-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/org-members when personal org is selected", async () => {
|
||||
// Set up config and organizations in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
// Use Zustand store for selected org ID
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly with the REAL route path (as defined in routes.ts)
|
||||
const request = new Request("http://localhost/settings/org-members");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from org-members settings for personal org
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings");
|
||||
});
|
||||
|
||||
it("should not allow direct URL access to /settings/billing when team org is selected", async () => {
|
||||
// Set up orgs in query client so clientLoader can access them
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
// Use Zustand store instead of query client for selected org ID
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
renderSettingsScreen("/settings/billing");
|
||||
|
||||
// Should redirect away from billing settings for team org
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable_billing feature flag", () => {
|
||||
it("should show billing navigation item when enable_billing is true", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: true, // When enable_billing is true, billing nav is shown
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryClient.clear();
|
||||
// Set up personal org (billing is only shown for personal orgs, not team orgs)
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
renderSettingsScreen();
|
||||
|
||||
// Assert
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
await waitFor(() => {
|
||||
expect(within(navbar).getByText("Billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should hide billing navigation item when enable_billing is false", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(
|
||||
createMockWebClientConfig({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false, // When enable_billing is false, billing nav is hidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryClient.clear();
|
||||
|
||||
// Act
|
||||
renderSettingsScreen();
|
||||
|
||||
// Assert
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).queryByText("Billing")).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientLoader reads org ID from Zustand store", () => {
|
||||
beforeEach(() => {
|
||||
mockQueryClient.clear();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should redirect away from /settings/org when personal org is selected in Zustand store", async () => {
|
||||
// Arrange: Set up config and organizations in query client
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
|
||||
// Set org ID ONLY in Zustand store (not in query client)
|
||||
// This tests that clientLoader reads from the correct source
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly
|
||||
const request = new Request("http://localhost/settings/org");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from org settings for personal org
|
||||
expect(result).not.toBeNull();
|
||||
// In React Router, redirect returns a Response object
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings");
|
||||
});
|
||||
|
||||
it("should redirect away from /settings/billing when team org is selected in Zustand store", async () => {
|
||||
// Arrange: Set up config and organizations in query client
|
||||
mockQueryClient.setQueryData(["web-client-config"], { app_mode: "saas" });
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_TEAM_ORG_ACME],
|
||||
currentOrgId: MOCK_TEAM_ORG_ACME.id,
|
||||
});
|
||||
|
||||
// Set org ID ONLY in Zustand store (not in query client)
|
||||
useSelectedOrganizationStore.setState({ organizationId: "2" });
|
||||
|
||||
// Mock getMe so getActiveOrganizationUser returns admin
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(
|
||||
createMockUser({ role: "admin", org_id: "2" }),
|
||||
);
|
||||
|
||||
// Act: Call clientLoader directly
|
||||
const request = new Request("http://localhost/settings/billing");
|
||||
// @ts-expect-error - test only needs request and params, not full loader args
|
||||
const result = await clientLoader({ request, params: {} });
|
||||
|
||||
// Assert: Should redirect away from billing settings for team org
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
const response = result as Response;
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("Location")).toBe("/settings/user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hide page feature flags", () => {
|
||||
beforeEach(() => {
|
||||
// Set up as personal org admin so billing is accessible
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue({
|
||||
org_id: "1",
|
||||
user_id: "99",
|
||||
email: "me@test.com",
|
||||
role: "admin",
|
||||
llm_api_key: "**********",
|
||||
max_iterations: 20,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "https://api.openai.com",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide users page in navbar when hide_users_page is true", async () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true, // Enable billing so it's not hidden by isBillingHidden
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -218,6 +625,14 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
// Set up personal org so billing is visible
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -238,7 +653,7 @@ describe("Settings Screen", () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -251,6 +666,11 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -271,7 +691,7 @@ describe("Settings Screen", () => {
|
||||
const saasConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
enable_billing: false,
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
@@ -284,6 +704,13 @@ describe("Settings Screen", () => {
|
||||
|
||||
mockQueryClient.clear();
|
||||
mockQueryClient.setQueryData(["web-client-config"], saasConfig);
|
||||
mockQueryClient.setQueryData(["organizations"], {
|
||||
items: [MOCK_PERSONAL_ORG],
|
||||
currentOrgId: MOCK_PERSONAL_ORG.id,
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
|
||||
describe("useSelectedOrganizationStore", () => {
|
||||
it("should have null as initial organizationId", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update organizationId when setOrganizationId is called", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId("org-123");
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBe("org-123");
|
||||
});
|
||||
|
||||
it("should allow setting organizationId to null", () => {
|
||||
const { result } = renderHook(() => useSelectedOrganizationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId("org-123");
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBe("org-123");
|
||||
|
||||
act(() => {
|
||||
result.current.setOrganizationId(null);
|
||||
});
|
||||
|
||||
expect(result.current.organizationId).toBeNull();
|
||||
});
|
||||
|
||||
it("should share state across multiple hook instances", () => {
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useSelectedOrganizationStore(),
|
||||
);
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useSelectedOrganizationStore(),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result1.current.setOrganizationId("shared-organization");
|
||||
});
|
||||
|
||||
expect(result2.current.organizationId).toBe("shared-organization");
|
||||
});
|
||||
});
|
||||
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
50
frontend/__tests__/utils/billing-visibility.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isBillingHidden } from "#/utils/org/billing-visibility";
|
||||
import { WebClientConfig } from "#/api/option-service/option.types";
|
||||
|
||||
describe("isBillingHidden", () => {
|
||||
const createConfig = (
|
||||
featureFlagOverrides: Partial<WebClientConfig["feature_flags"]> = {},
|
||||
): WebClientConfig =>
|
||||
({
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test",
|
||||
feature_flags: {
|
||||
enable_billing: true,
|
||||
hide_llm_settings: false,
|
||||
enable_jira: false,
|
||||
enable_jira_dc: false,
|
||||
enable_linear: false,
|
||||
...featureFlagOverrides,
|
||||
},
|
||||
}) as WebClientConfig;
|
||||
|
||||
it("should return true when config is undefined (safe default)", () => {
|
||||
expect(isBillingHidden(undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when enable_billing is false", () => {
|
||||
const config = createConfig({ enable_billing: false });
|
||||
expect(isBillingHidden(config, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when user lacks view_billing permission", () => {
|
||||
const config = createConfig();
|
||||
expect(isBillingHidden(config, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when both enable_billing is false and user lacks permission", () => {
|
||||
const config = createConfig({ enable_billing: false });
|
||||
expect(isBillingHidden(config, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when enable_billing is true and user has view_billing permission", () => {
|
||||
const config = createConfig();
|
||||
expect(isBillingHidden(config, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat enable_billing as true by default (billing visible, subject to permission)", () => {
|
||||
const config = createConfig({ enable_billing: true });
|
||||
expect(isBillingHidden(config, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,27 +2,29 @@ import { describe, it, expect } from "vitest";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
|
||||
describe("getGitPath", () => {
|
||||
it("should return /workspace/project when no repository is selected", () => {
|
||||
expect(getGitPath(null)).toBe("/workspace/project");
|
||||
expect(getGitPath(undefined)).toBe("/workspace/project");
|
||||
const conversationId = "abc123";
|
||||
|
||||
it("should return /workspace/project/{conversationId} when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`);
|
||||
expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`);
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands");
|
||||
expect(getGitPath("facebook/react")).toBe("/workspace/project/react");
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`);
|
||||
expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`);
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan");
|
||||
expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`);
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath("repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath("")).toBe("/workspace/project");
|
||||
expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`);
|
||||
});
|
||||
});
|
||||
|
||||
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
172
frontend/__tests__/utils/input-validation.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isValidEmail,
|
||||
getInvalidEmails,
|
||||
areAllEmailsValid,
|
||||
hasDuplicates,
|
||||
} from "#/utils/input-validation";
|
||||
|
||||
describe("isValidEmail", () => {
|
||||
describe("valid email formats", () => {
|
||||
test("accepts standard email formats", () => {
|
||||
expect(isValidEmail("user@example.com")).toBe(true);
|
||||
expect(isValidEmail("john.doe@company.org")).toBe(true);
|
||||
expect(isValidEmail("test@subdomain.domain.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with numbers", () => {
|
||||
expect(isValidEmail("user123@example.com")).toBe(true);
|
||||
expect(isValidEmail("123user@example.com")).toBe(true);
|
||||
expect(isValidEmail("user@example123.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with special characters in local part", () => {
|
||||
expect(isValidEmail("user.name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user+tag@example.com")).toBe(true);
|
||||
expect(isValidEmail("user_name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user-name@example.com")).toBe(true);
|
||||
expect(isValidEmail("user%tag@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts emails with various TLDs", () => {
|
||||
expect(isValidEmail("user@example.io")).toBe(true);
|
||||
expect(isValidEmail("user@example.co.uk")).toBe(true);
|
||||
expect(isValidEmail("user@example.travel")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid email formats", () => {
|
||||
test("rejects empty strings", () => {
|
||||
expect(isValidEmail("")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without @", () => {
|
||||
expect(isValidEmail("userexample.com")).toBe(false);
|
||||
expect(isValidEmail("user.example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without domain", () => {
|
||||
expect(isValidEmail("user@")).toBe(false);
|
||||
expect(isValidEmail("user@.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without local part", () => {
|
||||
expect(isValidEmail("@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings without TLD", () => {
|
||||
expect(isValidEmail("user@example")).toBe(false);
|
||||
expect(isValidEmail("user@example.")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects strings with single character TLD", () => {
|
||||
expect(isValidEmail("user@example.c")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects plain text", () => {
|
||||
expect(isValidEmail("test")).toBe(false);
|
||||
expect(isValidEmail("just some text")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects emails with spaces", () => {
|
||||
expect(isValidEmail("user @example.com")).toBe(false);
|
||||
expect(isValidEmail("user@ example.com")).toBe(false);
|
||||
expect(isValidEmail(" user@example.com")).toBe(false);
|
||||
expect(isValidEmail("user@example.com ")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects emails with multiple @ symbols", () => {
|
||||
expect(isValidEmail("user@@example.com")).toBe(false);
|
||||
expect(isValidEmail("user@domain@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInvalidEmails", () => {
|
||||
test("returns empty array when all emails are valid", () => {
|
||||
const emails = ["user@example.com", "test@domain.org"];
|
||||
expect(getInvalidEmails(emails)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all invalid emails", () => {
|
||||
const emails = ["valid@example.com", "invalid", "test@", "another@valid.org"];
|
||||
expect(getInvalidEmails(emails)).toEqual(["invalid", "test@"]);
|
||||
});
|
||||
|
||||
test("returns all emails when none are valid", () => {
|
||||
const emails = ["invalid", "also-invalid", "no-at-symbol"];
|
||||
expect(getInvalidEmails(emails)).toEqual(emails);
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
expect(getInvalidEmails([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles array with single invalid email", () => {
|
||||
expect(getInvalidEmails(["invalid"])).toEqual(["invalid"]);
|
||||
});
|
||||
|
||||
test("handles array with single valid email", () => {
|
||||
expect(getInvalidEmails(["valid@example.com"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("areAllEmailsValid", () => {
|
||||
test("returns true when all emails are valid", () => {
|
||||
const emails = ["user@example.com", "test@domain.org", "admin@company.io"];
|
||||
expect(areAllEmailsValid(emails)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when any email is invalid", () => {
|
||||
const emails = ["user@example.com", "invalid", "test@domain.org"];
|
||||
expect(areAllEmailsValid(emails)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when all emails are invalid", () => {
|
||||
const emails = ["invalid", "also-invalid"];
|
||||
expect(areAllEmailsValid(emails)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for empty array", () => {
|
||||
expect(areAllEmailsValid([])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for single valid email", () => {
|
||||
expect(areAllEmailsValid(["valid@example.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for single invalid email", () => {
|
||||
expect(areAllEmailsValid(["invalid"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDuplicates", () => {
|
||||
test("returns false when all values are unique", () => {
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "c@test.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns true when duplicates exist", () => {
|
||||
expect(hasDuplicates(["a@test.com", "b@test.com", "a@test.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for case-insensitive duplicates", () => {
|
||||
expect(hasDuplicates(["User@Test.com", "user@test.com"])).toBe(true);
|
||||
expect(hasDuplicates(["A@EXAMPLE.COM", "a@example.com"])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty array", () => {
|
||||
expect(hasDuplicates([])).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for single item array", () => {
|
||||
expect(hasDuplicates(["single@test.com"])).toBe(false);
|
||||
});
|
||||
|
||||
test("handles multiple duplicates", () => {
|
||||
expect(
|
||||
hasDuplicates(["a@test.com", "a@test.com", "b@test.com", "b@test.com"]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
79
frontend/__tests__/utils/permission-checks.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { PermissionKey } from "#/utils/org/permissions";
|
||||
|
||||
// Mock dependencies for getActiveOrganizationUser tests
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
getMe: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/stores/selected-organization-store", () => ({
|
||||
getSelectedOrganizationIdFromStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/query-client-getters", () => ({
|
||||
getMeFromQueryClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
getAvailableRolesAUserCanAssign,
|
||||
getActiveOrganizationUser,
|
||||
} from "#/utils/org/permission-checks";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
|
||||
import { getMeFromQueryClient } from "#/utils/query-client-getters";
|
||||
|
||||
describe("getAvailableRolesAUserCanAssign", () => {
|
||||
it("returns empty array if user has no permissions", () => {
|
||||
const result = getAvailableRolesAUserCanAssign([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only roles the user has permission for", () => {
|
||||
const userPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(userPermissions);
|
||||
expect(result.sort()).toEqual(["admin", "member"].sort());
|
||||
});
|
||||
|
||||
it("returns all roles if user has all permissions", () => {
|
||||
const allPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
"change_user_role:owner",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(allPermissions);
|
||||
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveOrganizationUser", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return undefined when API call throws an error", async () => {
|
||||
// Arrange: orgId exists, cache is empty, API call fails
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
|
||||
vi.mocked(getMeFromQueryClient).mockReturnValue(undefined);
|
||||
vi.mocked(organizationService.getMe).mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert: should return undefined instead of propagating the error
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
175
frontend/__tests__/utils/permission-guard.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock("react-router", () => ({
|
||||
redirect: vi.fn((path: string) => ({ type: "redirect", path })),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/org/permission-checks", () => ({
|
||||
getActiveOrganizationUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/api/option-service/option-service.api", () => ({
|
||||
default: {
|
||||
getConfig: vi.fn().mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
hide_llm_settings: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockConfig = {
|
||||
app_mode: "saas",
|
||||
feature_flags: {
|
||||
hide_users_page: false,
|
||||
hide_billing_page: false,
|
||||
hide_integrations_page: false,
|
||||
hide_llm_settings: false,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
getQueryData: vi.fn(() => mockConfig),
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
import { getActiveOrganizationUser } from "#/utils/org/permission-checks";
|
||||
|
||||
// Helper to create a mock request
|
||||
const createMockRequest = (pathname: string = "/settings/billing") => ({
|
||||
request: new Request(`http://localhost${pathname}`),
|
||||
});
|
||||
|
||||
describe("createPermissionGuard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("permission checking", () => {
|
||||
it("should redirect when user lacks required permission", async () => {
|
||||
// Arrange: member lacks view_billing permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to first available path (/settings/user in SaaS mode)
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
|
||||
it("should allow access when user has required permission", async () => {
|
||||
// Arrange: admin has view_billing permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
const result = await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should not redirect, return null
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined (no org selected)", async () => {
|
||||
// Arrange: no user (e.g., no organization selected)
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to first available path
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
|
||||
it("should redirect when user is undefined even for member-level permissions", async () => {
|
||||
// Arrange: no user — manage_secrets is a member-level permission,
|
||||
// but undefined user should NOT get member access
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("manage_secrets");
|
||||
await guard(createMockRequest("/settings/secrets"));
|
||||
|
||||
// Assert: should redirect, not silently grant member-level access
|
||||
expect(redirect).toHaveBeenCalledWith("/settings/user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom redirect path", () => {
|
||||
it("should redirect to custom path when specified", async () => {
|
||||
// Arrange: member lacks permission
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue({
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "member",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Act
|
||||
const guard = createPermissionGuard("view_billing", "/custom/redirect");
|
||||
await guard(createMockRequest("/settings/billing"));
|
||||
|
||||
// Assert: should redirect to custom path
|
||||
expect(redirect).toHaveBeenCalledWith("/custom/redirect");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infinite loop prevention", () => {
|
||||
it("should return null instead of redirecting when fallback path equals current path", async () => {
|
||||
// Arrange: no user
|
||||
vi.mocked(getActiveOrganizationUser).mockResolvedValue(undefined);
|
||||
|
||||
// Act: access /settings/user when fallback would also be /settings/user
|
||||
const guard = createPermissionGuard("view_billing");
|
||||
const result = await guard(createMockRequest("/settings/user"));
|
||||
|
||||
// Assert: should NOT redirect to avoid infinite loop
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev:mock -- --port 3001",
|
||||
command: "npm run dev:mock:saas -- --port 3001",
|
||||
url: "http://localhost:3001/",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
@@ -12,7 +12,9 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
V1AppConversationPage,
|
||||
GetSkillsResponse,
|
||||
GetHooksResponse,
|
||||
V1RuntimeConversationInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
@@ -253,7 +255,7 @@ class V1ConversationService {
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
* V1 API endpoint: POST /api/file/upload?path={path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
@@ -269,10 +271,11 @@ class V1ConversationService {
|
||||
): Promise<void> {
|
||||
// Default to /workspace/{filename} if no path provided (must be absolute)
|
||||
const uploadPath = path || `/workspace/${file.name}`;
|
||||
const encodedPath = encodeURIComponent(uploadPath);
|
||||
const params = new URLSearchParams();
|
||||
params.append("path", uploadPath);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
`/api/file/upload?${params.toString()}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
@@ -398,6 +401,18 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks associated with a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns The available hooks associated with the conversation
|
||||
*/
|
||||
static async getHooks(conversationId: string): Promise<GetHooksResponse> {
|
||||
const { data } = await openHands.get<GetHooksResponse>(
|
||||
`/api/v1/app-conversations/${conversationId}/hooks`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation info directly from the runtime for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
@@ -423,6 +438,28 @@ class V1ConversationService {
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for V1 conversations by sandbox ID
|
||||
*
|
||||
* @param sandboxId The sandbox ID to filter by
|
||||
* @param limit Maximum number of results (default: 100)
|
||||
* @returns Array of conversations in the specified sandbox
|
||||
*/
|
||||
static async searchConversationsBySandboxId(
|
||||
sandboxId: string,
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("sandbox_id__eq", sandboxId);
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationPage>(
|
||||
`/api/v1/app-conversations/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -119,6 +119,11 @@ export interface V1AppConversation {
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface V1AppConversationPage {
|
||||
items: V1AppConversation[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
type: "repo" | "knowledge" | "agentskills";
|
||||
@@ -130,6 +135,27 @@ export interface GetSkillsResponse {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
export interface HookDefinition {
|
||||
type: string; // 'command' or 'prompt'
|
||||
command: string;
|
||||
timeout: number;
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string; // Pattern: '*', exact match, or regex
|
||||
hooks: HookDefinition[];
|
||||
}
|
||||
|
||||
export interface HookEvent {
|
||||
event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use'
|
||||
matchers: HookMatcher[];
|
||||
}
|
||||
|
||||
export interface GetHooksResponse {
|
||||
hooks: HookEvent[];
|
||||
}
|
||||
|
||||
// Runtime conversation types (from agent server)
|
||||
export interface V1RuntimeConversationStats {
|
||||
usage_to_metrics: Record<string, V1RuntimeMetrics>;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
OrganizationMembersPage,
|
||||
UpdateOrganizationMemberParams,
|
||||
} from "#/types/org";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
export const organizationService = {
|
||||
getMe: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<OrganizationMember>(
|
||||
`/api/organizations/${orgId}/me`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<Organization>(
|
||||
`/api/organizations/${orgId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizations: async () => {
|
||||
const { data } = await openHands.get<{
|
||||
items: Organization[];
|
||||
current_org_id: string | null;
|
||||
}>("/api/organizations");
|
||||
return {
|
||||
items: data?.items || [],
|
||||
currentOrgId: data?.current_org_id || null,
|
||||
};
|
||||
},
|
||||
|
||||
updateOrganization: async ({
|
||||
orgId,
|
||||
name,
|
||||
}: {
|
||||
orgId: string;
|
||||
name: string;
|
||||
}) => {
|
||||
const { data } = await openHands.patch<Organization>(
|
||||
`/api/organizations/${orgId}`,
|
||||
{ name },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
await openHands.delete(`/api/organizations/${orgId}`);
|
||||
},
|
||||
|
||||
getOrganizationMembers: async ({
|
||||
orgId,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
email,
|
||||
}: {
|
||||
orgId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
email?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Calculate offset from page number (page_id is offset-based)
|
||||
const offset = (page - 1) * limit;
|
||||
params.set("page_id", String(offset));
|
||||
params.set("limit", String(limit));
|
||||
|
||||
if (email) {
|
||||
params.set("email", email);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<OrganizationMembersPage>(
|
||||
`/api/organizations/${orgId}/members?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizationMembersCount: async ({
|
||||
orgId,
|
||||
email,
|
||||
}: {
|
||||
orgId: string;
|
||||
email?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (email) {
|
||||
params.set("email", email);
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/organizations/${orgId}/members/count?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
getOrganizationPaymentInfo: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.get<{
|
||||
cardNumber: string;
|
||||
}>(`/api/organizations/${orgId}/payment`);
|
||||
return data;
|
||||
},
|
||||
|
||||
updateMember: async ({
|
||||
orgId,
|
||||
userId,
|
||||
...updateData
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
} & UpdateOrganizationMemberParams) => {
|
||||
const { data } = await openHands.patch(
|
||||
`/api/organizations/${orgId}/members/${userId}`,
|
||||
updateData,
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
removeMember: async ({
|
||||
orgId,
|
||||
userId,
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await openHands.delete(`/api/organizations/${orgId}/members/${userId}`);
|
||||
},
|
||||
|
||||
inviteMembers: async ({
|
||||
orgId,
|
||||
emails,
|
||||
}: {
|
||||
orgId: string;
|
||||
emails: string[];
|
||||
}) => {
|
||||
const { data } = await openHands.post<OrganizationMember[]>(
|
||||
`/api/organizations/${orgId}/members/invite`,
|
||||
{
|
||||
emails,
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
switchOrganization: async ({ orgId }: { orgId: string }) => {
|
||||
const { data } = await openHands.post<Organization>(
|
||||
`/api/organizations/${orgId}/switch`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Pending Message Service
|
||||
*
|
||||
* This service handles server-side message queuing for V1 conversations.
|
||||
* Messages can be queued when the WebSocket is not connected and will be
|
||||
* delivered automatically when the conversation becomes ready.
|
||||
*/
|
||||
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import type {
|
||||
PendingMessageResponse,
|
||||
QueuePendingMessageRequest,
|
||||
} from "./pending-message-service.types";
|
||||
|
||||
class PendingMessageService {
|
||||
/**
|
||||
* Queue a message for delivery when conversation becomes ready.
|
||||
*
|
||||
* This endpoint allows users to submit messages even when the conversation's
|
||||
* WebSocket connection is not yet established. Messages are stored server-side
|
||||
* and delivered automatically when the conversation transitions to READY status.
|
||||
*
|
||||
* @param conversationId The conversation ID (can be task ID before conversation is ready)
|
||||
* @param message The message to queue
|
||||
* @returns PendingMessageResponse with the message ID and queue position
|
||||
* @throws Error if too many pending messages (limit: 10 per conversation)
|
||||
*/
|
||||
static async queueMessage(
|
||||
conversationId: string,
|
||||
message: QueuePendingMessageRequest,
|
||||
): Promise<PendingMessageResponse> {
|
||||
const { data } = await openHands.post<PendingMessageResponse>(
|
||||
`/api/v1/conversations/${conversationId}/pending-messages`,
|
||||
message,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default PendingMessageService;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Types for the pending message service
|
||||
*/
|
||||
|
||||
import type { V1MessageContent } from "../conversation-service/v1-conversation-service.types";
|
||||
|
||||
/**
|
||||
* Response when queueing a pending message
|
||||
*/
|
||||
export interface PendingMessageResponse {
|
||||
id: string;
|
||||
queued: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to queue a pending message
|
||||
*/
|
||||
export interface QueuePendingMessageRequest {
|
||||
role?: "user";
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaUserShield } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
@@ -65,6 +66,12 @@ export function LoginContent({
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const enterpriseSsoAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "enterprise_sso",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleAuthRedirect = async (
|
||||
redirectUrl: string,
|
||||
provider: Provider,
|
||||
@@ -127,6 +134,12 @@ export function LoginContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoAuthUrl) {
|
||||
handleAuthRedirect(enterpriseSsoAuthUrl, "enterprise_sso");
|
||||
}
|
||||
};
|
||||
|
||||
const showGithub =
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
@@ -143,6 +156,10 @@ export function LoginContent({
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("bitbucket_data_center");
|
||||
const showEnterpriseSso =
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("enterprise_sso");
|
||||
|
||||
const noProvidersConfigured =
|
||||
!providersConfigured || providersConfigured.length === 0;
|
||||
@@ -261,6 +278,19 @@ export function LoginContent({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showEnterpriseSso && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterpriseSsoAuth}
|
||||
className={`${buttonBaseClasses} bg-[#374151] text-white`}
|
||||
>
|
||||
<FaUserShield size={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -190,8 +190,14 @@ export function ChatInterface() {
|
||||
const prompt =
|
||||
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
|
||||
|
||||
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
|
||||
setOptimisticUserMessage(content);
|
||||
const result = await send(
|
||||
createChatMessage(prompt, imageUrls, uploadedFiles, timestamp),
|
||||
);
|
||||
// Only show optimistic UI if message was sent immediately via WebSocket
|
||||
// If queued for later delivery, the message will appear when actually delivered
|
||||
if (!result.queued) {
|
||||
setOptimisticUserMessage(content);
|
||||
}
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
saveDraft,
|
||||
} = useChatInputLogic();
|
||||
|
||||
const {
|
||||
@@ -158,6 +159,7 @@ export function CustomChatInput({
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
updateSlashMenu();
|
||||
saveDraft();
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user