Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/backend-tests-postgres.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Backend Tests (Postgres)

on:
pull_request:
branches:
- main
- master
push:
branches:
- main
- master

jobs:
postgres-test:
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: efficientai_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d efficientai_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10

env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/efficientai_test
TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/efficientai_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: efficientai_test
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
REDIS_URL: redis://localhost:6379/0
CELERY_BROKER_URL: redis://localhost:6379/0
CELERY_RESULT_BACKEND: redis://localhost:6379/0

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

- name: Run backend test suite on Postgres
run: make test
48 changes: 34 additions & 14 deletions .github/workflows/release-and-publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
name: Release and Publish Docker Images

on:
pull_request_target:
types: [closed]
branches:
- main
- master
workflow_run:
workflows: ["Backend Tests (Postgres)"]
types: [completed]

env:
REGISTRY: ghcr.io
Expand All @@ -18,28 +16,49 @@ permissions:
pull-requests: read

concurrency:
group: release-${{ github.event.pull_request.base.ref }}
group: release-${{ github.event.workflow_run.head_branch || github.ref_name }}
cancel-in-progress: false

jobs:
# ── Step 1: Compute version and create git tag ──────────────
release:
if: github.event.pull_request.merged == true
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
major_minor: ${{ steps.version.outputs.major_minor }}
next_tag: ${{ steps.version.outputs.next_tag }}

steps:
- name: Determine bump type from PR labels
- name: Determine bump type from associated PR labels
id: bump
uses: actions/github-script@v7
env:
MERGE_SHA: ${{ github.event.workflow_run.head_sha }}
with:
script: |
const labels = (context.payload.pull_request.labels || []).map((label) =>
label.name.toLowerCase()
);
const { owner, repo } = context.repo;
const mergeSha = process.env.MERGE_SHA;
let labels = [];
try {
const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: mergeSha,
});
const mergedPR = (response.data || []).find((pr) => pr.merged_at);
if (mergedPR) {
labels = (mergedPR.labels || []).map((label) => label.name.toLowerCase());
core.info(`Using labels from PR #${mergedPR.number}`);
} else {
core.info("No merged PR associated with this commit. Defaulting to patch.");
}
} catch (err) {
core.warning(`Unable to resolve PR labels for commit ${mergeSha}: ${err.message}`);
}
core.info(`PR labels: ${labels.join(", ") || "none"}`);

