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:
raf 2026-02-05 22:42:00 +03:00
commit dec4753567
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 1369 additions and 33 deletions

View file

@ -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;

View 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 &params.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)) = &params.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)) = &params.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))
}

View 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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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();
}