lenso-service 0.1.21

Public contracts for Lenso Providers and Autonomous Services.
Documentation
use lenso_service::{
    AUTONOMOUS_SERVICE_V2_FIXTURE_JSON, AutonomousServiceContract, COMMON_CONTEXT_V1_FIXTURE_JSON,
    CommonContextContract, CommonContextRequirement, CompatibilityCategory, EventContext,
    EventContractGenerationError, EventEnvelope, EventEnvelopeIssueCode, SUPPORT_EVENT_SCHEMA_JSON,
    evaluate_generated_event_contract_compatibility, event_envelope_from_cloudevent,
    generate_event_contract, validate_event_envelope_value,
};
use serde_json::json;

#[test]
fn support_event_declaration_builds_a_versioned_event_envelope() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let contract = generate_event_contract(&service, &service.event_contracts[0], &payload_schema)
        .expect("support event declaration should generate");
    let context: CommonContextContract =
        serde_json::from_str(COMMON_CONTEXT_V1_FIXTURE_JSON).unwrap();

    let envelope = EventEnvelope::new(
        &contract,
        "event_support_ticket_01",
        "2026-07-14T10:15:30Z",
        context,
        json!({
            "ticketId": "ticket_01",
            "openedAt": "2026-07-14T10:15:00Z"
        }),
    );

    assert_eq!(envelope.protocol, "lenso.event-envelope.v1");
    assert_eq!(envelope.event_id, "event_support_ticket_01");
    assert_eq!(envelope.event_type, "support.ticket-opened.v1");
    assert_eq!(envelope.contract_id, "ticket-opened");
    assert_eq!(envelope.contract_version, "v1");
    assert_eq!(envelope.producer_service_id, "support");
    assert_eq!(envelope.module_id, "acme/support-ticket");
    assert_eq!(envelope.content.content_type, "application/json");
    assert_eq!(
        envelope.content.schema,
        "contracts/events/support/support.ticket-opened.v1.schema.json"
    );
    assert_eq!(
        envelope.context.story.as_ref().unwrap().story_id,
        "story_support_case_01"
    );
    assert_eq!(
        envelope.context.causation.as_ref().unwrap().causation_id,
        "request_01"
    );
    assert_eq!(
        envelope.context.tenant.as_ref().unwrap().tenant_id,
        "tenant_01"
    );
    assert_eq!(
        envelope.context.service_principal.as_ref().unwrap().subject,
        "spiffe://example.com/service/support"
    );
    assert_eq!(
        envelope.context.region.as_ref().unwrap().operating_region,
        "cn-east-1"
    );
}

#[test]
fn event_generation_rejects_a_contract_not_declared_by_the_service() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let mut undeclared = service.event_contracts[0].clone();
    undeclared
        .context
        .required
        .push(CommonContextRequirement::Deadline);
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();

    assert_eq!(
        generate_event_contract(&service, &undeclared, &payload_schema),
        Err(EventContractGenerationError::InvalidDeclaration)
    );
    let mut invalid_schema = payload_schema;
    invalid_schema["title"] = json!("support.ticket-opened.invalid");
    assert_eq!(
        generate_event_contract(&service, &service.event_contracts[0], &invalid_schema),
        Err(EventContractGenerationError::InvalidPayloadSchema)
    );
    let nested_schema = json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "$id": "https://contracts.lenso.local/events/support.ticket-opened.v1.schema.json",
        "title": "support.ticket-opened.v1",
        "type": "object",
        "$defs": {
            "ticket": {
                "type": "object",
                "properties": {
                    "tags": {"type": "array", "items": {"type": "string"}}
                },
                "required": ["tags"]
            }
        },
        "properties": {
            "ticket": {"$ref": "#/$defs/ticket"}
        },
        "required": ["ticket"]
    });
    assert!(generate_event_contract(&service, &service.event_contracts[0], &nested_schema).is_ok());
}

#[test]
fn envelope_validation_reports_a_malformed_deserialized_payload_schema() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let mut contract =
        generate_event_contract(&service, &service.event_contracts[0], &payload_schema).unwrap();
    let context: CommonContextContract =
        serde_json::from_str(COMMON_CONTEXT_V1_FIXTURE_JSON).unwrap();
    let envelope = EventEnvelope::new(
        &contract,
        "event_support_ticket_01",
        "2026-07-14T10:15:30Z",
        context,
        json!({"ticketId": "ticket_01", "openedAt": "2026-07-14T10:15:00Z"}),
    );
    contract.payload_schema = json!({"type": 42});

    let issues = lenso_service::validate_event_envelope(&contract, &envelope);

    assert_eq!(issues[0].code, EventEnvelopeIssueCode::InvalidContent);
    assert_eq!(issues[0].path, "$.content.data");
    assert!(!issues[0].next_action.is_empty());
}

#[test]
fn cloudevents_structured_representation_round_trips_every_lenso_field() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let contract = generate_event_contract(&service, &service.event_contracts[0], &payload_schema)
        .expect("support event declaration should generate");
    let context: CommonContextContract =
        serde_json::from_str(COMMON_CONTEXT_V1_FIXTURE_JSON).unwrap();
    let envelope = EventEnvelope::new(
        &contract,
        "event_support_ticket_01",
        "2026-07-14T10:15:30Z",
        context,
        json!({"ticketId": "ticket_01", "openedAt": "2026-07-14T10:15:00Z"}),
    );

    let cloud_event = envelope.to_cloudevent();
    let serialized = serde_json::to_value(&cloud_event).unwrap();
    assert_eq!(serialized["specversion"], "1.0");
    assert_eq!(serialized["id"], envelope.event_id);
    assert_eq!(serialized["type"], envelope.event_type);
    assert_eq!(serialized["source"], "urn:lenso:service:support");
    assert_eq!(
        serialized["subject"],
        "acme/support-ticket/ticket-opened/v1"
    );
    assert_eq!(serialized["data"]["context"], json!(envelope.context));
    assert!(serialized.get("topic").is_none());
    assert!(serialized.get("partition").is_none());
    assert!(serialized.get("offset").is_none());

    let decoded = event_envelope_from_cloudevent(&contract, &cloud_event)
        .expect("CloudEvents representation should decode");
    assert_eq!(decoded, envelope);
}

#[test]
fn envelope_validation_rejects_missing_malformed_untrusted_and_incompatible_context() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let contract = generate_event_contract(&service, &service.event_contracts[0], &payload_schema)
        .expect("support event declaration should generate");
    let context: CommonContextContract =
        serde_json::from_str(COMMON_CONTEXT_V1_FIXTURE_JSON).unwrap();
    let envelope = EventEnvelope::new(
        &contract,
        "event_support_ticket_01",
        "2026-07-14T10:15:30Z",
        context,
        json!({"ticketId": "ticket_01", "openedAt": "2026-07-14T10:15:00Z"}),
    );

    let mut missing = serde_json::to_value(&envelope).unwrap();
    missing["context"].as_object_mut().unwrap().remove("story");
    let mut malformed = serde_json::to_value(&envelope).unwrap();
    malformed["context"]["trace"]["traceparent"] = json!("");
    let mut malformed_time = serde_json::to_value(&envelope).unwrap();
    malformed_time["occurredAt"] = json!("yesterday");
    let mut untrusted = serde_json::to_value(&envelope).unwrap();
    untrusted["context"]["trace"]["baggage"]["actor.role"] = json!("admin");
    let mut incompatible = serde_json::to_value(&envelope).unwrap();
    incompatible["contractId"] = json!("ticket-closed");
    let mut incompatible_context = serde_json::to_value(&envelope).unwrap();
    incompatible_context["context"]["protocol"] = json!("lenso.context.v2");
    let mut incompatible_region = serde_json::to_value(&envelope).unwrap();
    incompatible_region["context"]["region"]["operatingRegion"] = json!("eu-west-1");
    let mut invalid_content = serde_json::to_value(&envelope).unwrap();
    invalid_content["content"]["data"] = json!({});

    for (value, expected_code, expected_path) in [
        (
            missing,
            EventEnvelopeIssueCode::MissingRequiredContext,
            "$.context.story.storyId",
        ),
        (
            malformed,
            EventEnvelopeIssueCode::MalformedContext,
            "$.context.trace.traceparent",
        ),
        (
            malformed_time,
            EventEnvelopeIssueCode::InvalidOccurrenceTime,
            "$.occurredAt",
        ),
        (
            untrusted,
            EventEnvelopeIssueCode::UntrustedContext,
            "$.context.trace.baggage.actor.role",
        ),
        (
            incompatible,
            EventEnvelopeIssueCode::IncompatibleContractIdentity,
            "$.contractId",
        ),
        (
            incompatible_context,
            EventEnvelopeIssueCode::IncompatibleContext,
            "$.context.protocol",
        ),
        (
            incompatible_region,
            EventEnvelopeIssueCode::IncompatibleRegion,
            "$.context.region.operatingRegion",
        ),
        (
            invalid_content,
            EventEnvelopeIssueCode::InvalidContent,
            "$.content.data",
        ),
    ] {
        let issues = validate_event_envelope_value(&contract, &value);
        assert_eq!(issues[0].code, expected_code);
        assert_eq!(issues[0].path, expected_path);
        assert!(!issues[0].next_action.is_empty());
    }
}

