use chrono::{TimeZone, Utc};
use proptest::prelude::*;
use tracing::info;
use beads_rust::error::{BeadsError, ErrorCode, StructuredError};
use beads_rust::model::{Issue, IssueType, Priority, Status};
use beads_rust::validation::{IssueValidator, LabelValidator};
fn init_test_logging() {
let _ = tracing_subscriber::fmt()
.with_env_filter("info")
.with_test_writer()
.try_init();
}
fn make_valid_issue(title: &str) -> Issue {
let now = Utc::now();
Issue {
id: "bd-test123".to_string(),
content_hash: None,
title: title.to_string(),
description: None,
design: None,
acceptance_criteria: None,
notes: None,
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
assignee: None,
owner: None,
estimated_minutes: None,
created_at: now,
created_by: None,
updated_at: now,
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
source_repo_path: None,
agent_context: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
fn assert_validation_error_for_field(
result: Result<(), Vec<beads_rust::error::ValidationError>>,
expected_field: &str,
) {
let errors = result.expect_err("issue should fail validation");
assert!(
errors.iter().any(|e| e.field == expected_field),
"expected {expected_field} error, got {errors:?}"
);
let structured = StructuredError::from_error(&BeadsError::from_validation_errors(errors));
assert_eq!(structured.code, ErrorCode::ValidationFailed);
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 100,
..Default::default()
})]
#[test]
fn valid_issue_passes(title in "[a-zA-Z0-9 ]{1,100}") {
init_test_logging();
info!("proptest_valid_issue: title_len={len}", len = title.len());
prop_assume!(!title.trim().is_empty());
let issue = make_valid_issue(&title);
let result = IssueValidator::validate(&issue);
prop_assert!(
result.is_ok(),
"Valid issue should pass validation: {result:?}"
);
}
#[test]
fn invalid_priority_fails(priority in 5i32..100i32) {
init_test_logging();
info!("proptest_invalid_priority: priority={priority}");
let mut issue = make_valid_issue("Test Issue");
issue.priority = Priority(priority);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "Priority {priority} should fail validation");
let errors = result.unwrap_err();
prop_assert!(
errors.iter().any(|e| e.field == "priority"),
"Should have priority error"
);
}
#[test]
fn negative_priority_fails(priority in -100i32..-1i32) {
init_test_logging();
info!("proptest_negative_priority: priority={priority}");
let mut issue = make_valid_issue("Test Issue");
issue.priority = Priority(priority);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "Priority {priority} should fail validation");
}
#[test]
fn valid_priority_passes(priority in 0i32..=4i32) {
init_test_logging();
info!("proptest_valid_priority: priority={priority}");
let mut issue = make_valid_issue("Test Issue");
issue.priority = Priority(priority);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_ok(), "Priority {priority} should pass validation");
}
#[test]
fn empty_title_fails(whitespace in "\\s{0,10}") {
init_test_logging();
info!(
"proptest_empty_title: whitespace_len={len}",
len = whitespace.len()
);
let mut issue = make_valid_issue("Valid");
issue.title = whitespace;
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "Empty/whitespace title should fail");
let errors = result.unwrap_err();
prop_assert!(
errors.iter().any(|e| e.field == "title"),
"Should have title error"
);
}
#[test]
fn long_title_fails(len in 501usize..600usize) {
init_test_logging();
info!("proptest_long_title: len={len}");
let mut issue = make_valid_issue("Valid");
issue.title = "x".repeat(len);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "Title with {len} chars should fail");
let errors = result.unwrap_err();
prop_assert!(
errors.iter().any(|e| e.field == "title"),
"Should have title error"
);
}
#[test]
fn title_at_limit_passes(len in 1usize..=500usize) {
init_test_logging();
info!("proptest_title_limit: len={len}");
let mut issue = make_valid_issue("Valid");
issue.title = "x".repeat(len);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_ok(), "Title with {len} chars should pass");
}
#[test]
fn large_description_validates(extra_bytes in 1usize..1000usize) {
init_test_logging();
let len = 600_000 + extra_bytes;
info!("proptest_large_desc: len={len}");
let mut issue = make_valid_issue("Test Issue");
issue.description = Some("x".repeat(len));
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_ok(), "Description with {len} bytes must validate (long-text fields are unbounded)");
}
#[test]
fn large_rich_text_fields_validate(
field_index in 0usize..3usize,
extra_bytes in 1usize..1000usize,
) {
init_test_logging();
let len = 600_000 + extra_bytes;
let payload = "x".repeat(len);
let mut issue = make_valid_issue("Test Issue");
match field_index {
0 => issue.design = Some(payload),
1 => issue.acceptance_criteria = Some(payload),
_ => issue.notes = Some(payload),
}
IssueValidator::validate(&issue).expect("long rich-text fields must validate cleanly");
}
#[test]
fn long_actor_fields_fail(
field_index in 0usize..4usize,
extra_chars in 1usize..50usize,
) {
init_test_logging();
let payload = "x".repeat(200 + extra_chars);
let mut issue = make_valid_issue("Test Issue");
let expected_field = match field_index {
0 => {
issue.assignee = Some(payload);
"assignee"
}
1 => {
issue.owner = Some(payload);
"owner"
}
2 => {
issue.created_by = Some(payload);
"created_by"
}
_ => {
issue.source_system = Some(payload);
"source_system"
}
};
assert_validation_error_for_field(IssueValidator::validate(&issue), expected_field);
}
#[test]
fn long_custom_status_and_type_fail(
field_index in 0usize..2usize,
extra_chars in 1usize..50usize,
) {
init_test_logging();
let payload = "x".repeat(50 + extra_chars);
let mut issue = make_valid_issue("Test Issue");
let expected_field = if field_index == 0 {
issue.status = Status::Custom(payload);
"status"
} else {
issue.issue_type = IssueType::Custom(payload);
"issue_type"
};
assert_validation_error_for_field(IssueValidator::validate(&issue), expected_field);
}
#[test]
fn unbounded_labels_fail(
label_count in 65usize..80usize,
long_label_extra in 1usize..50usize,
) {
init_test_logging();
let mut too_many = make_valid_issue("Test Issue");
too_many.labels = (0..label_count).map(|i| format!("label{i}")).collect();
assert_validation_error_for_field(IssueValidator::validate(&too_many), "labels");
let mut too_long = make_valid_issue("Test Issue");
too_long.labels = vec!["x".repeat(50 + long_label_extra)];
assert_validation_error_for_field(IssueValidator::validate(&too_long), "labels");
}
#[test]
fn updated_before_created_fails(days_before in 1u32..100u32) {
init_test_logging();
info!("proptest_timestamp_order: days_before={days_before}");
let mut issue = make_valid_issue("Test Issue");
issue.created_at = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap();
issue.updated_at = issue.created_at - chrono::Duration::days(i64::from(days_before));
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "updated_at before created_at should fail");
let errors = result.unwrap_err();
prop_assert!(
errors.iter().any(|e| e.field == "updated_at"),
"Should have updated_at error"
);
}
#[test]
fn valid_label_passes(label in "[a-zA-Z0-9_:-]{1,50}") {
init_test_logging();
info!("proptest_valid_label: label={label}");
let result = LabelValidator::validate(&label);
prop_assert!(result.is_ok(), "Label '{label}' should pass validation");
}
#[test]
fn label_with_space_fails(
prefix in "[a-z]{1,10}",
suffix in "[a-z]{1,10}",
) {
init_test_logging();
let label = format!("{prefix} {suffix}");
info!("proptest_label_space: label={label}");
let result = LabelValidator::validate(&label);
prop_assert!(result.is_err(), "Label with space should fail: '{label}'");
}
#[test]
fn empty_label_fails(_dummy in 0..1u8) {
init_test_logging();
let result = LabelValidator::validate("");
prop_assert!(result.is_err(), "Empty label should fail");
}
#[test]
fn long_label_fails(len in 51usize..100usize) {
init_test_logging();
let label = "x".repeat(len);
info!("proptest_long_label: len={len}");
let result = LabelValidator::validate(&label);
prop_assert!(result.is_err(), "Label with {len} chars should fail");
}
#[test]
fn external_ref_whitespace_fails(
prefix in "[a-z]{1,10}",
suffix in "[a-z]{1,10}",
) {
init_test_logging();
let external_ref = format!("{prefix} {suffix}");
info!("proptest_external_ref: external_ref={external_ref}");
let mut issue = make_valid_issue("Test Issue");
issue.external_ref = Some(external_ref);
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_err(), "External ref with whitespace should fail");
let errors = result.unwrap_err();
prop_assert!(
errors.iter().any(|e| e.field == "external_ref"),
"Should have external_ref error"
);
}
#[test]
fn valid_external_ref_passes(external_ref in "[a-zA-Z0-9_/-]{1,50}") {
init_test_logging();
info!("proptest_valid_external_ref: external_ref={external_ref}");
let mut issue = make_valid_issue("Test Issue");
issue.external_ref = Some(external_ref.clone());
let result = IssueValidator::validate(&issue);
prop_assert!(result.is_ok(), "Valid external ref should pass: '{external_ref}'");
}
}
#[test]
fn all_standard_statuses_valid() {
init_test_logging();
info!("proptest_statuses: testing all standard statuses");
let statuses = [
Status::Open,
Status::InProgress,
Status::Blocked,
Status::Deferred,
Status::Closed,
Status::Tombstone,
Status::Pinned,
];
for status in statuses {
let mut issue = make_valid_issue("Test Issue");
issue.status = status.clone();
if status == Status::Closed {
issue.closed_at = Some(issue.updated_at);
}
let result = IssueValidator::validate(&issue);
assert!(result.is_ok(), "Status {status:?} should be valid");
}
info!("proptest_statuses: PASS - all standard statuses valid");
}
#[test]
fn all_standard_types_valid() {
init_test_logging();
info!("proptest_types: testing all standard issue types");
let types = [
IssueType::Task,
IssueType::Bug,
IssueType::Feature,
IssueType::Epic,
IssueType::Chore,
IssueType::Docs,
IssueType::Question,
];
for issue_type in types {
let mut issue = make_valid_issue("Test Issue");
issue.issue_type = issue_type.clone();
let result = IssueValidator::validate(&issue);
assert!(result.is_ok(), "IssueType {issue_type:?} should be valid");
}
info!("proptest_types: PASS - all standard types valid");
}