fc-server: add (more) advanced search API
Basically, implements a multi-entity search functionality with filters for projects, jobsets, evaluations and builds. Also fixes COUNT query to apply same filters as main query, and fixes an offset mismatch in response. Some integration tests have also been added, but chances are we'll want to write VM tests for this. Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Icdda77966a7218f54fd34b78bdc9b55c6a6a6964
This commit is contained in:
parent
b3125a89d8
commit
dec4753567
4 changed files with 1369 additions and 33 deletions
|
|
@ -11,6 +11,7 @@ pub mod notification_configs;
|
|||
pub mod project_members;
|
||||
pub mod projects;
|
||||
pub mod remote_builders;
|
||||
pub mod search;
|
||||
pub mod starred_jobs;
|
||||
pub mod users;
|
||||
pub mod webhook_configs;
|
||||
|
|
|
|||
516
crates/common/src/repo/search.rs
Normal file
516
crates/common/src/repo/search.rs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
//! Advanced search functionality for FC
|
||||
|
||||
use sqlx::{PgPool, Postgres, QueryBuilder};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
error::Result,
|
||||
models::{Build, Evaluation, Jobset, Project},
|
||||
};
|
||||
|
||||
/// Search entity types
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SearchEntity {
|
||||
Projects,
|
||||
Jobsets,
|
||||
Evaluations,
|
||||
Builds,
|
||||
}
|
||||
|
||||
/// Sort order for search results
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SortOrder {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Sort field for builds
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BuildSortField {
|
||||
CreatedAt,
|
||||
JobName,
|
||||
Status,
|
||||
Priority,
|
||||
}
|
||||
|
||||
/// Sort field for projects
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ProjectSortField {
|
||||
Name,
|
||||
CreatedAt,
|
||||
LastEvaluation,
|
||||
}
|
||||
|
||||
/// Build status filter
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BuildStatusFilter {
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Search filters for builds
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BuildSearchFilters {
|
||||
pub status: Option<BuildStatusFilter>,
|
||||
pub project_id: Option<Uuid>,
|
||||
pub jobset_id: Option<Uuid>,
|
||||
pub evaluation_id: Option<Uuid>,
|
||||
pub created_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub min_priority: Option<i32>,
|
||||
pub max_priority: Option<i32>,
|
||||
pub has_substitutes: Option<bool>,
|
||||
}
|
||||
|
||||
/// Search filters for projects
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProjectSearchFilters {
|
||||
pub created_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub has_jobsets: Option<bool>,
|
||||
}
|
||||
|
||||
/// Search filters for jobsets
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JobsetSearchFilters {
|
||||
pub project_id: Option<Uuid>,
|
||||
pub enabled: Option<bool>,
|
||||
pub flake_mode: Option<bool>,
|
||||
}
|
||||
|
||||
/// Search filters for evaluations
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EvaluationSearchFilters {
|
||||
pub project_id: Option<Uuid>,
|
||||
pub jobset_id: Option<Uuid>,
|
||||
pub has_builds: Option<bool>,
|
||||
pub finished_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub finished_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// Search parameters
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchParams {
|
||||
pub query: String,
|
||||
pub entities: Vec<SearchEntity>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
pub build_filters: Option<BuildSearchFilters>,
|
||||
pub project_filters: Option<ProjectSearchFilters>,
|
||||
pub jobset_filters: Option<JobsetSearchFilters>,
|
||||
pub evaluation_filters: Option<EvaluationSearchFilters>,
|
||||
pub build_sort: Option<(BuildSortField, SortOrder)>,
|
||||
pub project_sort: Option<(ProjectSortField, SortOrder)>,
|
||||
}
|
||||
|
||||
impl Default for SearchParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: String::new(),
|
||||
entities: vec![SearchEntity::Projects, SearchEntity::Builds],
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search results container
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResults {
|
||||
pub projects: Vec<Project>,
|
||||
pub jobsets: Vec<Jobset>,
|
||||
pub evaluations: Vec<Evaluation>,
|
||||
pub builds: Vec<Build>,
|
||||
pub total_projects: i64,
|
||||
pub total_jobsets: i64,
|
||||
pub total_evaluations: i64,
|
||||
pub total_builds: i64,
|
||||
}
|
||||
|
||||
/// Execute a comprehensive search across all entities
|
||||
pub async fn search(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
) -> Result<SearchResults> {
|
||||
let mut results = SearchResults {
|
||||
projects: vec![],
|
||||
jobsets: vec![],
|
||||
evaluations: vec![],
|
||||
builds: vec![],
|
||||
total_projects: 0,
|
||||
total_jobsets: 0,
|
||||
total_evaluations: 0,
|
||||
total_builds: 0,
|
||||
};
|
||||
|
||||
for entity in ¶ms.entities {
|
||||
match entity {
|
||||
SearchEntity::Projects => {
|
||||
let (projects, total) = search_projects(pool, params).await?;
|
||||
results.projects = projects;
|
||||
results.total_projects = total;
|
||||
},
|
||||
SearchEntity::Jobsets => {
|
||||
let (jobsets, total) = search_jobsets(pool, params).await?;
|
||||
results.jobsets = jobsets;
|
||||
results.total_jobsets = total;
|
||||
},
|
||||
SearchEntity::Evaluations => {
|
||||
let (evaluations, total) = search_evaluations(pool, params).await?;
|
||||
results.evaluations = evaluations;
|
||||
results.total_evaluations = total;
|
||||
},
|
||||
SearchEntity::Builds => {
|
||||
let (builds, total) = search_builds(pool, params).await?;
|
||||
results.builds = builds;
|
||||
results.total_builds = total;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Search projects with filters
|
||||
async fn search_projects(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
) -> Result<(Vec<Project>, i64)> {
|
||||
let pattern = if params.query.is_empty() {
|
||||
"%".to_string()
|
||||
} else {
|
||||
format!("%{}%", params.query)
|
||||
};
|
||||
|
||||
let mut query_builder: QueryBuilder<Postgres> =
|
||||
QueryBuilder::new("SELECT * FROM projects WHERE (name ILIKE ");
|
||||
query_builder.push_bind(&pattern);
|
||||
query_builder.push(" OR description ILIKE ");
|
||||
query_builder.push_bind(&pattern);
|
||||
query_builder.push(")");
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref filters) = params.project_filters {
|
||||
if let Some(after) = filters.created_after {
|
||||
query_builder.push(" AND created_at >= ");
|
||||
query_builder.push_bind(after);
|
||||
}
|
||||
if let Some(before) = filters.created_before {
|
||||
query_builder.push(" AND created_at <= ");
|
||||
query_builder.push_bind(before);
|
||||
}
|
||||
if let Some(has_jobsets) = filters.has_jobsets {
|
||||
if has_jobsets {
|
||||
query_builder.push(
|
||||
" AND EXISTS (SELECT 1 FROM jobsets WHERE jobsets.project_id = \
|
||||
projects.id)",
|
||||
);
|
||||
} else {
|
||||
query_builder.push(
|
||||
" AND NOT EXISTS (SELECT 1 FROM jobsets WHERE jobsets.project_id = \
|
||||
projects.id)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
let (total,): (i64,) = if pattern == "%" {
|
||||
sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM projects WHERE name ILIKE $1 OR description ILIKE \
|
||||
$1",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Apply sorting
|
||||
query_builder.push(" ORDER BY ");
|
||||
if let Some((field, order)) = ¶ms.project_sort {
|
||||
let field_str = match field {
|
||||
ProjectSortField::Name => "name",
|
||||
ProjectSortField::CreatedAt => "created_at",
|
||||
ProjectSortField::LastEvaluation => "last_evaluation_at",
|
||||
};
|
||||
let order_str = match order {
|
||||
SortOrder::Asc => "ASC",
|
||||
SortOrder::Desc => "DESC",
|
||||
};
|
||||
query_builder.push(field_str);
|
||||
query_builder.push(" ");
|
||||
query_builder.push(order_str);
|
||||
} else {
|
||||
query_builder.push("name ASC");
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
query_builder.push(" LIMIT ");
|
||||
query_builder.push_bind(params.limit);
|
||||
query_builder.push(" OFFSET ");
|
||||
query_builder.push_bind(params.offset);
|
||||
|
||||
let projects = query_builder
|
||||
.build_query_as::<Project>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((projects, total))
|
||||
}
|
||||
|
||||
/// Search jobsets with filters
|
||||
async fn search_jobsets(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
) -> Result<(Vec<Jobset>, i64)> {
|
||||
let pattern = if params.query.is_empty() {
|
||||
"%".to_string()
|
||||
} else {
|
||||
format!("%{}%", params.query)
|
||||
};
|
||||
|
||||
let mut query_builder: QueryBuilder<Postgres> =
|
||||
QueryBuilder::new("SELECT * FROM jobsets WHERE name ILIKE ");
|
||||
query_builder.push_bind(&pattern);
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref filters) = params.jobset_filters {
|
||||
if let Some(project_id) = filters.project_id {
|
||||
query_builder.push(" AND project_id = ");
|
||||
query_builder.push_bind(project_id);
|
||||
}
|
||||
if let Some(enabled) = filters.enabled {
|
||||
query_builder.push(" AND enabled = ");
|
||||
query_builder.push_bind(enabled);
|
||||
}
|
||||
if let Some(flake_mode) = filters.flake_mode {
|
||||
query_builder.push(" AND flake_mode = ");
|
||||
query_builder.push_bind(flake_mode);
|
||||
}
|
||||
}
|
||||
|
||||
// Get count
|
||||
let (total,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE name ILIKE $1")
|
||||
.bind(&pattern)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
// Apply sorting
|
||||
query_builder.push(" ORDER BY name ASC LIMIT ");
|
||||
query_builder.push_bind(params.limit);
|
||||
query_builder.push(" OFFSET ");
|
||||
query_builder.push_bind(params.offset);
|
||||
|
||||
let jobsets = query_builder
|
||||
.build_query_as::<Jobset>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((jobsets, total))
|
||||
}
|
||||
|
||||
/// Search evaluations with filters
|
||||
async fn search_evaluations(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
) -> Result<(Vec<Evaluation>, i64)> {
|
||||
let mut query_builder: QueryBuilder<Postgres> =
|
||||
QueryBuilder::new("SELECT * FROM evaluations WHERE 1=1");
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref filters) = params.evaluation_filters {
|
||||
if let Some(project_id) = filters.project_id {
|
||||
query_builder.push(" AND project_id = ");
|
||||
query_builder.push_bind(project_id);
|
||||
}
|
||||
if let Some(jobset_id) = filters.jobset_id {
|
||||
query_builder.push(" AND jobset_id = ");
|
||||
query_builder.push_bind(jobset_id);
|
||||
}
|
||||
if let Some(has_builds) = filters.has_builds {
|
||||
if has_builds {
|
||||
query_builder.push(
|
||||
" AND EXISTS (SELECT 1 FROM builds WHERE builds.evaluation_id = \
|
||||
evaluations.id)",
|
||||
);
|
||||
} else {
|
||||
query_builder.push(
|
||||
" AND NOT EXISTS (SELECT 1 FROM builds WHERE builds.evaluation_id = \
|
||||
evaluations.id)",
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(after) = filters.finished_after {
|
||||
query_builder.push(" AND finished_at >= ");
|
||||
query_builder.push_bind(after);
|
||||
}
|
||||
if let Some(before) = filters.finished_before {
|
||||
query_builder.push(" AND finished_at <= ");
|
||||
query_builder.push_bind(before);
|
||||
}
|
||||
}
|
||||
|
||||
// Get count
|
||||
let count_sql = query_builder.sql().replace("SELECT *", "SELECT COUNT(*)");
|
||||
let (total,): (i64,) = sqlx::query_as(&count_sql).fetch_one(pool).await?;
|
||||
|
||||
// Apply sorting and pagination
|
||||
query_builder.push(" ORDER BY created_at DESC LIMIT ");
|
||||
query_builder.push_bind(params.limit);
|
||||
query_builder.push(" OFFSET ");
|
||||
query_builder.push_bind(params.offset);
|
||||
|
||||
let evaluations = query_builder
|
||||
.build_query_as::<Evaluation>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((evaluations, total))
|
||||
}
|
||||
|
||||
/// Search builds with advanced filters
|
||||
async fn search_builds(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
) -> Result<(Vec<Build>, i64)> {
|
||||
let pattern = if params.query.is_empty() {
|
||||
"%".to_string()
|
||||
} else {
|
||||
format!("%{}%", params.query)
|
||||
};
|
||||
|
||||
let mut query_builder: QueryBuilder<Postgres> =
|
||||
QueryBuilder::new("SELECT * FROM builds WHERE (job_name ILIKE ");
|
||||
query_builder.push_bind(&pattern);
|
||||
query_builder.push(" OR drv_path ILIKE ");
|
||||
query_builder.push_bind(&pattern);
|
||||
query_builder.push(")");
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref filters) = params.build_filters {
|
||||
if let Some(status) = filters.status {
|
||||
let status_str = match status {
|
||||
BuildStatusFilter::Pending => "pending",
|
||||
BuildStatusFilter::Running => "running",
|
||||
BuildStatusFilter::Succeeded => "succeeded",
|
||||
BuildStatusFilter::Failed => "failed",
|
||||
BuildStatusFilter::Cancelled => "cancelled",
|
||||
};
|
||||
query_builder.push(" AND status = ");
|
||||
query_builder.push_bind(status_str);
|
||||
}
|
||||
if let Some(project_id) = filters.project_id {
|
||||
query_builder.push(" AND project_id = ");
|
||||
query_builder.push_bind(project_id);
|
||||
}
|
||||
if let Some(jobset_id) = filters.jobset_id {
|
||||
query_builder.push(" AND jobset_id = ");
|
||||
query_builder.push_bind(jobset_id);
|
||||
}
|
||||
if let Some(evaluation_id) = filters.evaluation_id {
|
||||
query_builder.push(" AND evaluation_id = ");
|
||||
query_builder.push_bind(evaluation_id);
|
||||
}
|
||||
if let Some(after) = filters.created_after {
|
||||
query_builder.push(" AND created_at >= ");
|
||||
query_builder.push_bind(after);
|
||||
}
|
||||
if let Some(before) = filters.created_before {
|
||||
query_builder.push(" AND created_at <= ");
|
||||
query_builder.push_bind(before);
|
||||
}
|
||||
if let Some(min) = filters.min_priority {
|
||||
query_builder.push(" AND priority >= ");
|
||||
query_builder.push_bind(min);
|
||||
}
|
||||
if let Some(max) = filters.max_priority {
|
||||
query_builder.push(" AND priority <= ");
|
||||
query_builder.push_bind(max);
|
||||
}
|
||||
if let Some(has) = filters.has_substitutes {
|
||||
query_builder.push(" AND has_substitutes = ");
|
||||
query_builder.push_bind(has);
|
||||
}
|
||||
}
|
||||
|
||||
// Get count - apply same filters as main query
|
||||
let _count_sql = query_builder.sql().replace("SELECT *", "SELECT COUNT(*)");
|
||||
let count_query = query_builder.build_query_as::<(i64,)>();
|
||||
let (total,): (i64,) = count_query.fetch_one(pool).await?;
|
||||
|
||||
// Apply sorting
|
||||
query_builder.push(" ORDER BY ");
|
||||
if let Some((field, order)) = ¶ms.build_sort {
|
||||
let field_str = match field {
|
||||
BuildSortField::CreatedAt => "created_at",
|
||||
BuildSortField::JobName => "job_name",
|
||||
BuildSortField::Status => "status",
|
||||
BuildSortField::Priority => "priority",
|
||||
};
|
||||
let order_str = match order {
|
||||
SortOrder::Asc => "ASC",
|
||||
SortOrder::Desc => "DESC",
|
||||
};
|
||||
query_builder.push(field_str);
|
||||
query_builder.push(" ");
|
||||
query_builder.push(order_str);
|
||||
} else {
|
||||
query_builder.push("created_at DESC");
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
query_builder.push(" LIMIT ");
|
||||
query_builder.push_bind(params.limit);
|
||||
query_builder.push(" OFFSET ");
|
||||
query_builder.push_bind(params.offset);
|
||||
|
||||
let builds = query_builder
|
||||
.build_query_as::<Build>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((builds, total))
|
||||
}
|
||||
|
||||
/// Quick search - simple text search across entities
|
||||
pub async fn quick_search(
|
||||
pool: &PgPool,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
) -> Result<(Vec<Project>, Vec<Build>)> {
|
||||
let pattern = format!("%{}%", query);
|
||||
|
||||
let projects = sqlx::query_as::<_, Project>(
|
||||
"SELECT * FROM projects WHERE name ILIKE $1 OR description ILIKE $1 ORDER \
|
||||
BY name LIMIT $2",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let builds = sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds WHERE job_name ILIKE $1 OR drv_path ILIKE $1 ORDER \
|
||||
BY created_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok((projects, builds))
|
||||
}
|
||||
489
crates/common/tests/search_tests.rs
Normal file
489
crates/common/tests/search_tests.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
//! Integration tests for advanced search functionality
|
||||
//! Requires TEST_DATABASE_URL to be set to a PostgreSQL connection string.
|
||||
|
||||
use fc_common::{models::*, repo, repo::search::*};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping search test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&url)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!("./migrations").run(&pool).await.ok()?;
|
||||
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_project_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create test projects
|
||||
let project1 = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("search-test-alpha-{}", Uuid::new_v4().simple()),
|
||||
description: Some("Alpha testing project".to_string()),
|
||||
repository_url: "https://github.com/test/alpha".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project 1");
|
||||
|
||||
let project2 = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("search-test-beta-{}", Uuid::new_v4().simple()),
|
||||
description: Some("Beta testing project".to_string()),
|
||||
repository_url: "https://github.com/test/beta".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project 2");
|
||||
|
||||
// Search for "alpha"
|
||||
let params = SearchParams {
|
||||
query: "alpha".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.projects.len(), 1);
|
||||
assert_eq!(results.projects[0].id, project1.id);
|
||||
assert_eq!(results.total_projects, 1);
|
||||
|
||||
// Search for "testing" (should match both)
|
||||
let params = SearchParams {
|
||||
query: "testing".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.projects.len(), 2);
|
||||
assert_eq!(results.total_projects, 2);
|
||||
|
||||
// Cleanup
|
||||
repo::projects::delete(&pool, project1.id).await.ok();
|
||||
repo::projects::delete(&pool, project2.id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_build_search_with_filters() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Setup
|
||||
let project = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("build-search-{}", Uuid::new_v4().simple()),
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/repo".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
let jobset = repo::jobsets::create(&pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
enabled: Some(true),
|
||||
flake_mode: None,
|
||||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
// Create builds with different statuses
|
||||
let build1 = repo::builds::create(&pool, &CreateBuild {
|
||||
project_id: project.id,
|
||||
jobset_id: jobset.id,
|
||||
evaluation_id: None,
|
||||
job_name: "package-hello".to_string(),
|
||||
drv_path: "/nix/store/...-hello.drv".to_string(),
|
||||
priority: 100,
|
||||
})
|
||||
.await
|
||||
.expect("create build 1");
|
||||
|
||||
// Update build status
|
||||
repo::builds::update_status(&pool, build1.id, "succeeded")
|
||||
.await
|
||||
.expect("update build 1 status");
|
||||
|
||||
let build2 = repo::builds::create(&pool, &CreateBuild {
|
||||
project_id: project.id,
|
||||
jobset_id: jobset.id,
|
||||
evaluation_id: None,
|
||||
job_name: "package-world".to_string(),
|
||||
drv_path: "/nix/store/...-world.drv".to_string(),
|
||||
priority: 50,
|
||||
})
|
||||
.await
|
||||
.expect("create build 2");
|
||||
|
||||
repo::builds::update_status(&pool, build2.id, "failed")
|
||||
.await
|
||||
.expect("update build 2 status");
|
||||
|
||||
// Search by job name
|
||||
let params = SearchParams {
|
||||
query: "hello".to_string(),
|
||||
entities: vec![SearchEntity::Builds],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.builds.len(), 1);
|
||||
assert_eq!(results.builds[0].id, build1.id);
|
||||
|
||||
// Search with status filter (succeeded)
|
||||
let params = SearchParams {
|
||||
query: "".to_string(),
|
||||
entities: vec![SearchEntity::Builds],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: Some(BuildSearchFilters {
|
||||
status: Some(BuildStatusFilter::Succeeded),
|
||||
project_id: None,
|
||||
jobset_id: None,
|
||||
evaluation_id: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
min_priority: None,
|
||||
max_priority: None,
|
||||
has_substitutes: None,
|
||||
}),
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.builds.len(), 1);
|
||||
assert_eq!(results.builds[0].id, build1.id);
|
||||
|
||||
// Search with priority filter
|
||||
let params = SearchParams {
|
||||
query: "".to_string(),
|
||||
entities: vec![SearchEntity::Builds],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: Some(BuildSearchFilters {
|
||||
status: None,
|
||||
project_id: None,
|
||||
jobset_id: None,
|
||||
evaluation_id: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
min_priority: Some(75),
|
||||
max_priority: None,
|
||||
has_substitutes: None,
|
||||
}),
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.builds.len(), 1);
|
||||
assert_eq!(results.builds[0].id, build1.id);
|
||||
|
||||
// Cleanup
|
||||
repo::jobsets::delete(&pool, jobset.id).await.ok();
|
||||
repo::projects::delete(&pool, project.id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multi_entity_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create project with jobset and builds
|
||||
let project = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("multi-search-{}", Uuid::new_v4().simple()),
|
||||
description: Some("Multi-entity search test".to_string()),
|
||||
repository_url: "https://github.com/test/multi".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
let jobset = repo::jobsets::create(&pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
enabled: Some(true),
|
||||
flake_mode: None,
|
||||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
let build = repo::builds::create(&pool, &CreateBuild {
|
||||
project_id: project.id,
|
||||
jobset_id: jobset.id,
|
||||
evaluation_id: None,
|
||||
job_name: "test-job".to_string(),
|
||||
drv_path: "/nix/store/...-test.drv".to_string(),
|
||||
priority: 100,
|
||||
})
|
||||
.await
|
||||
.expect("create build");
|
||||
|
||||
// Search across all entities
|
||||
let params = SearchParams {
|
||||
query: "test".to_string(),
|
||||
entities: vec![
|
||||
SearchEntity::Projects,
|
||||
SearchEntity::Jobsets,
|
||||
SearchEntity::Builds,
|
||||
],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert!(!results.projects.is_empty());
|
||||
assert!(!results.jobsets.is_empty());
|
||||
assert!(!results.builds.is_empty());
|
||||
|
||||
// Cleanup
|
||||
repo::builds::delete(&pool, build.id).await.ok();
|
||||
repo::jobsets::delete(&pool, jobset.id).await.ok();
|
||||
repo::projects::delete(&pool, project.id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_pagination() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create multiple projects
|
||||
let mut project_ids = vec![];
|
||||
for i in 0..5 {
|
||||
let project = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("page-test-{}-{}", i, Uuid::new_v4().simple()),
|
||||
description: Some(format!("Page test project {}", i)),
|
||||
repository_url: "https://github.com/test/page".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project");
|
||||
project_ids.push(project.id);
|
||||
}
|
||||
|
||||
// Search with limit 2
|
||||
let params = SearchParams {
|
||||
query: "page-test".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.projects.len(), 2);
|
||||
assert!(results.total_projects >= 5);
|
||||
|
||||
// Search with offset 2
|
||||
let params = SearchParams {
|
||||
query: "page-test".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 2,
|
||||
offset: 2,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.projects.len(), 2);
|
||||
|
||||
// Cleanup
|
||||
for id in project_ids {
|
||||
repo::projects::delete(&pool, id).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_sorting() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create projects in reverse alphabetical order
|
||||
let project_z = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("zzz-sort-test-{}", Uuid::new_v4().simple()),
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/z".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project z");
|
||||
|
||||
let project_a = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("aaa-sort-test-{}", Uuid::new_v4().simple()),
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/a".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project a");
|
||||
|
||||
// Search sorted by name ascending
|
||||
let params = SearchParams {
|
||||
query: "sort-test".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: Some((ProjectSortField::Name, SortOrder::Asc)),
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
assert_eq!(results.projects.len(), 2);
|
||||
assert!(results.projects[0].name.starts_with("aaa"));
|
||||
assert!(results.projects[1].name.starts_with("zzz"));
|
||||
|
||||
// Cleanup
|
||||
repo::projects::delete(&pool, project_a.id).await.ok();
|
||||
repo::projects::delete(&pool, project_z.id).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_empty_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Empty query should return all entities (up to limit)
|
||||
let params = SearchParams {
|
||||
query: "".to_string(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
build_filters: None,
|
||||
project_filters: None,
|
||||
jobset_filters: None,
|
||||
evaluation_filters: None,
|
||||
build_sort: None,
|
||||
project_sort: None,
|
||||
};
|
||||
|
||||
let results = search(&pool, ¶ms).await.expect("search");
|
||||
// Should not error, just return results
|
||||
assert!(results.total_projects >= 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quick_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Create test data
|
||||
let project = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("quick-search-{}", Uuid::new_v4().simple()),
|
||||
description: Some("Quick search test".to_string()),
|
||||
repository_url: "https://github.com/test/quick".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("create project");
|
||||
|
||||
let jobset = repo::jobsets::create(&pool, CreateJobset {
|
||||
project_id: project.id,
|
||||
name: "default".to_string(),
|
||||
nix_expression: "packages".to_string(),
|
||||
enabled: Some(true),
|
||||
flake_mode: None,
|
||||
check_interval: None,
|
||||
branch: None,
|
||||
scheduling_shares: None,
|
||||
})
|
||||
.await
|
||||
.expect("create jobset");
|
||||
|
||||
let build = repo::builds::create(&pool, &CreateBuild {
|
||||
project_id: project.id,
|
||||
jobset_id: jobset.id,
|
||||
evaluation_id: None,
|
||||
job_name: "quick-job".to_string(),
|
||||
drv_path: "/nix/store/...-quick.drv".to_string(),
|
||||
priority: 100,
|
||||
})
|
||||
.await
|
||||
.expect("create build");
|
||||
|
||||
// Quick search
|
||||
let (projects, builds) = quick_search(&pool, "quick", 10)
|
||||
.await
|
||||
.expect("quick search");
|
||||
assert!(!projects.is_empty());
|
||||
assert!(!builds.is_empty());
|
||||
|
||||
// Cleanup
|
||||
repo::builds::delete(&pool, build.id).await.ok();
|
||||
repo::jobsets::delete(&pool, jobset.id).await.ok();
|
||||
repo::projects::delete(&pool, project.id).await.ok();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue