coil-customer-sdk 0.1.1

Stable customer extension interfaces for the Coil framework.
Documentation
#![forbid(unsafe_code)]

mod error;
mod facade;
mod hooks;
mod registry;
mod types;

pub use error::*;
pub use facade::*;
pub use hooks::*;
pub use registry::*;
pub use types::*;

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    use std::sync::{Arc, Mutex};

    #[derive(Default)]
    struct RecordingRegistry {
        hook_kinds: Vec<RegisteredHookKind>,
    }

    struct RecordingRepository {
        read_results: Vec<RepositoryRecordSet>,
        writes: Arc<Mutex<Vec<RepositoryWrite>>>,
    }

    impl CustomerHookRegistry for RecordingRegistry {
        fn register_checkout_hooks(
            &mut self,
            _hooks: Arc<dyn CheckoutHooks>,
        ) -> Result<(), BackendError> {
            self.hook_kinds.push(RegisteredHookKind::Checkout);
            Ok(())
        }

        fn register_cms_hooks(&mut self, _hooks: Arc<dyn CmsHooks>) -> Result<(), BackendError> {
            self.hook_kinds.push(RegisteredHookKind::CmsPagePublish);
            Ok(())
        }

        fn register_verified_webhook_hooks(
            &mut self,
            _hooks: Arc<dyn VerifiedWebhookHooks>,
        ) -> Result<(), BackendError> {
            self.hook_kinds.push(RegisteredHookKind::VerifiedWebhook);
            Ok(())
        }

        fn register_verified_webhook_asset_hooks(
            &mut self,
            _hooks: Arc<dyn VerifiedWebhookAssetHooks>,
        ) -> Result<(), BackendError> {
            self.hook_kinds
                .push(RegisteredHookKind::VerifiedWebhookAssets);
            Ok(())
        }
    }

    impl RepositoryFacade for RecordingRepository {
        fn read(&self, query: &RepositoryQuery) -> Result<RepositoryRecordSet, BackendError> {
            self.read_results
                .iter()
                .find(|result| result.repository == query.repository)
                .cloned()
                .ok_or_else(|| {
                    BackendError::new(
                        BackendErrorKind::Unsupported,
                        "repository.test.unconfigured",
                        "test repository did not provide a matching read result",
                    )
                })
        }

        fn write(&self, _change: RepositoryWrite) -> Result<RepositoryWriteReceipt, BackendError> {
            let change = _change;
            self.writes.lock().unwrap().push(change.clone());
            Ok(RepositoryWriteReceipt {
                repository: change.repository,
                record_id: change.record_id,
                version: Some("test-version".to_string()),
            })
        }
    }

    struct ExamplePlugin;

    impl CustomerBackendPlugin for ExamplePlugin {
        fn descriptor(&self) -> CustomerPluginDescriptor {
            CustomerPluginDescriptor::new("shoppr-backend", "Shoppr Backend", "0.1.0")
        }

        fn register(&self, registry: &mut dyn CustomerHookRegistry) -> Result<(), BackendError> {
            let hooks = Arc::new(ExampleHooks);
            registry.register_checkout_hooks(hooks.clone())?;
            registry.register_cms_hooks(hooks.clone())?;
            registry.register_verified_webhook_hooks(hooks)?;
            Ok(())
        }
    }

    struct ExampleHooks;

    impl CheckoutHooks for ExampleHooks {
        fn review_order(
            &self,
            _ctx: &RequestContext,
            _order: &OrderDraft,
            _commerce: &dyn CommerceFacade,
            _auth: &dyn AuthFacade,
            _audit: &dyn AuditFacade,
        ) -> Result<OrderReviewDecision, BackendError> {
            Ok(OrderReviewDecision::approved())
        }
    }

    impl CmsHooks for ExampleHooks {
        fn validate_page_publish(
            &self,
            _ctx: &RequestContext,
            _draft: &CmsPageDraft,
            _repositories: &dyn RepositoryFacade,
            _audit: &dyn AuditFacade,
        ) -> Result<CmsPublishDecision, BackendError> {
            Ok(CmsPublishDecision::Allow)
        }
    }

    impl VerifiedWebhookHooks for ExampleHooks {
        fn handle_verified_webhook(
            &self,
            _ctx: &RequestContext,
            _webhook: &VerifiedWebhook,
            _http: &dyn OutboundHttpFacade,
            _jobs: &dyn JobsFacade,
            _repositories: &dyn RepositoryFacade,
            _audit: &dyn AuditFacade,
        ) -> Result<WebhookHandlingResult, BackendError> {
            Ok(WebhookHandlingResult::accepted(None))
        }
    }

    impl VerifiedWebhookAssetHooks for ExampleHooks {
        fn handle_verified_webhook(
            &self,
            _ctx: &RequestContext,
            _webhook: &VerifiedWebhook,
            _http: &dyn OutboundHttpFacade,
            _jobs: &dyn JobsFacade,
            _repositories: &dyn RepositoryFacade,
            _audit: &dyn AuditFacade,
            _assets: &dyn AssetsFacade,
        ) -> Result<WebhookHandlingResult, BackendError> {
            Ok(WebhookHandlingResult::accepted(None))
        }
    }

    #[test]
    fn customer_plugin_registers_explicit_hook_kinds() {
        let plugin = ExamplePlugin;
        let mut registry = RecordingRegistry::default();

        plugin.register(&mut registry).unwrap();

        assert_eq!(
            registry.hook_kinds,
            vec![
                RegisteredHookKind::Checkout,
                RegisteredHookKind::CmsPagePublish,
                RegisteredHookKind::VerifiedWebhook,
            ]
        );
    }

    #[test]
    fn order_review_decision_helpers_build_stable_variants() {
        assert_eq!(
            OrderReviewDecision::approved(),
            OrderReviewDecision::Approved
        );
        assert_eq!(
            OrderReviewDecision::rejected("checkout.policy", "blocked"),
            OrderReviewDecision::Rejected(OrderRejection::new("checkout.policy", "blocked"))
        );
    }

    #[test]
    fn registry_can_record_asset_capable_verified_webhook_hooks() {
        let hooks = Arc::new(ExampleHooks);
        let mut registry = RecordingRegistry::default();

        registry
            .register_verified_webhook_asset_hooks(hooks)
            .unwrap();

        assert_eq!(
            registry.hook_kinds,
            vec![RegisteredHookKind::VerifiedWebhookAssets]
        );
    }

    #[test]
    fn repository_facade_ext_parses_typed_catalog_and_order_records() {
        let repository = RecordingRepository {
            read_results: vec![
                RepositoryRecordSet {
                    repository: CmsPageRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "page-membership-guide".to_string(),
                        fields: BTreeMap::from([
                            ("title".to_string(), "Membership guide".to_string()),
                            ("slug".to_string(), "membership-guide".to_string()),
                            ("summary".to_string(), "How activation works".to_string()),
                            ("body_html".to_string(), "<p>Body</p>".to_string()),
                            ("status".to_string(), "draft".to_string()),
                            (
                                "live_path".to_string(),
                                "/pages/membership-guide".to_string(),
                            ),
                        ]),
                    }],
                },
                RepositoryRecordSet {
                    repository: CmsNavigationRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "0".to_string(),
                        fields: BTreeMap::from([
                            ("label".to_string(), "Home".to_string()),
                            ("href".to_string(), "/".to_string()),
                        ]),
                    }],
                },
                RepositoryRecordSet {
                    repository: CmsRedirectRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "1".to_string(),
                        fields: BTreeMap::from([
                            ("from".to_string(), "/legacy".to_string()),
                            ("to".to_string(), "/pages/membership-guide".to_string()),
                            ("permanent".to_string(), "true".to_string()),
                        ]),
                    }],
                },
                RepositoryRecordSet {
                    repository: CommerceCatalogProductRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "gold-membership".to_string(),
                        fields: BTreeMap::from([
                            ("handle".to_string(), "gold-membership".to_string()),
                            ("sku".to_string(), "membership-gold".to_string()),
                            ("title".to_string(), "Gold Membership".to_string()),
                            ("summary".to_string(), "Recurring access".to_string()),
                            ("price_minor".to_string(), "12900".to_string()),
                            ("currency".to_string(), "GBP".to_string()),
                            ("collection_handle".to_string(), "memberships".to_string()),
                            ("is_visible".to_string(), "true".to_string()),
                            ("product_kind".to_string(), "membership".to_string()),
                            ("entitlement_key".to_string(), "membership.gold".to_string()),
                        ]),
                    }],
                },
                RepositoryRecordSet {
                    repository: CommerceCatalogCollectionRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "memberships".to_string(),
                        fields: BTreeMap::from([
                            ("handle".to_string(), "memberships".to_string()),
                            ("title".to_string(), "Memberships".to_string()),
                            ("label".to_string(), "Recurring value".to_string()),
                            (
                                "summary".to_string(),
                                "Benefits and premium access".to_string(),
                            ),
                            ("is_visible".to_string(), "true".to_string()),
                        ]),
                    }],
                },
                RepositoryRecordSet {
                    repository: CommerceOrderRecord::REPOSITORY.to_string(),
                    records: vec![RepositoryRecord {
                        id: "ORD-10042".to_string(),
                        fields: BTreeMap::from([
                            ("status".to_string(), "paid".to_string()),
                            ("payment_status".to_string(), "captured".to_string()),
                            ("payment_reference".to_string(), "PAY-50001".to_string()),
                            ("payment_method".to_string(), "card".to_string()),
                            (
                                "checkout_email".to_string(),
                                "buyer@example.com".to_string(),
                            ),
                            ("principal_id".to_string(), "member-live-1".to_string()),
                            ("currency".to_string(), "GBP".to_string()),
                            ("total_minor".to_string(), "12900".to_string()),
                            ("line_count".to_string(), "1".to_string()),
                        ]),
                    }],
                },
            ],
            writes: Arc::new(Mutex::new(Vec::new())),
        };

        let page = repository
            .cms_page("page-membership-guide")
            .unwrap()
            .unwrap();
        assert_eq!(page.page_id, "page-membership-guide");
        assert_eq!(page.slug, "membership-guide");
        assert_eq!(page.live_path.as_deref(), Some("/pages/membership-guide"));

        let navigation = repository.cms_navigation_items().unwrap();
        assert_eq!(navigation.len(), 1);
        assert_eq!(navigation[0].record_id, 0);
        assert_eq!(navigation[0].href, "/");

        let redirects = repository.cms_redirects().unwrap();
        assert_eq!(redirects.len(), 1);
        assert_eq!(redirects[0].record_id, 1);
        assert!(redirects[0].permanent);

        let product = repository
            .commerce_catalog_product("gold-membership")
            .unwrap()
            .unwrap();
        assert_eq!(product.handle, "gold-membership");
        assert_eq!(product.sku, "membership-gold");
        assert_eq!(product.price_minor, 12_900);

        let collection = repository
            .commerce_catalog_collection("memberships")
            .unwrap()
            .unwrap();
        assert_eq!(collection.handle, "memberships");
        assert!(collection.is_visible);

        let order = repository
            .commerce_order_by_payment_reference("PAY-50001")
            .unwrap()
            .unwrap();
        assert_eq!(order.order_id, "ORD-10042");
        assert_eq!(order.payment_status, "captured");
        assert_eq!(order.total_minor, 12_900);

        repository
            .update_cms_page(&CmsPageUpdate::new(
                "page-membership-guide",
                "Updated title",
                "membership-guide",
                "Updated summary",
                "<p>Updated body</p>",
            ))
            .unwrap();
        repository
            .append_cms_navigation_item(&CmsNavigationAppend::new(
                "Shipping",
                "/pages/shipping-returns",
            ))
            .unwrap();
        repository
            .append_cms_redirect(&CmsRedirectAppend::new(
                "/legacy/shipping",
                "/pages/shipping-returns",
                true,
            ))
            .unwrap();

        let writes = repository.writes.lock().unwrap().clone();
        assert_eq!(writes.len(), 3);
        assert_eq!(writes[0].repository, "cms.pages");
        assert_eq!(writes[0].record_id, "page-membership-guide");
        assert_eq!(
            writes[0].fields.get("title").map(String::as_str),
            Some("Updated title")
        );
        assert_eq!(writes[1].repository, "cms.navigation");
        assert_eq!(writes[1].record_id, "append");
        assert_eq!(
            writes[1].fields.get("href").map(String::as_str),
            Some("/pages/shipping-returns")
        );
        assert_eq!(writes[2].repository, "cms.redirects");
        assert_eq!(writes[2].record_id, "append");
        assert_eq!(
            writes[2].fields.get("permanent").map(String::as_str),
            Some("true")
        );
    }
}