coil-ops 0.1.1

Operations and release-management capabilities for the Coil framework.
Documentation
use super::*;

fn jobs_runtime() -> JobsRuntime {
    JobsRuntime::from_config(&coil_config::JobsConfig {
        backend: coil_config::JobBackend::Redis,
        retry_limit: 3,
    })
    .expect("valid jobs runtime")
}

fn sample_manifests() -> Vec<ModuleManifest> {
    vec![
        ModuleManifest::new("cms")
            .with_search_contributions(vec![ManifestSearchIndexContribution::new(
                "search.cms.pages",
                ManifestSearchDocumentKind::Page,
                ManifestSearchVisibility::Public,
                true,
                vec![ManifestSearchFieldContribution::new(
                    "title",
                    "title",
                    ManifestSearchFieldRole::Title,
                    true,
                    true,
                )],
                vec![ManifestSearchInvalidationRule::new(
                    ManifestSearchInvalidationTrigger::Published,
                    "page published",
                )],
                ManifestSearchRebuildStrategy::OnInvalidate,
            )])
            .with_bulk_operations(vec![ManifestBulkOperationDefinition::new(
                "bulk.cms.publish",
                "Bulk publish pages",
                Some("Publishes editorial pages".to_string()),
                Capability::CmsPagePublish,
                ManifestBulkOperationKind::Publish,
                ManifestBulkOperationScope::Cms,
                default_retry_policy(),
                Some(100),
                true,
            )]),
        ModuleManifest::new("commerce")
            .with_search_contributions(vec![ManifestSearchIndexContribution::new(
                "search.catalog.products",
                ManifestSearchDocumentKind::Product,
                ManifestSearchVisibility::Public,
                true,
                vec![ManifestSearchFieldContribution::new(
                    "name",
                    "name",
                    ManifestSearchFieldRole::Title,
                    true,
                    true,
                )],
                vec![ManifestSearchInvalidationRule::new(
                    ManifestSearchInvalidationTrigger::Published,
                    "product published",
                )],
                ManifestSearchRebuildStrategy::OnInvalidate,
            )])
            .with_report_definitions(vec![ManifestReportDefinition::new(
                "report.orders.summary",
                "Orders summary",
                Some("Operational summary of captured orders".to_string()),
                Capability::OrderRead,
                ManifestReportFormat::Csv,
                ManifestReportSensitivity::Restricted,
                ManifestReportDeliveryMode::InternalOnly,
                "reports/orders",
                default_retry_policy(),
            )]),
        ModuleManifest::new("events")
            .with_search_contributions(vec![ManifestSearchIndexContribution::new(
                "search.events.bookings",
                ManifestSearchDocumentKind::Booking,
                ManifestSearchVisibility::Capability(Capability::EventsBookingCheckIn),
                false,
                vec![ManifestSearchFieldContribution::new(
                    "attendee",
                    "attendee.display_name",
                    ManifestSearchFieldRole::Title,
                    true,
                    true,
                )],
                vec![ManifestSearchInvalidationRule::new(
                    ManifestSearchInvalidationTrigger::Updated,
                    "booking changed",
                )],
                ManifestSearchRebuildStrategy::ManualOnly,
            )])
            .with_bulk_operations(vec![ManifestBulkOperationDefinition::new(
                "bulk.events.check-in",
                "Bulk check in bookings",
                Some("Checks in event bookings".to_string()),
                Capability::EventsBookingCheckIn,
                ManifestBulkOperationKind::CheckIn,
                ManifestBulkOperationScope::Events,
                default_retry_policy(),
                Some(500),
                true,
            )]),
        ModuleManifest::new("media").with_search_contributions(vec![
            ManifestSearchIndexContribution::new(
                "search.media",
                ManifestSearchDocumentKind::Media,
                ManifestSearchVisibility::Public,
                true,
                vec![ManifestSearchFieldContribution::new(
                    "title",
                    "title",
                    ManifestSearchFieldRole::Title,
                    true,
                    true,
                )],
                vec![ManifestSearchInvalidationRule::new(
                    ManifestSearchInvalidationTrigger::Published,
                    "asset published",
                )],
                ManifestSearchRebuildStrategy::OnInvalidate,
            ),
        ]),
        OpsModule::new().manifest(),
    ]
}

#[test]
fn catalog_exposes_search_visibility_rules() {
    let manifests = sample_manifests();
    let catalog = OpsCatalog::from_manifests(&manifests).expect("catalog");
    catalog
        .validate()
        .expect("composed catalog should validate");

    let visible = catalog.search.visible_to(&[]);
    assert!(
        visible
            .iter()
            .any(|index| index.id.as_str() == "search.cms.pages")
    );
    assert!(
        visible
            .iter()
            .any(|index| index.id.as_str() == "search.media")
    );
    assert!(
        !visible
            .iter()
            .any(|index| index.id.as_str() == "search.events.bookings")
    );
}

#[test]
fn report_exports_are_planned_as_async_jobs_with_output_metadata() {
    let planner = OpsPlanner::new(
        jobs_runtime(),
        OpsCatalog::from_manifests(&sample_manifests()).expect("catalog"),
    )
    .expect("planner");
    let request = ReportExportRequest::new(
        ReportExportId::new("export-1").expect("valid id"),
        ReportId::new("report.orders.summary").expect("valid id"),
        "operator-1",
        JobInstant::from_unix_seconds(100),
    )
    .expect("request")
    .with_capability(Capability::OrderRead)
    .with_idempotency_key(IdempotencyKey::new("orders:summary:v1").expect("valid key"));

    let plan = planner.plan_report_export(request).expect("export plan");
    assert_eq!(plan.job.queue.as_str(), "jobs.work");
    assert_eq!(plan.definition.id.as_str(), "report.orders.summary");
    assert!(plan.planned_job.idempotency_key.is_some());
    assert_eq!(plan.output_object_key, "reports/orders/export-1.csv");
}

#[test]
fn recovery_workflows_require_capability_idempotency_and_local_only_acknowledgement() {
    let planner = OpsPlanner::new(
        jobs_runtime(),
        OpsCatalog::from_manifests(&sample_manifests()).expect("catalog"),
    )
    .expect("planner");
    let request = RecoveryPlanRequest::new(
        RecoveryExecutionId::new("recovery-1").expect("valid id"),
        RecoveryWorkflowId::new("recovery.customer-app.full-restore").expect("valid id"),
        "showcase-events",
        "operator-3",
        JobInstant::from_unix_seconds(300),
    )
    .expect("request");

    let missing = planner.plan_recovery_workflow(request.clone()).unwrap_err();
    assert!(matches!(
        missing,
        OpsModelError::MissingCapability {
            operation: "recovery workflow",
            required: Capability::SystemModuleManage
        }
    ));

    let missing_ack = planner
        .plan_recovery_workflow(
            request
                .clone()
                .with_capability(Capability::SystemModuleManage)
                .with_idempotency_key(
                    IdempotencyKey::new("recovery:showcase-events:v1").expect("valid key"),
                )
                .with_local_only_sensitive_data(false),
        )
        .unwrap_err();
    assert!(matches!(
        missing_ack,
        OpsModelError::MissingOperatorAcknowledgement { .. }
    ));

    let plan = planner
        .plan_recovery_workflow(
            request
                .with_capability(Capability::SystemModuleManage)
                .with_idempotency_key(
                    IdempotencyKey::new("recovery:showcase-events:v2").expect("valid key"),
                )
                .with_local_only_sensitive_data(true),
        )
        .expect("recovery plan");

    assert_eq!(
        plan.definition.id.as_str(),
        "recovery.customer-app.full-restore"
    );
    assert_eq!(plan.customer_app_id, "showcase-events");
    assert!(plan.requires_host_local_restore);
    assert!(plan.planned_job.idempotency_key.is_some());
    assert!(plan.stages.contains(&RecoveryStage::RestoreDatabase));
    assert!(
        plan.stages
            .contains(&RecoveryStage::RestoreLocalOnlySensitive)
    );
}

#[test]
fn bulk_operations_require_their_capability_and_idempotency() {
    let planner = OpsPlanner::new(
        jobs_runtime(),
        OpsCatalog::from_manifests(&sample_manifests()).expect("catalog"),
    )
    .expect("planner");
    let request = BulkOperationRequest::new(
        BulkExecutionId::new("bulk-1").expect("valid id"),
        BulkOperationId::new("bulk.events.check-in").expect("valid id"),
        "operator-2",
        JobInstant::from_unix_seconds(200),
        12,
    )
    .expect("request");

    let missing = planner.plan_bulk_operation(request.clone()).unwrap_err();
    assert!(matches!(
        missing,
        OpsModelError::MissingCapability {
            operation: "bulk operation",
            required: Capability::EventsBookingCheckIn
        }
    ));

    let plan = planner
        .plan_bulk_operation(
            request
                .with_capability(Capability::EventsBookingCheckIn)
                .with_idempotency_key(
                    IdempotencyKey::new("events:check-in:v1").expect("valid key"),
                ),
        )
        .expect("bulk plan");

    assert_eq!(plan.definition.id.as_str(), "bulk.events.check-in");
    assert!(plan.planned_job.idempotency_key.is_some());
    assert_eq!(plan.target_count, 12);
}

#[test]
fn search_and_report_definitions_are_registry_ready() {
    let module = OpsModule::new();
    let manifest = module.manifest();

    assert_eq!(manifest.name, "ops");
    assert_eq!(manifest.config_namespace.as_deref(), Some("ops"));
    assert!(
        manifest
            .required_capabilities
            .contains(&Capability::AdminShellAccess)
    );
    assert!(
        manifest
            .optional_capabilities
            .contains(&Capability::CmsPagePublish)
    );
    assert!(
        manifest
            .module_dependencies
            .iter()
            .any(|dependency| dependency.module == "admin")
    );
    assert_eq!(manifest.migrations.len(), 3);
    assert_eq!(manifest.route_surfaces.len(), 4);
    assert_eq!(manifest.http_surfaces.len(), 4);
    assert_eq!(manifest.jobs.len(), 4);
    assert_eq!(manifest.event_subscriptions.len(), 3);
    assert_eq!(manifest.admin_resources.len(), 4);
    assert_eq!(manifest.report_definitions.len(), 2);
    assert_eq!(manifest.bulk_operations.len(), 2);
    assert!(
        manifest
            .core_service_dependencies
            .contains(&CoreServiceDependency::Storage)
    );
    assert!(
        manifest
            .extension_slots
            .iter()
            .any(|slot| slot.kind == ExtensionSlotKind::Job)
    );
    assert_eq!(
        module
            .install_migration_plan()
            .expect("ops migration plan")
            .ordered_steps()
            .len(),
        3
    );

    let mut registry = ServiceRegistry::new();
    module.register(&mut registry).expect("module register");
    assert_eq!(registry.services().count(), 5);
    assert_eq!(registry.modules().count(), 0);
}