various: reuse HTTP client; eliminate intermediate string allocations; add tests
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I18b89e1aae78a400a89c9d89423ce1da6a6a6964
This commit is contained in:
parent
38ed7faee2
commit
f7081317ee
9 changed files with 245 additions and 96 deletions
|
|
@ -292,8 +292,6 @@ pub struct RemoteBuilder {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// --- User Management ---
|
||||
|
||||
/// User account for authentication and personalization
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
|
|
@ -352,7 +350,7 @@ pub struct UserSession {
|
|||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// --- Pagination ---
|
||||
// Pagination
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
|
|
@ -389,7 +387,7 @@ pub struct PaginatedResponse<T> {
|
|||
pub offset: i64,
|
||||
}
|
||||
|
||||
// --- DTO structs for creation and updates ---
|
||||
// DTO structs for creation and updates
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateProject {
|
||||
|
|
@ -537,7 +535,7 @@ pub struct SystemStatus {
|
|||
pub channels_count: i64,
|
||||
}
|
||||
|
||||
// --- User DTOs ---
|
||||
// User DTOs
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateUser {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Notification dispatch for build events
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
|
|
@ -7,6 +9,13 @@ use crate::{
|
|||
models::{Build, BuildStatus, Project},
|
||||
};
|
||||
|
||||
/// Shared HTTP client for all notification dispatches.
|
||||
/// Avoids recreating connection pools on every build completion.
|
||||
fn http_client() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(reqwest::Client::new)
|
||||
}
|
||||
|
||||
/// Dispatch all configured notifications for a completed build.
|
||||
pub async fn dispatch_build_finished(
|
||||
build: &Build,
|
||||
|
|
@ -113,8 +122,7 @@ async fn set_github_status(
|
|||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
match http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.header("User-Agent", "fc-ci")
|
||||
|
|
@ -166,8 +174,7 @@ async fn set_gitea_status(
|
|||
"context": format!("fc/{}", build.job_name),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
match http_client()
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {token}"))
|
||||
.json(&body)
|
||||
|
|
@ -226,8 +233,7 @@ async fn set_gitlab_status(
|
|||
"name": format!("fc/{}", build.job_name),
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
match http_client()
|
||||
.post(&url)
|
||||
.header("PRIVATE-TOKEN", token)
|
||||
.json(&body)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async fn create_test_build(
|
|||
.expect("create build")
|
||||
}
|
||||
|
||||
// ---- Existing tests ----
|
||||
// CRUD and lifecycle tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_project_crud() {
|
||||
|
|
@ -416,7 +416,7 @@ async fn test_not_found_errors() {
|
|||
));
|
||||
}
|
||||
|
||||
// ---- New hardening tests ----
|
||||
// Batch operations and edge cases
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_batch_get_completed_by_drv_paths() {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ fn test_parse_nix_eval_jobs_both_attr_and_name() {
|
|||
assert_eq!(outputs.get("out").unwrap(), "/nix/store/abc123-hello");
|
||||
}
|
||||
|
||||
// --- Inputs hash computation ---
|
||||
// Inputs hash computation
|
||||
|
||||
#[test]
|
||||
fn test_inputs_hash_deterministic() {
|
||||
|
|
|
|||
|
|
@ -137,17 +137,20 @@ async fn latest_build(
|
|||
}
|
||||
|
||||
fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
let subject_width = subject.len() * 7 + 10;
|
||||
let status_width = status.len() * 7 + 10;
|
||||
let total_width = subject_width + status_width;
|
||||
let subject_x = subject_width / 2;
|
||||
let status_x = subject_width + status_width / 2;
|
||||
|
||||
let mut svg = String::new();
|
||||
svg.push_str(&format!(
|
||||
let mut svg = String::with_capacity(768);
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_width}\" \
|
||||
height=\"20\">\n"
|
||||
));
|
||||
height=\"20\">"
|
||||
);
|
||||
svg.push_str(" <linearGradient id=\"b\" x2=\"0\" y2=\"100%\">\n");
|
||||
svg.push_str(
|
||||
" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n",
|
||||
|
|
@ -155,41 +158,43 @@ fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
|||
svg.push_str(" <stop offset=\"1\" stop-opacity=\".1\"/>\n");
|
||||
svg.push_str(" </linearGradient>\n");
|
||||
svg.push_str(" <mask id=\"a\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" \
|
||||
fill=\"#fff\"/>\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <rect width=\"{total_width}\" height=\"20\" rx=\"3\" fill=\"#fff\"/>"
|
||||
);
|
||||
svg.push_str(" </mask>\n");
|
||||
svg.push_str(" <g mask=\"url(#a)\">\n");
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{subject_width}\" height=\"20\" fill=\"#555\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <rect width=\"{subject_width}\" height=\"20\" fill=\"#555\"/>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <rect x=\"{subject_width}\" width=\"{status_width}\" height=\"20\" \
|
||||
fill=\"{color}\"/>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <rect width=\"{total_width}\" height=\"20\" fill=\"url(#b)\"/>\n"
|
||||
));
|
||||
fill=\"{color}\"/>"
|
||||
);
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <rect width=\"{total_width}\" height=\"20\" fill=\"url(#b)\"/>"
|
||||
);
|
||||
svg.push_str(" </g>\n");
|
||||
svg.push_str(
|
||||
" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu \
|
||||
Sans,Verdana,Geneva,sans-serif\" font-size=\"11\">\n",
|
||||
);
|
||||
svg.push_str(&format!(
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <text x=\"{subject_x}\" y=\"15\" fill=\"#010101\" \
|
||||
fill-opacity=\".3\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{subject_x}\" y=\"14\">{subject}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
fill-opacity=\".3\">{subject}</text>"
|
||||
);
|
||||
let _ =
|
||||
writeln!(svg, " <text x=\"{subject_x}\" y=\"14\">{subject}</text>");
|
||||
let _ = writeln!(
|
||||
svg,
|
||||
" <text x=\"{status_x}\" y=\"15\" fill=\"#010101\" \
|
||||
fill-opacity=\".3\">{status}</text>\n"
|
||||
));
|
||||
svg.push_str(&format!(
|
||||
" <text x=\"{status_x}\" y=\"14\">{status}</text>\n"
|
||||
));
|
||||
fill-opacity=\".3\">{status}</text>"
|
||||
);
|
||||
let _ = writeln!(svg, " <text x=\"{status_x}\" y=\"14\">{status}</text>");
|
||||
svg.push_str(" </g>\n");
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
|
|
@ -200,3 +205,50 @@ pub fn router() -> Router<AppState> {
|
|||
.route("/job/{project}/{jobset}/{job}/shield", get(build_badge))
|
||||
.route("/job/{project}/{jobset}/{job}/latest", get(latest_build))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_shield_svg_is_valid_svg() {
|
||||
let svg = shield_svg("build", "passing", "#4c1");
|
||||
assert!(svg.starts_with("<svg xmlns="));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert!(svg.contains("build"));
|
||||
assert!(svg.contains("passing"));
|
||||
assert!(svg.contains("#4c1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shield_svg_dimensions() {
|
||||
let svg = shield_svg("build", "failing", "#e05d44");
|
||||
// "build" = 5 chars * 7 + 10 = 45
|
||||
// "failing" = 7 chars * 7 + 10 = 59
|
||||
// total = 104
|
||||
assert!(svg.contains("width=\"104\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shield_svg_text_positions() {
|
||||
let svg = shield_svg("ci", "ok", "#4c1");
|
||||
// "ci" = 2*7+10 = 24, subject_x = 12
|
||||
// "ok" = 2*7+10 = 24, status_x = 24 + 12 = 36
|
||||
assert!(svg.contains("x=\"12\""));
|
||||
assert!(svg.contains("x=\"36\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shield_svg_different_statuses() {
|
||||
for (status, color) in [
|
||||
("passing", "#4c1"),
|
||||
("failing", "#e05d44"),
|
||||
("building", "#dfb317"),
|
||||
("not found", "#9f9f9f"),
|
||||
] {
|
||||
let svg = shield_svg("build", status, color);
|
||||
assert!(svg.contains(status), "SVG should contain status '{status}'");
|
||||
assert!(svg.contains(color), "SVG should contain color '{color}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ async fn delete_jobset(
|
|||
Ok(Json(serde_json::json!({ "deleted": true })))
|
||||
}
|
||||
|
||||
// --- Jobset input routes ---
|
||||
// Jobset input routes
|
||||
|
||||
async fn list_jobset_inputs(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -124,31 +124,38 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
.await
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
let mut output = String::new();
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut output = String::with_capacity(2048);
|
||||
|
||||
// Build counts by status
|
||||
output.push_str("# HELP fc_builds_total Total number of builds by status\n");
|
||||
output.push_str("# TYPE fc_builds_total gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"completed\"}} {}\n",
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"completed\"}} {}",
|
||||
stats.completed_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"failed\"}} {}\n",
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"failed\"}} {}",
|
||||
stats.failed_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"running\"}} {}\n",
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"running\"}} {}",
|
||||
stats.running_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"pending\"}} {}\n",
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"pending\"}} {}",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"fc_builds_total{{status=\"all\"}} {}\n",
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"all\"}} {}",
|
||||
stats.total_builds.unwrap_or(0)
|
||||
));
|
||||
);
|
||||
|
||||
// Build duration stats
|
||||
output.push_str(
|
||||
|
|
@ -156,67 +163,73 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
seconds\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_builds_avg_duration_seconds gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_builds_avg_duration_seconds {:.2}\n",
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_avg_duration_seconds {:.2}",
|
||||
stats.avg_duration_seconds.unwrap_or(0.0)
|
||||
));
|
||||
);
|
||||
|
||||
output.push_str(
|
||||
"\n# HELP fc_builds_duration_seconds Build duration percentiles\n",
|
||||
);
|
||||
output.push_str("# TYPE fc_builds_duration_seconds gauge\n");
|
||||
if let Some(p50) = duration_p50 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.5\"}} {p50:.2}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_duration_seconds{{quantile=\"0.5\"}} {p50:.2}"
|
||||
);
|
||||
}
|
||||
if let Some(p95) = duration_p95 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.95\"}} {p95:.2}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_duration_seconds{{quantile=\"0.95\"}} {p95:.2}"
|
||||
);
|
||||
}
|
||||
if let Some(p99) = duration_p99 {
|
||||
output.push_str(&format!(
|
||||
"fc_builds_duration_seconds{{quantile=\"0.99\"}} {p99:.2}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_duration_seconds{{quantile=\"0.99\"}} {p99:.2}"
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluations
|
||||
output
|
||||
.push_str("\n# HELP fc_evaluations_total Total number of evaluations\n");
|
||||
output.push_str("# TYPE fc_evaluations_total gauge\n");
|
||||
output.push_str(&format!("fc_evaluations_total {eval_count}\n"));
|
||||
let _ = writeln!(output, "fc_evaluations_total {eval_count}");
|
||||
|
||||
output.push_str("\n# HELP fc_evaluations_by_status Evaluations by status\n");
|
||||
output.push_str("# TYPE fc_evaluations_by_status gauge\n");
|
||||
for (status, count) in &eval_by_status {
|
||||
output.push_str(&format!(
|
||||
"fc_evaluations_by_status{{status=\"{status}\"}} {count}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_evaluations_by_status{{status=\"{status}\"}} {count}"
|
||||
);
|
||||
}
|
||||
|
||||
// Queue depth (pending builds)
|
||||
output
|
||||
.push_str("\n# HELP fc_queue_depth Number of pending builds in queue\n");
|
||||
output.push_str("# TYPE fc_queue_depth gauge\n");
|
||||
output.push_str(&format!(
|
||||
"fc_queue_depth {}\n",
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_queue_depth {}",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
));
|
||||
);
|
||||
|
||||
// Infrastructure
|
||||
output.push_str("\n# HELP fc_projects_total Total number of projects\n");
|
||||
output.push_str("# TYPE fc_projects_total gauge\n");
|
||||
output.push_str(&format!("fc_projects_total {project_count}\n"));
|
||||
let _ = writeln!(output, "fc_projects_total {project_count}");
|
||||
|
||||
output.push_str("\n# HELP fc_channels_total Total number of channels\n");
|
||||
output.push_str("# TYPE fc_channels_total gauge\n");
|
||||
output.push_str(&format!("fc_channels_total {channel_count}\n"));
|
||||
let _ = writeln!(output, "fc_channels_total {channel_count}");
|
||||
|
||||
output
|
||||
.push_str("\n# HELP fc_remote_builders_active Active remote builders\n");
|
||||
output.push_str("# TYPE fc_remote_builders_active gauge\n");
|
||||
output.push_str(&format!("fc_remote_builders_active {builder_count}\n"));
|
||||
let _ = writeln!(output, "fc_remote_builders_active {builder_count}");
|
||||
|
||||
// Per-project build counts
|
||||
if !per_project.is_empty() {
|
||||
|
|
@ -226,9 +239,10 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
output.push_str("# TYPE fc_project_builds_completed gauge\n");
|
||||
for (name, completed, _) in &per_project {
|
||||
let escaped = escape_prometheus_label(name);
|
||||
output.push_str(&format!(
|
||||
"fc_project_builds_completed{{project=\"{escaped}\"}} {completed}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_project_builds_completed{{project=\"{escaped}\"}} {completed}"
|
||||
);
|
||||
}
|
||||
output.push_str(
|
||||
"\n# HELP fc_project_builds_failed Failed builds per project\n",
|
||||
|
|
@ -236,9 +250,10 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
output.push_str("# TYPE fc_project_builds_failed gauge\n");
|
||||
for (name, _, failed) in &per_project {
|
||||
let escaped = escape_prometheus_label(name);
|
||||
output.push_str(&format!(
|
||||
"fc_project_builds_failed{{project=\"{escaped}\"}} {failed}\n"
|
||||
));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_project_builds_failed{{project=\"{escaped}\"}} {failed}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,3 +369,33 @@ pub fn router() -> Router<AppState> {
|
|||
)
|
||||
.route("/api/v1/metrics/systems", get(system_distribution))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_escape_prometheus_label_plain() {
|
||||
assert_eq!(escape_prometheus_label("hello"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_prometheus_label_backslash() {
|
||||
assert_eq!(escape_prometheus_label(r"a\b"), r"a\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_prometheus_label_quotes() {
|
||||
assert_eq!(escape_prometheus_label(r#"say "hi""#), r#"say \"hi\""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_prometheus_label_newline() {
|
||||
assert_eq!(escape_prometheus_label("line1\nline2"), r"line1\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_prometheus_label_combined() {
|
||||
assert_eq!(escape_prometheus_label("a\\b\n\"c\""), r#"a\\b\n\"c\""#);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{auth_middleware::RequireAdmin, error::ApiError, state::AppState};
|
||||
|
||||
// --- DTOs ---
|
||||
// Request/response DTOs
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
|
|
@ -92,7 +92,7 @@ pub struct StarredJobResponse {
|
|||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
// Admin user management handlers
|
||||
|
||||
async fn list_users(
|
||||
_auth: RequireAdmin,
|
||||
|
|
@ -165,7 +165,7 @@ async fn delete_user(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// --- Current User Handlers ---
|
||||
// Current user (self-service) handlers
|
||||
|
||||
async fn get_current_user(
|
||||
extensions: axum::http::Extensions,
|
||||
|
|
@ -283,7 +283,7 @@ pub struct ChangePasswordRequest {
|
|||
pub new_password: String,
|
||||
}
|
||||
|
||||
// --- Starred Jobs Handlers ---
|
||||
// Starred jobs handlers
|
||||
|
||||
async fn list_starred_jobs(
|
||||
State(state): State<AppState>,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ fn build_app_with_config(
|
|||
fc_server::routes::router(state, &server_config)
|
||||
}
|
||||
|
||||
// ---- Existing tests ----
|
||||
// API endpoint tests
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint() {
|
||||
|
|
@ -240,7 +240,7 @@ async fn test_builds_endpoints() {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ---- Hardening tests ----
|
||||
// Error response structure
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_includes_error_code() {
|
||||
|
|
@ -663,9 +663,57 @@ async fn test_metrics_endpoint() {
|
|||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
|
||||
// Verify metric names are present
|
||||
assert!(body_str.contains("fc_builds_total"));
|
||||
assert!(body_str.contains("fc_projects_total"));
|
||||
assert!(body_str.contains("fc_evaluations_total"));
|
||||
|
||||
// Verify Prometheus format: HELP/TYPE headers and label syntax
|
||||
assert!(
|
||||
body_str.contains("# HELP fc_builds_total"),
|
||||
"Missing HELP header for fc_builds_total"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("# TYPE fc_builds_total gauge"),
|
||||
"Missing TYPE header for fc_builds_total"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("fc_builds_total{status=\"completed\"}"),
|
||||
"Missing completed status label"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("fc_builds_total{status=\"failed\"}"),
|
||||
"Missing failed status label"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("fc_queue_depth"),
|
||||
"Missing queue depth metric"
|
||||
);
|
||||
assert!(
|
||||
body_str.contains("fc_builds_avg_duration_seconds"),
|
||||
"Missing avg duration metric"
|
||||
);
|
||||
|
||||
// Verify each line with a metric value ends with a number (basic format
|
||||
// check)
|
||||
for line in body_str.lines() {
|
||||
if line.starts_with('#') || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Metric lines should have format: metric_name{labels} value
|
||||
// or: metric_name value
|
||||
let parts: Vec<&str> = line.rsplitn(2, ' ').collect();
|
||||
assert!(
|
||||
parts.len() == 2,
|
||||
"Malformed metric line (expected 'name value'): {line}"
|
||||
);
|
||||
assert!(
|
||||
parts[0].parse::<f64>().is_ok(),
|
||||
"Metric value is not a number: '{}' in line: {line}",
|
||||
parts[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -698,7 +746,7 @@ async fn test_get_nonexistent_build_returns_error_code() {
|
|||
assert!(json["error"].as_str().unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
// ---- Validation tests ----
|
||||
// Input validation
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_rejects_invalid_name() {
|
||||
|
|
@ -802,7 +850,7 @@ async fn test_create_project_validation_accepts_valid() {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ---- Error handling tests ----
|
||||
// Auth and error handling
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_project_create_with_auth() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue