Skip to main content

coil_customer_sdk/
lib.rs

1#![forbid(unsafe_code)]
2
3mod error;
4mod facade;
5mod hooks;
6mod registry;
7mod types;
8
9pub use error::*;
10pub use facade::*;
11pub use hooks::*;
12pub use registry::*;
13pub use types::*;
14
15#[cfg(test)]
16mod tests {
17    use super::*;
18    use std::collections::BTreeMap;
19    use std::sync::{Arc, Mutex};
20
21    #[derive(Default)]
22    struct RecordingRegistry {
23        hook_kinds: Vec<RegisteredHookKind>,
24    }
25
26    struct RecordingRepository {
27        read_results: Vec<RepositoryRecordSet>,
28        writes: Arc<Mutex<Vec<RepositoryWrite>>>,
29    }
30
31    impl CustomerHookRegistry for RecordingRegistry {
32        fn register_checkout_hooks(
33            &mut self,
34            _hooks: Arc<dyn CheckoutHooks>,
35        ) -> Result<(), BackendError> {
36            self.hook_kinds.push(RegisteredHookKind::Checkout);
37            Ok(())
38        }
39
40        fn register_cms_hooks(&mut self, _hooks: Arc<dyn CmsHooks>) -> Result<(), BackendError> {
41            self.hook_kinds.push(RegisteredHookKind::CmsPagePublish);
42            Ok(())
43        }
44
45        fn register_verified_webhook_hooks(
46            &mut self,
47            _hooks: Arc<dyn VerifiedWebhookHooks>,
48        ) -> Result<(), BackendError> {
49            self.hook_kinds.push(RegisteredHookKind::VerifiedWebhook);
50            Ok(())
51        }
52
53        fn register_verified_webhook_asset_hooks(
54            &mut self,
55            _hooks: Arc<dyn VerifiedWebhookAssetHooks>,
56        ) -> Result<(), BackendError> {
57            self.hook_kinds
58                .push(RegisteredHookKind::VerifiedWebhookAssets);
59            Ok(())
60        }
61    }
62
63    impl RepositoryFacade for RecordingRepository {
64        fn read(&self, query: &RepositoryQuery) -> Result<RepositoryRecordSet, BackendError> {
65            self.read_results
66                .iter()
67                .find(|result| result.repository == query.repository)
68                .cloned()
69                .ok_or_else(|| {
70                    BackendError::new(
71                        BackendErrorKind::Unsupported,
72                        "repository.test.unconfigured",
73                        "test repository did not provide a matching read result",
74                    )
75                })
76        }
77
78        fn write(&self, _change: RepositoryWrite) -> Result<RepositoryWriteReceipt, BackendError> {
79            let change = _change;
80            self.writes.lock().unwrap().push(change.clone());
81            Ok(RepositoryWriteReceipt {
82                repository: change.repository,
83                record_id: change.record_id,
84                version: Some("test-version".to_string()),
85            })
86        }
87    }
88
89    struct ExamplePlugin;
90
91    impl CustomerBackendPlugin for ExamplePlugin {
92        fn descriptor(&self) -> CustomerPluginDescriptor {
93            CustomerPluginDescriptor::new("shoppr-backend", "Shoppr Backend", "0.1.0")
94        }
95
96        fn register(&self, registry: &mut dyn CustomerHookRegistry) -> Result<(), BackendError> {
97            let hooks = Arc::new(ExampleHooks);
98            registry.register_checkout_hooks(hooks.clone())?;
99            registry.register_cms_hooks(hooks.clone())?;
100            registry.register_verified_webhook_hooks(hooks)?;
101            Ok(())
102        }
103    }
104
105    struct ExampleHooks;
106
107    impl CheckoutHooks for ExampleHooks {
108        fn review_order(
109            &self,
110            _ctx: &RequestContext,
111            _order: &OrderDraft,
112            _commerce: &dyn CommerceFacade,
113            _auth: &dyn AuthFacade,
114            _audit: &dyn AuditFacade,
115        ) -> Result<OrderReviewDecision, BackendError> {
116            Ok(OrderReviewDecision::approved())
117        }
118    }
119
120    impl CmsHooks for ExampleHooks {
121        fn validate_page_publish(
122            &self,
123            _ctx: &RequestContext,
124            _draft: &CmsPageDraft,
125            _repositories: &dyn RepositoryFacade,
126            _audit: &dyn AuditFacade,
127        ) -> Result<CmsPublishDecision, BackendError> {
128            Ok(CmsPublishDecision::Allow)
129        }
130    }
131
132    impl VerifiedWebhookHooks for ExampleHooks {
133        fn handle_verified_webhook(
134            &self,
135            _ctx: &RequestContext,
136            _webhook: &VerifiedWebhook,
137            _http: &dyn OutboundHttpFacade,
138            _jobs: &dyn JobsFacade,
139            _repositories: &dyn RepositoryFacade,
140            _audit: &dyn AuditFacade,
141        ) -> Result<WebhookHandlingResult, BackendError> {
142            Ok(WebhookHandlingResult::accepted(None))
143        }
144    }
145
146    impl VerifiedWebhookAssetHooks for ExampleHooks {
147        fn handle_verified_webhook(
148            &self,
149            _ctx: &RequestContext,
150            _webhook: &VerifiedWebhook,
151            _http: &dyn OutboundHttpFacade,
152            _jobs: &dyn JobsFacade,
153            _repositories: &dyn RepositoryFacade,
154            _audit: &dyn AuditFacade,
155            _assets: &dyn AssetsFacade,
156        ) -> Result<WebhookHandlingResult, BackendError> {
157            Ok(WebhookHandlingResult::accepted(None))
158        }
159    }
160
161    #[test]
162    fn customer_plugin_registers_explicit_hook_kinds() {
163        let plugin = ExamplePlugin;
164        let mut registry = RecordingRegistry::default();
165
166        plugin.register(&mut registry).unwrap();
167
168        assert_eq!(
169            registry.hook_kinds,
170            vec![
171                RegisteredHookKind::Checkout,
172                RegisteredHookKind::CmsPagePublish,
173                RegisteredHookKind::VerifiedWebhook,
174            ]
175        );
176    }
177
178    #[test]
179    fn order_review_decision_helpers_build_stable_variants() {
180        assert_eq!(
181            OrderReviewDecision::approved(),
182            OrderReviewDecision::Approved
183        );
184        assert_eq!(
185            OrderReviewDecision::rejected("checkout.policy", "blocked"),
186            OrderReviewDecision::Rejected(OrderRejection::new("checkout.policy", "blocked"))
187        );
188    }
189
190    #[test]
191    fn registry_can_record_asset_capable_verified_webhook_hooks() {
192        let hooks = Arc::new(ExampleHooks);
193        let mut registry = RecordingRegistry::default();
194
195        registry
196            .register_verified_webhook_asset_hooks(hooks)
197            .unwrap();
198
199        assert_eq!(
200            registry.hook_kinds,
201            vec![RegisteredHookKind::VerifiedWebhookAssets]
202        );
203    }
204
205    #[test]
206    fn repository_facade_ext_parses_typed_catalog_and_order_records() {
207        let repository = RecordingRepository {
208            read_results: vec![
209                RepositoryRecordSet {
210                    repository: CmsPageRecord::REPOSITORY.to_string(),
211                    records: vec![RepositoryRecord {
212                        id: "page-membership-guide".to_string(),
213                        fields: BTreeMap::from([
214                            ("title".to_string(), "Membership guide".to_string()),
215                            ("slug".to_string(), "membership-guide".to_string()),
216                            ("summary".to_string(), "How activation works".to_string()),
217                            ("body_html".to_string(), "<p>Body</p>".to_string()),
218                            ("status".to_string(), "draft".to_string()),
219                            (
220                                "live_path".to_string(),
221                                "/pages/membership-guide".to_string(),
222                            ),
223                        ]),
224                    }],
225                },
226                RepositoryRecordSet {
227                    repository: CmsNavigationRecord::REPOSITORY.to_string(),
228                    records: vec![RepositoryRecord {
229                        id: "0".to_string(),
230                        fields: BTreeMap::from([
231                            ("label".to_string(), "Home".to_string()),
232                            ("href".to_string(), "/".to_string()),
233                        ]),
234                    }],
235                },
236                RepositoryRecordSet {
237                    repository: CmsRedirectRecord::REPOSITORY.to_string(),
238                    records: vec![RepositoryRecord {
239                        id: "1".to_string(),
240                        fields: BTreeMap::from([
241                            ("from".to_string(), "/legacy".to_string()),
242                            ("to".to_string(), "/pages/membership-guide".to_string()),
243                            ("permanent".to_string(), "true".to_string()),
244                        ]),
245                    }],
246                },
247                RepositoryRecordSet {
248                    repository: CommerceCatalogProductRecord::REPOSITORY.to_string(),
249                    records: vec![RepositoryRecord {
250                        id: "gold-membership".to_string(),
251                        fields: BTreeMap::from([
252                            ("handle".to_string(), "gold-membership".to_string()),
253                            ("sku".to_string(), "membership-gold".to_string()),
254                            ("title".to_string(), "Gold Membership".to_string()),
255                            ("summary".to_string(), "Recurring access".to_string()),
256                            ("price_minor".to_string(), "12900".to_string()),
257                            ("currency".to_string(), "GBP".to_string()),
258                            ("collection_handle".to_string(), "memberships".to_string()),
259                            ("is_visible".to_string(), "true".to_string()),
260                            ("product_kind".to_string(), "membership".to_string()),
261                            ("entitlement_key".to_string(), "membership.gold".to_string()),
262                        ]),
263                    }],
264                },
265                RepositoryRecordSet {
266                    repository: CommerceCatalogCollectionRecord::REPOSITORY.to_string(),
267                    records: vec![RepositoryRecord {
268                        id: "memberships".to_string(),
269                        fields: BTreeMap::from([
270                            ("handle".to_string(), "memberships".to_string()),
271                            ("title".to_string(), "Memberships".to_string()),
272                            ("label".to_string(), "Recurring value".to_string()),
273                            (
274                                "summary".to_string(),
275                                "Benefits and premium access".to_string(),
276                            ),
277                            ("is_visible".to_string(), "true".to_string()),
278                        ]),
279                    }],
280                },
281                RepositoryRecordSet {
282                    repository: CommerceOrderRecord::REPOSITORY.to_string(),
283                    records: vec![RepositoryRecord {
284                        id: "ORD-10042".to_string(),
285                        fields: BTreeMap::from([
286                            ("status".to_string(), "paid".to_string()),
287                            ("payment_status".to_string(), "captured".to_string()),
288                            ("payment_reference".to_string(), "PAY-50001".to_string()),
289                            ("payment_method".to_string(), "card".to_string()),
290                            (
291                                "checkout_email".to_string(),
292                                "buyer@example.com".to_string(),
293                            ),
294                            ("principal_id".to_string(), "member-live-1".to_string()),
295                            ("currency".to_string(), "GBP".to_string()),
296                            ("total_minor".to_string(), "12900".to_string()),
297                            ("line_count".to_string(), "1".to_string()),
298                        ]),
299                    }],
300                },
301            ],
302            writes: Arc::new(Mutex::new(Vec::new())),
303        };
304
305        let page = repository
306            .cms_page("page-membership-guide")
307            .unwrap()
308            .unwrap();
309        assert_eq!(page.page_id, "page-membership-guide");
310        assert_eq!(page.slug, "membership-guide");
311        assert_eq!(page.live_path.as_deref(), Some("/pages/membership-guide"));
312
313        let navigation = repository.cms_navigation_items().unwrap();
314        assert_eq!(navigation.len(), 1);
315        assert_eq!(navigation[0].record_id, 0);
316        assert_eq!(navigation[0].href, "/");
317
318        let redirects = repository.cms_redirects().unwrap();
319        assert_eq!(redirects.len(), 1);
320        assert_eq!(redirects[0].record_id, 1);
321        assert!(redirects[0].permanent);
322
323        let product = repository
324            .commerce_catalog_product("gold-membership")
325            .unwrap()
326            .unwrap();
327        assert_eq!(product.handle, "gold-membership");
328        assert_eq!(product.sku, "membership-gold");
329        assert_eq!(product.price_minor, 12_900);
330
331        let collection = repository
332            .commerce_catalog_collection("memberships")
333            .unwrap()
334            .unwrap();
335        assert_eq!(collection.handle, "memberships");
336        assert!(collection.is_visible);
337
338        let order = repository
339            .commerce_order_by_payment_reference("PAY-50001")
340            .unwrap()
341            .unwrap();
342        assert_eq!(order.order_id, "ORD-10042");
343        assert_eq!(order.payment_status, "captured");
344        assert_eq!(order.total_minor, 12_900);
345
346        repository
347            .update_cms_page(&CmsPageUpdate::new(
348                "page-membership-guide",
349                "Updated title",
350                "membership-guide",
351                "Updated summary",
352                "<p>Updated body</p>",
353            ))
354            .unwrap();
355        repository
356            .append_cms_navigation_item(&CmsNavigationAppend::new(
357                "Shipping",
358                "/pages/shipping-returns",
359            ))
360            .unwrap();
361        repository
362            .append_cms_redirect(&CmsRedirectAppend::new(
363                "/legacy/shipping",
364                "/pages/shipping-returns",
365                true,
366            ))
367            .unwrap();
368
369        let writes = repository.writes.lock().unwrap().clone();
370        assert_eq!(writes.len(), 3);
371        assert_eq!(writes[0].repository, "cms.pages");
372        assert_eq!(writes[0].record_id, "page-membership-guide");
373        assert_eq!(
374            writes[0].fields.get("title").map(String::as_str),
375            Some("Updated title")
376        );
377        assert_eq!(writes[1].repository, "cms.navigation");
378        assert_eq!(writes[1].record_id, "append");
379        assert_eq!(
380            writes[1].fields.get("href").map(String::as_str),
381            Some("/pages/shipping-returns")
382        );
383        assert_eq!(writes[2].repository, "cms.redirects");
384        assert_eq!(writes[2].record_id, "append");
385        assert_eq!(
386            writes[2].fields.get("permanent").map(String::as_str),
387            Some("true")
388        );
389    }
390}