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}