#[test]
fn subset_context_round_trips_without_undeclared_context_fields() {
    let mut service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    service.event_contracts[0].tenancy_mode = lenso_service::ServiceTenancyMode::Optional;
    service.event_contracts[0].context.required = vec![CommonContextRequirement::Story];
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let contract = generate_event_contract(&service, &service.event_contracts[0], &payload_schema)
        .expect("subset context declaration should generate");
    let full: CommonContextContract = serde_json::from_str(COMMON_CONTEXT_V1_FIXTURE_JSON).unwrap();
    let context = EventContext {
        protocol: full.protocol,
        story: Some(full.story),
        trace: None,
        service_principal: None,
        delegated_actor: None,
        tenant: None,
        deadline: None,
        idempotency_key: None,
        causation: None,
        region: None,
    };
    let envelope = EventEnvelope::new(
        &contract,
        "event_support_ticket_02",
        "2026-07-14T10:15:30Z",
        context,
        json!({"ticketId": "ticket_02", "openedAt": "2026-07-14T10:15:00Z"}),
    );

    assert!(lenso_service::validate_event_envelope(&contract, &envelope).is_empty());
    assert_eq!(
        event_envelope_from_cloudevent(&contract, &envelope.to_cloudevent()).unwrap(),
        envelope
    );
}

#[test]
fn generated_event_artifacts_classify_every_compatibility_category() {
    let service: AutonomousServiceContract =
        serde_json::from_str(AUTONOMOUS_SERVICE_V2_FIXTURE_JSON).unwrap();
    let payload_schema = serde_json::from_str(SUPPORT_EVENT_SCHEMA_JSON).unwrap();
    let before = generate_event_contract(&service, &service.event_contracts[0], &payload_schema)
        .expect("support event declaration should generate");
    let mut safe = before.clone();
    safe.contract_version = "v2".to_owned();
    safe.event_type = "support.ticket-opened.v2".to_owned();
    safe.artifact.path = "contracts/events/support/support.ticket-opened.v2.schema.json".to_owned();
    safe.payload_schema["title"] = json!("support.ticket-opened.v2");
    safe.payload_schema["properties"]["priority"] = json!({"type": "string"});
    let mut needs_attention = safe.clone();
    needs_attention
        .operating_regions
        .push("eu-west-1".to_owned());
    let mut breaking = safe.clone();
    breaking
        .context
        .required
        .push(CommonContextRequirement::Deadline);
    let mut blocked = safe.clone();
    blocked.protocol = "lenso.event-contract.v2".to_owned();

    for (after, expected) in [
        (safe, CompatibilityCategory::Safe),
        (needs_attention, CompatibilityCategory::NeedsAttention),
        (breaking, CompatibilityCategory::Breaking),
        (blocked, CompatibilityCategory::Blocked),
    ] {
        let result = evaluate_generated_event_contract_compatibility(&before, &after);
        assert_eq!(result.category, expected);
        assert!(!result.reasons.is_empty());
        assert!(result.reasons.iter().all(|reason| {
            !reason.code.is_empty() && !reason.path.is_empty() && !reason.next_action.is_empty()
        }));
        if expected != CompatibilityCategory::Safe {
            assert!(
                result
                    .reasons
                    .iter()
                    .all(|reason| reason.code != "event_backward_compatible")
            );
        }
    }
    let mut renamed = before.clone();
    renamed.contract_version = "v2".to_owned();
    renamed.event_type = "renamed.ticket-opened.v2".to_owned();
    renamed.artifact.path =
        "contracts/events/support/renamed.ticket-opened.v2.schema.json".to_owned();
    renamed.payload_schema["title"] = json!("renamed.ticket-opened.v2");
    assert_eq!(
        evaluate_generated_event_contract_compatibility(&before, &renamed).category,
        CompatibilityCategory::Breaking
    );
    let mut redirected = before.clone();
    redirected.contract_version = "v2".to_owned();
    redirected.event_type = "support.ticket-opened.v2".to_owned();
    redirected.artifact.path =
        "contracts/events/redirected/support.ticket-opened.v2.schema.json".to_owned();
    redirected.payload_schema["title"] = json!("support.ticket-opened.v2");
    assert_eq!(
        evaluate_generated_event_contract_compatibility(&before, &redirected).category,
        CompatibilityCategory::Breaking
    );
}