let bump = "patch";
Expand All @@ -58,12 +77,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: Compute next semantic version
id: version
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGE_SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
set -euo pipefail
Expand Down Expand Up @@ -130,7 +150,7 @@ jobs:
if: steps.version.outputs.create_tag == 'true'
env:
NEXT_TAG: ${{ steps.version.outputs.next_tag }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGE_SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
set -euo pipefail
Expand Down Expand Up @@ -287,7 +307,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT_TAG: ${{ needs.release.outputs.next_tag }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGE_SHA: ${{ github.event.workflow_run.head_sha }}
shell: bash
run: |
set -euo pipefail
Expand Down
59 changes: 59 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
.PHONY: help install-dev check-pytest test test-docker-db test-unit test-integration test-phase1 test-file test-k

PYTHON ?= python
PYTEST ?= $(PYTHON) -m pytest
PYTEST_FLAGS ?= -q
TEST_DB_HOST ?= localhost
TEST_DB_PORT ?= 5432
TEST_DB_NAME ?= efficientai
TEST_DB_USER ?= efficientai
TEST_DB_PASSWORD ?= password
TEST_DATABASE_URL ?= postgresql://$(TEST_DB_USER):$(TEST_DB_PASSWORD)@$(TEST_DB_HOST):$(TEST_DB_PORT)/$(TEST_DB_NAME)

help: ## Show available make targets
@echo "Available targets:"
@echo " make install-dev - install project + dev dependencies"
@echo " make test - run all tests under tests/"
@echo " make test-docker-db - run tests against running Docker Compose Postgres"
@echo " make test-unit - run unit tests (marker: unit)"
@echo " make test-integration - run integration tests (marker: integration)"
@echo " make test-phase1 - run current Phase 1 suites"
@echo " make test-file FILE=...- run a specific test file/path"
@echo " make test-k K=... - run tests matching expression"

install-dev: ## Install project and dev dependencies
$(PYTHON) -m pip install -e ".[dev]"

check-pytest:
@$(PYTHON) -c "import pytest" >/dev/null 2>&1 || ( \
echo "pytest is not installed in the current environment."; \
echo "Run: make install-dev"; \
echo "or: $(PYTHON) -m pip install pytest pytest-asyncio pytest-cov pytest-mock"; \
exit 1; \
)

test: check-pytest ## Run the full test suite
$(PYTEST) tests $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-docker-db: check-pytest ## Run tests against running Docker Compose Postgres
TEST_DATABASE_URL="$(TEST_DATABASE_URL)" DATABASE_URL="$(TEST_DATABASE_URL)" \
POSTGRES_HOST="$(TEST_DB_HOST)" POSTGRES_PORT="$(TEST_DB_PORT)" POSTGRES_DB="$(TEST_DB_NAME)" \
POSTGRES_USER="$(TEST_DB_USER)" POSTGRES_PASSWORD="$(TEST_DB_PASSWORD)" \
$(PYTEST) tests $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-unit: check-pytest ## Run tests marked as unit
$(PYTEST) -m "unit" tests $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-integration: check-pytest ## Run tests marked as integration
$(PYTEST) -m "integration" tests $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-phase1: check-pytest ## Run Phase 1 test suites
$(PYTEST) tests/test_core tests/test_models tests/test_utils tests/test_services/test_helpers $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-file: check-pytest ## Run one test module/file; usage: make test-file FILE=tests/test_core/test_password.py
@if [ -z "$(FILE)" ]; then echo "FILE is required. Example: make test-file FILE=tests/test_core/test_password.py"; exit 1; fi
$(PYTEST) $(FILE) $(PYTEST_FLAGS) $(PYTEST_ARGS)

test-k: check-pytest ## Run tests by keyword expression; usage: make test-k K=password
@if [ -z "$(K)" ]; then echo "K is required. Example: make test-k K=password"; exit 1; fi
$(PYTEST) tests -k "$(K)" $(PYTEST_FLAGS) $(PYTEST_ARGS)
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,43 @@ docker compose up -d
- PostgreSQL running (locally or remote)
- Redis running (locally or remote)

### Test Commands (Make)

If you prefer shorthand commands, use the root `Makefile`:

```bash
# Run all backend tests
make test

# Run tests against a running Docker Compose Postgres
make test-docker-db

# Run current Phase 1 suites
make test-phase1

# Run only unit or integration tests
make test-unit
make test-integration

# Run a specific file
make test-file FILE=tests/test_core/test_password.py

# Run tests by keyword
make test-k K=password
```

You can also pass extra pytest args:

```bash
make test PYTEST_ARGS="-x -vv"
```

To override DB connection values for `make test-docker-db`:

```bash
make test-docker-db TEST_DB_HOST=localhost TEST_DB_PORT=5432 TEST_DB_NAME=efficientai TEST_DB_USER=efficientai TEST_DB_PASSWORD=password
```

---

## 💻 CLI Commands
Expand Down
6 changes: 3 additions & 3 deletions app/api/v1/routes/aiproviders.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async def update_aiprovider(
status_code=404, detail=f"AI Provider {aiprovider_id} not found"
)

update_data = aiprovider_update.dict(exclude_unset=True)
update_data = aiprovider_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field == 'api_key' and value:
encrypted_api_key = encrypt_api_key(value)
Expand Down Expand Up @@ -189,8 +189,8 @@ async def test_aiprovider(

# TODO: Implement actual API key testing based on provider type
# For now, just update the last_tested_at timestamp
from datetime import datetime
aiprovider.last_tested_at = datetime.utcnow()
from datetime import datetime, timezone
aiprovider.last_tested_at = datetime.now(timezone.utc)
db.commit()

return {"status": "success", "message": "API key test completed"}
Expand Down
8 changes: 4 additions & 4 deletions app/api/v1/routes/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from sqlalchemy import and_
from uuid import UUID
from typing import List, Optional
from datetime import datetime
from datetime import datetime, timezone

from app.database import get_db
from app.dependencies import get_organization_id
Expand Down Expand Up @@ -354,7 +354,7 @@ def test_alert_notification(
raise HTTPException(status_code=404, detail="Alert not found")

results = []
now = datetime.utcnow()
now = datetime.now(timezone.utc)

common_params = dict(
alert_name=f"[TEST] {alert.name}",
Expand Down Expand Up @@ -531,12 +531,12 @@ def update_alert_history(

# Set timestamps based on status transition
if new_status == AlertHistoryStatus.ACKNOWLEDGED.value and history.acknowledged_at is None:
history.acknowledged_at = datetime.utcnow()
history.acknowledged_at = datetime.now(timezone.utc)
if update_data.acknowledged_by:
history.acknowledged_by = update_data.acknowledged_by

if new_status == AlertHistoryStatus.RESOLVED.value and history.resolved_at is None:
history.resolved_at = datetime.utcnow()
history.resolved_at = datetime.now(timezone.utc)
if update_data.resolved_by:
history.resolved_by = update_data.resolved_by
if update_data.resolution_notes:
Expand Down
36 changes: 35 additions & 1 deletion app/api/v1/routes/evaluators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from app.database import get_db
from app.dependencies import get_organization_id, get_api_key
from app.models.database import Evaluator, Agent, Persona, Scenario, EvaluatorResult, EvaluatorResultStatus
from app.models.database import Evaluator, Agent, Persona, Scenario, EvaluatorResult, EvaluatorResultStatus, VoiceBundle
from app.models.schemas import (
EvaluatorCreate,
EvaluatorUpdate,
Expand Down Expand Up @@ -142,6 +142,21 @@ def create_evaluator(
if not scenario:
raise HTTPException(status_code=404, detail="Scenario not found")

if agent.voice_bundle_id and persona.tts_provider:
voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == agent.voice_bundle_id).first()
if voice_bundle and voice_bundle.tts_provider:
vb_provider = (voice_bundle.tts_provider.value if hasattr(voice_bundle.tts_provider, "value") else str(voice_bundle.tts_provider)).lower()
persona_provider = persona.tts_provider.lower()
if vb_provider != persona_provider:
raise HTTPException(
status_code=400,
detail=(
f"Persona '{persona.name}' uses TTS provider '{persona.tts_provider}' "
f"but agent '{agent.name}' voice bundle uses '{voice_bundle.tts_provider}'. "
f"The persona's TTS provider must match the agent's voice bundle TTS provider."
)
)

evaluator_id = generate_unique_evaluator_id(db)

evaluator = Evaluator(
Expand Down Expand Up @@ -191,6 +206,25 @@ def create_evaluators_bulk(
if len(personas) != len(bulk_data.persona_ids):
raise HTTPException(status_code=404, detail="One or more personas not found")

# Validate TTS provider compatibility between personas and voice bundle
if agent.voice_bundle_id:
voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == agent.voice_bundle_id).first()
if voice_bundle and voice_bundle.tts_provider:
vb_provider = (voice_bundle.tts_provider.value if hasattr(voice_bundle.tts_provider, "value") else str(voice_bundle.tts_provider)).lower()
mismatched = [
p.name for p in personas
if p.tts_provider and p.tts_provider.lower() != vb_provider
]
if mismatched:
raise HTTPException(
status_code=400,
detail=(
f"The following personas use a different TTS provider than the agent's voice bundle "
f"('{voice_bundle.tts_provider}'): {', '.join(mismatched)}. "
f"All personas must use a TTS provider that matches the agent's voice bundle."
)
)

# Create evaluators for each persona
evaluators = []
for persona_id in bulk_data.persona_ids:
Expand Down
Loading
Loading