Skip to main content

backbone_core/
service.rs

1//! Generic CRUD service — one implementation used by all entities.
2//!
3//! Generated code emits only a type alias:
4//!
5//! ```rust,ignore
6//! // Generated (was ~250 lines, now 1 line):
7//! pub type StoredFileService = GenericCrudService<
8//!     StoredFile,
9//!     CreateStoredFileDto,
10//!     UpdateStoredFileDto,
11//!     PostgresStoredFileRepository,
12//! >;
13//! ```
14//!
15//! Custom services wrap the alias with a decorator:
16//!
17//! ```rust,ignore
18//! pub struct StoredFileServiceCustom {
19//!     inner: Arc<StoredFileService>,
20//!     notifications: Arc<NotificationService>,
21//! }
22//!
23//! impl StoredFileServiceCustom {
24//!     pub async fn archive(&self, id: &str, reason: String) -> ServiceResult<StoredFile> {
25//!         let entity = self.inner.get_by_id(id).await?.ok_or(ServiceError::NotFound)?;
26//!         // custom business logic
27//!     }
28//! }
29//! ```
30
31use async_trait::async_trait;
32use std::collections::HashMap;
33use std::marker::PhantomData;
34use std::sync::Arc;
35
36use backbone_messaging::crud_event::{CrudEvent, CrudEventPublisher, EventMetadata, NoOpCrudEventPublisher};
37
38use crate::persistence::traits::{CrudRepository, PersistentEntity, RepositoryError};
39
40// ─── Service error ───────────────────────────────────────────────────────────
41
42/// Standard service-layer error type.
43#[derive(Debug, thiserror::Error)]
44pub enum ServiceError {
45    #[error("not found")]
46    NotFound,
47
48    #[error("already exists: {0}")]
49    AlreadyExists(String),
50
51    #[error("validation failed: {0}")]
52    Validation(String),
53
54    /// The write broke named rules: one violation per rule, each with the
55    /// field it concerns and a stable code. Displays as the same
56    /// `validation failed: …` sentence a plain validation error does.
57    #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
58    Violations(Vec<crate::violation::Violation>),
59
60    #[error("repository error: {0}")]
61    Repository(#[from] RepositoryError),
62
63    #[error("internal error: {0}")]
64    Internal(String),
65}
66
67pub type ServiceResult<T> = Result<T, ServiceError>;
68
69// ─── DTO conversion traits ────────────────────────────────────────────────────
70
71/// Convert a Create DTO into a new entity instance.
72///
73/// Implemented once per entity by generated code, may be overridden in custom
74/// decorators via `UseCaseHooks`.
75pub trait FromCreateDto<DTO>: Sized {
76    fn from_create_dto(dto: DTO) -> ServiceResult<Self>;
77}
78
79/// Apply an Update DTO's fields to an existing entity (full update).
80pub trait ApplyUpdateDto<DTO> {
81    fn apply_update(self, dto: DTO) -> ServiceResult<Self>
82    where
83        Self: Sized;
84}
85
86// ─── ServiceLifecycle hooks ──────────────────────────────────────────────────
87
88/// Optional lifecycle callbacks injected into `GenericCrudService`.
89///
90/// Override individual methods — all default to no-op.
91#[async_trait]
92pub trait ServiceLifecycle<E: PersistentEntity>: Send + Sync {
93    async fn before_create(&self, entity: &mut E) -> ServiceResult<()> {
94        let _ = entity;
95        Ok(())
96    }
97
98    async fn after_create(&self, entity: &E) -> ServiceResult<()> {
99        let _ = entity;
100        Ok(())
101    }
102
103    async fn before_update(&self, entity: &mut E) -> ServiceResult<()> {
104        let _ = entity;
105        Ok(())
106    }
107
108    async fn after_update(&self, entity: &E) -> ServiceResult<()> {
109        let _ = entity;
110        Ok(())
111    }
112
113    async fn before_delete(&self, entity: &E) -> ServiceResult<()> {
114        let _ = entity;
115        Ok(())
116    }
117
118    async fn after_delete(&self, id: &str) -> ServiceResult<()> {
119        let _ = id;
120        Ok(())
121    }
122
123    /// Called before a soft-deleted entity is restored, singly or in a batch.
124    async fn before_restore(&self, entity: &E) -> ServiceResult<()> {
125        let _ = entity;
126        Ok(())
127    }
128
129    /// Called before a soft-deleted entity is removed for good, singly or in
130    /// a batch.
131    async fn before_hard_delete(&self, entity: &E) -> ServiceResult<()> {
132        let _ = entity;
133        Ok(())
134    }
135
136    /// Called once before the whole trash is restored. The rows are not
137    /// loaded for this; refuse here to keep the bulk path closed.
138    async fn before_restore_all(&self) -> ServiceResult<()> {
139        Ok(())
140    }
141
142    /// Called once before the whole trash is removed for good. The rows are
143    /// not loaded for this; refuse here to keep the bulk path closed.
144    async fn before_empty_trash(&self) -> ServiceResult<()> {
145        Ok(())
146    }
147}
148
149/// No-op lifecycle — the default used by generated type aliases.
150pub struct NoOpLifecycle<E> {
151    _phantom: PhantomData<E>,
152}
153
154impl<E> NoOpLifecycle<E> {
155    pub fn new() -> Self {
156        Self {
157            _phantom: PhantomData,
158        }
159    }
160}
161
162impl<E> Default for NoOpLifecycle<E> {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[async_trait]
169impl<E: PersistentEntity> ServiceLifecycle<E> for NoOpLifecycle<E> {}
170
171// ─── GenericCrudService ──────────────────────────────────────────────────────
172
173/// A fully generic CRUD service.
174///
175/// Type parameters:
176/// - `E`  — entity type (must implement `PersistentEntity`)
177/// - `C`  — create DTO (entity implements `FromCreateDto<C>`)
178/// - `U`  — update DTO (entity implements `ApplyUpdateDto<U>`)
179/// - `R`  — repository (implements `CrudRepository<E>`)
180///
181/// The service always holds an `Arc<dyn CrudEventPublisher<E>>`.  When no
182/// publisher is needed, `NoOpCrudEventPublisher` is used (zero overhead).
183/// Event-publish errors are fire-and-forget — they never fail the operation.
184pub struct GenericCrudService<E, C, U, R>
185where
186    E: PersistentEntity + Clone,
187    R: CrudRepository<E>,
188{
189    repository: Arc<R>,
190    lifecycle: Arc<dyn ServiceLifecycle<E>>,
191    event_publisher: Arc<dyn CrudEventPublisher<E>>,
192    guard: Arc<dyn crate::write_guard::WriteGuard<E>>,
193    _phantom: PhantomData<(C, U)>,
194}
195
196impl<E, C, U, R> GenericCrudService<E, C, U, R>
197where
198    E: PersistentEntity + Clone + FromCreateDto<C> + ApplyUpdateDto<U>,
199    C: Send + Sync + 'static,
200    U: Send + Sync + 'static,
201    R: CrudRepository<E>,
202{
203    /// Full constructor — supply all three dependencies.
204    pub fn new(
205        repository: Arc<R>,
206        lifecycle: Arc<dyn ServiceLifecycle<E>>,
207        event_publisher: Arc<dyn CrudEventPublisher<E>>,
208    ) -> Self {
209        Self {
210            repository,
211            lifecycle,
212            event_publisher,
213            guard: Arc::new(crate::write_guard::AllowAll),
214            _phantom: PhantomData,
215        }
216    }
217
218    /// Check every write against `guard`: its refusals refuse the write, its
219    /// shadowed violations are logged and the write proceeds.
220    pub fn with_guard(mut self, guard: Arc<dyn crate::write_guard::WriteGuard<E>>) -> Self {
221        self.guard = guard;
222        self
223    }
224
225    /// Ask the guard about one write.
226    async fn consult(
227        &self,
228        kind: crate::write_guard::WriteKind,
229        before: Option<&E>,
230        after: Option<&E>,
231    ) -> ServiceResult<()> {
232        self.consult_as(kind, before, after, true).await
233    }
234
235    /// [`consult`](Self::consult) for a dry run: the same refusals, without
236    /// logging what a shadow rule would refuse — the real write logs that, and a
237    /// preview before it would count every shadow violation twice.
238    async fn consult_quietly(
239        &self,
240        kind: crate::write_guard::WriteKind,
241        before: Option<&E>,
242        after: Option<&E>,
243    ) -> ServiceResult<()> {
244        self.consult_as(kind, before, after, false).await
245    }
246
247    async fn consult_as(
248        &self,
249        kind: crate::write_guard::WriteKind,
250        before: Option<&E>,
251        after: Option<&E>,
252        log_shadow: bool,
253    ) -> ServiceResult<()> {
254        let ctx = crate::write_guard::WriteCtx::new(kind, before, after);
255        let outcome = self.guard.check(&ctx).await;
256        for v in outcome.shadow.iter().filter(|_| log_shadow) {
257            tracing::warn!(
258                target: "backbone::write_guard",
259                entity = std::any::type_name::<E>(),
260                kind = ?kind,
261                code = %v.code,
262                path = %v.path,
263                "a rule running in shadow would refuse this write: {}",
264                v.message
265            );
266        }
267        if outcome.refuse.is_empty() {
268            Ok(())
269        } else {
270            Err(ServiceError::Violations(outcome.refuse))
271        }
272    }
273
274    /// Create with a custom lifecycle and the no-op event publisher.
275    pub fn with_lifecycle(repository: Arc<R>, lifecycle: Arc<dyn ServiceLifecycle<E>>) -> Self {
276        Self::new(repository, lifecycle, NoOpCrudEventPublisher::arc())
277    }
278
279    /// Create with the no-op lifecycle and no-op event publisher (generated default).
280    pub fn with_repository(repository: Arc<R>) -> Self {
281        Self::new(
282            repository,
283            Arc::new(NoOpLifecycle::new()),
284            NoOpCrudEventPublisher::arc(),
285        )
286    }
287
288    /// Replace the event publisher on an existing service (builder pattern).
289    pub fn with_event_publisher(mut self, publisher: Arc<dyn CrudEventPublisher<E>>) -> Self {
290        self.event_publisher = publisher;
291        self
292    }
293
294    /// Access the underlying repository for custom query extensions.
295    pub fn repository(&self) -> &R {
296        &self.repository
297    }
298
299    // ─── Individual operations ────────────────────────────────────────────
300
301    pub async fn list(
302        &self,
303        page: u32,
304        limit: u32,
305        filters: HashMap<String, String>,
306    ) -> ServiceResult<(Vec<E>, u64)> {
307        self.repository
308            .list_filtered(page, limit, filters)
309            .await
310            .map_err(ServiceError::Repository)
311    }
312
313    /// `list`, carrying the pagination info (cursor positions included) so
314    /// the HTTP layer can surface keyset paging on every generic route.
315    pub async fn list_with_info(
316        &self,
317        page: u32,
318        limit: u32,
319        filters: HashMap<String, String>,
320    ) -> ServiceResult<(Vec<E>, backbone_orm::repository::PaginationInfo)> {
321        self.repository
322            .list_filtered_with_info(page, limit, filters)
323            .await
324            .map_err(ServiceError::Repository)
325    }
326
327    /// Group and reduce the rows `list` would return, under the same filters.
328    pub async fn aggregate(
329        &self,
330        spec: &backbone_orm::repository::AggregateSpec,
331        filters: HashMap<String, String>,
332    ) -> ServiceResult<backbone_orm::repository::AggregateResult> {
333        self.repository
334            .aggregate_filtered(spec, filters)
335            .await
336            .map_err(ServiceError::Repository)
337    }
338
339    pub async fn create(&self, dto: C) -> ServiceResult<E> {
340        let mut entity = E::from_create_dto(dto)?;
341        self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity)).await?;
342        self.lifecycle.before_create(&mut entity).await?;
343        let saved = self
344            .repository
345            .create(entity)
346            .await
347            .map_err(ServiceError::Repository)?;
348        self.lifecycle.after_create(&saved).await?;
349
350        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
351        let _ = self
352            .event_publisher
353            .publish(CrudEvent::Created { entity: saved.clone(), metadata: meta })
354            .await;
355
356        Ok(saved)
357    }
358
359    pub async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
360        self.repository
361            .find_by_id(id)
362            .await
363            .map_err(ServiceError::Repository)
364    }
365
366    pub async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
367        let existing = self
368            .repository
369            .find_by_id(id)
370            .await
371            .map_err(ServiceError::Repository)?;
372        let Some(before) = existing else {
373            return Ok(None);
374        };
375        let mut updated = before.clone().apply_update(dto)?;
376        refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
377        self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
378        self.lifecycle.before_update(&mut updated).await?;
379        // The same changed-fields, live-row-only save as `partial_update`.
380        let Some(saved) = self
381            .repository
382            .update_changed(&before, updated)
383            .await
384            .map_err(ServiceError::Repository)?
385        else {
386            return Ok(None);
387        };
388        self.lifecycle.after_update(&saved).await?;
389
390        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
391        let _ = self
392            .event_publisher
393            .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
394            .await;
395
396        Ok(Some(saved))
397    }
398
399    pub async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
400        let existing = self
401            .repository
402            .find_by_id(id)
403            .await
404            .map_err(ServiceError::Repository)?;
405        let Some(entity) = existing else {
406            return Ok(false);
407        };
408        self.consult(crate::write_guard::WriteKind::Delete, Some(&entity), None).await?;
409        self.lifecycle.before_delete(&entity).await?;
410        let deleted = self
411            .repository
412            .soft_delete(id)
413            .await
414            .map_err(ServiceError::Repository)?;
415        self.lifecycle.after_delete(id).await?;
416
417        if deleted {
418            let meta = EventMetadata::new(id, std::any::type_name::<E>());
419            let _ = self
420                .event_publisher
421                .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
422                .await;
423        }
424
425        Ok(deleted)
426    }
427
428    pub async fn restore(&self, id: &str) -> ServiceResult<Option<E>> {
429        let Some(trashed) = self.get_deleted_by_id(id).await? else {
430            return Ok(None);
431        };
432        self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
433        self.lifecycle.before_restore(&trashed).await?;
434        let restored = self
435            .repository
436            .restore(id)
437            .await
438            .map_err(ServiceError::Repository)?;
439
440        if let Some(ref entity) = restored {
441            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
442            let _ = self
443                .event_publisher
444                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
445                .await;
446        }
447
448        Ok(restored)
449    }
450
451    pub async fn hard_delete(&self, id: &str) -> ServiceResult<bool> {
452        let Some(entity) = self.get_deleted_by_id(id).await? else {
453            return Ok(false);
454        };
455        self.consult(crate::write_guard::WriteKind::HardDelete, Some(&entity), None).await?;
456        self.lifecycle.before_hard_delete(&entity).await?;
457        let deleted = self
458            .repository
459            .hard_delete(id)
460            .await
461            .map_err(ServiceError::Repository)?;
462
463        if deleted {
464            let meta = EventMetadata::new(id, std::any::type_name::<E>());
465            let _ = self
466                .event_publisher
467                .publish(CrudEvent::HardDeleted {
468                    entity_id: id.to_string(),
469                    metadata: meta,
470                })
471                .await;
472        }
473
474        Ok(deleted)
475    }
476
477    pub async fn list_deleted(
478        &self,
479        page: u32,
480        limit: u32,
481    ) -> ServiceResult<(Vec<E>, u64)> {
482        self.repository
483            .list_deleted(page, limit)
484            .await
485            .map_err(ServiceError::Repository)
486    }
487
488    /// Permanently delete everything in the trash (atomic).
489    ///
490    /// The trashed rows are loaded first, at most [`MAX_BATCH_SIZE`] of them (a larger trash is
491    /// refused: empty it in batches by id), and the write guard and lifecycle are asked about
492    /// each one as a single permanent delete would. The delete then names exactly those ids and
493    /// rolls back unless every one is still in the trash, so a row trashed after the check is
494    /// never deleted unchecked.
495    pub async fn empty_trash(&self) -> ServiceResult<u64> {
496        self.lifecycle.before_empty_trash().await?;
497        let trashed = self.whole_trash("emptied").await?;
498        if trashed.is_empty() {
499            return Ok(0);
500        }
501        for entity in &trashed {
502            self.consult(crate::write_guard::WriteKind::HardDelete, Some(entity), None).await?;
503            self.lifecycle.before_hard_delete(entity).await?;
504        }
505        let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
506        let affected = self
507            .repository
508            .bulk_hard_delete(&ids)
509            .await
510            .map_err(ServiceError::Repository)?;
511        for id in &ids {
512            let meta = EventMetadata::new(id, std::any::type_name::<E>());
513            let _ = self
514                .event_publisher
515                .publish(CrudEvent::HardDeleted {
516                    entity_id: id.to_string(),
517                    metadata: meta,
518                })
519                .await;
520        }
521        Ok(affected)
522    }
523
524    /// Every row in the trash, refused when there are more than one batch may hold.
525    async fn whole_trash(&self, verb: &str) -> ServiceResult<Vec<E>> {
526        let (rows, total) = self
527            .repository
528            .list_deleted(1, MAX_BATCH_SIZE as u32)
529            .await
530            .map_err(ServiceError::Repository)?;
531        if total > MAX_BATCH_SIZE as u64 || rows.len() > MAX_BATCH_SIZE {
532            return Err(ServiceError::Validation(format!(
533                "The trash holds {total} items, more than the {MAX_BATCH_SIZE} that can be {verb} \
534                 at once. Select the items to act on instead."
535            )));
536        }
537        Ok(rows)
538    }
539
540    pub async fn count(&self) -> ServiceResult<u64> {
541        self.repository
542            .count()
543            .await
544            .map_err(ServiceError::Repository)
545    }
546
547    pub async fn count_active(&self) -> ServiceResult<u64> {
548        self.repository
549            .count()
550            .await
551            .map_err(ServiceError::Repository)
552    }
553
554    /// Alias for `get_by_id` — used by generated handlers.
555    pub async fn find_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
556        self.get_by_id(id).await
557    }
558
559    /// Alias for `hard_delete` — used by generated handlers.
560    pub async fn permanent_delete(&self, id: &str) -> ServiceResult<bool> {
561        self.hard_delete(id).await
562    }
563
564    /// Count soft-deleted entities.
565    pub async fn count_deleted(&self) -> ServiceResult<u64> {
566        self.repository
567            .count_deleted()
568            .await
569            .map_err(ServiceError::Repository)
570    }
571
572    /// Retrieve a soft-deleted entity by ID (includes deleted records).
573    pub async fn get_deleted_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
574        self.repository
575            .find_by_id_including_deleted(id)
576            .await
577            .map_err(ServiceError::Repository)
578    }
579
580    /// List deleted entities with the same filters and search the active list takes.
581    pub async fn list_deleted_filtered(
582        &self,
583        page: u32,
584        limit: u32,
585        filters: HashMap<String, String>,
586    ) -> ServiceResult<(Vec<E>, u64)> {
587        self.repository
588            .list_deleted_filtered(page, limit, filters)
589            .await
590            .map_err(ServiceError::Repository)
591    }
592
593    /// Upsert is not available on the generic service, and refuses rather than guessing.
594    ///
595    /// An upsert must match the record it would update, by id or by a natural key. A create
596    /// DTO carries no id and the framework knows no natural key for an entity, so the only thing
597    /// this could do is create — which is what it used to do, silently duplicating the record on
598    /// every retry. The generic HTTP route answers 501 for the same reason. An entity with a real
599    /// upsert implements it in its own service.
600    pub async fn upsert(&self, dto: C) -> ServiceResult<E> {
601        let _ = dto;
602        Err(ServiceError::Validation(UPSERT_UNSUPPORTED.to_string()))
603    }
604
605    /// Apply a partial update from a JSON map of field values.
606    ///
607    /// Fields not present in the map are left unchanged. A key the entity does
608    /// not have, or a new value for a protected field, refuses the whole write.
609    pub async fn partial_update(
610        &self,
611        id: &str,
612        fields: HashMap<String, serde_json::Value>,
613    ) -> ServiceResult<Option<E>>
614    where
615        E: serde::Serialize + serde::de::DeserializeOwned,
616    {
617        let existing = self
618            .repository
619            .find_by_id(id)
620            .await
621            .map_err(ServiceError::Repository)?;
622        let Some(before) = existing else {
623            return Ok(None);
624        };
625        let mut patched = merge_patch(&before, fields)?;
626        self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
627        self.lifecycle.before_update(&mut patched).await?;
628        // Only the fields this write changed are saved, and only while the row is still live:
629        // see `CrudRepository::update_changed`. A row deleted since the read answers not found.
630        let Some(saved) = self
631            .repository
632            .update_changed(&before, patched)
633            .await
634            .map_err(ServiceError::Repository)?
635        else {
636            return Ok(None);
637        };
638        self.lifecycle.after_update(&saved).await?;
639
640        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
641        let _ = self
642            .event_publisher
643            .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
644            .await;
645
646        Ok(Some(saved))
647    }
648
649    /// Create many entities, all or nothing.
650    ///
651    /// Each item goes through what a single create does — the DTO conversion, the write guard,
652    /// the `before_create` hook — before anything is written; then the repository inserts them
653    /// in one transaction. A failure anywhere fails the whole batch and names the item: a
654    /// violation's path starts with the item's index (`3/name`).
655    pub async fn bulk_create(&self, dtos: Vec<C>) -> ServiceResult<Vec<E>>
656    where
657        E: FromCreateDto<C>,
658    {
659        check_batch_size(dtos.len())?;
660        let mut prepared = Vec::with_capacity(dtos.len());
661        for (index, dto) in dtos.into_iter().enumerate() {
662            let at = |e: ServiceError| at_batch_index(e, index);
663            let mut entity = E::from_create_dto(dto).map_err(at)?;
664            self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity))
665                .await
666                .map_err(at)?;
667            self.lifecycle.before_create(&mut entity).await.map_err(at)?;
668            prepared.push(entity);
669        }
670        let saved = self
671            .repository
672            .bulk_create(prepared)
673            .await
674            .map_err(ServiceError::Repository)?;
675        for entity in &saved {
676            self.lifecycle.after_create(entity).await?;
677            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
678            let _ = self
679                .event_publisher
680                .publish(CrudEvent::Created { entity: entity.clone(), metadata: meta })
681                .await;
682        }
683        Ok(saved)
684    }
685
686    // ── Atomic batch operations ───────────────────────────────────────────────
687    //
688    // All-or-nothing: ids are validated up-front (so a missing id fails the whole
689    // request before any write), then the repository applies the change inside a
690    // single transaction. Lifecycle hooks and CRUD events fire per affected
691    // entity, mirroring the single-row methods. Events are best-effort.
692
693    /// Soft-delete many entities by id (atomic).
694    pub async fn bulk_soft_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
695        check_batch_size(ids.len())?;
696        let ids = dedup_ids(ids);
697        // Load the entities up-front: they are the payloads for the lifecycle
698        // `before_delete` hook and the `SoftDeleted` event below. The repository's
699        // atomic affected-row check (not this loop) is what guarantees the write
700        // is all-or-nothing.
701        let mut entities = Vec::with_capacity(ids.len());
702        for id in &ids {
703            match self
704                .repository
705                .find_by_id(id)
706                .await
707                .map_err(ServiceError::Repository)?
708            {
709                Some(e) => entities.push(e),
710                None => return Err(ServiceError::Validation(format!("id '{id}' not found"))),
711            }
712        }
713        for entity in &entities {
714            self.consult(crate::write_guard::WriteKind::Delete, Some(entity), None).await?;
715            self.lifecycle.before_delete(entity).await?;
716        }
717        let affected = self
718            .repository
719            .bulk_soft_delete(&ids)
720            .await
721            .map_err(ServiceError::Repository)?;
722        for (id, entity) in ids.iter().zip(entities.into_iter()) {
723            self.lifecycle.after_delete(id).await?;
724            let meta = EventMetadata::new(id, std::any::type_name::<E>());
725            let _ = self
726                .event_publisher
727                .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
728                .await;
729        }
730        Ok(affected)
731    }
732
733    /// Restore many soft-deleted entities by id (atomic).
734    pub async fn bulk_restore(&self, ids: Vec<String>) -> ServiceResult<Vec<E>> {
735        check_batch_size(ids.len())?;
736        let ids = dedup_ids(ids);
737        // Ask the lifecycle about every row it can see; an id that is not in
738        // the trash is left to the repository's atomic check below.
739        for id in &ids {
740            if let Some(trashed) = self.get_deleted_by_id(id).await? {
741                self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
742                self.lifecycle.before_restore(&trashed).await?;
743            }
744        }
745        let restored = self
746            .repository
747            .bulk_restore(&ids)
748            .await
749            .map_err(ServiceError::Repository)?;
750        for entity in &restored {
751            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
752            let _ = self
753                .event_publisher
754                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
755                .await;
756        }
757        Ok(restored)
758    }
759
760    /// Restore every soft-deleted entity (atomic). Returns the number restored.
761    /// Emits a `Restored` event per entity, mirroring [`bulk_restore`](Self::bulk_restore).
762    ///
763    /// Like [`empty_trash`](Self::empty_trash), the trashed rows are loaded first (at most
764    /// [`MAX_BATCH_SIZE`]), each is put to the write guard and lifecycle as a single restore
765    /// would be, and the restore names exactly those ids, all or nothing.
766    pub async fn restore_all(&self) -> ServiceResult<u64> {
767        self.lifecycle.before_restore_all().await?;
768        let trashed = self.whole_trash("restored").await?;
769        if trashed.is_empty() {
770            return Ok(0);
771        }
772        for entity in &trashed {
773            self.consult(crate::write_guard::WriteKind::Restore, Some(entity), None).await?;
774            self.lifecycle.before_restore(entity).await?;
775        }
776        let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
777        let restored = self
778            .repository
779            .bulk_restore(&ids)
780            .await
781            .map_err(ServiceError::Repository)?;
782        for entity in &restored {
783            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
784            let _ = self
785                .event_publisher
786                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
787                .await;
788        }
789        Ok(restored.len() as u64)
790    }
791
792    /// Permanently delete many soft-deleted entities by id (atomic).
793    pub async fn bulk_permanent_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
794        check_batch_size(ids.len())?;
795        let ids = dedup_ids(ids);
796        // The lifecycle is asked about every row it can see. Whether every id
797        // is in the trash stays with `bulk_hard_delete`, which deletes only
798        // trashed rows and rolls the whole batch back unless every id matched,
799        // so a row that moves between this loop and the write cannot slip
800        // through.
801        for id in &ids {
802            if let Some(trashed) = self.get_deleted_by_id(id).await? {
803                self.consult(crate::write_guard::WriteKind::HardDelete, Some(&trashed), None).await?;
804                self.lifecycle.before_hard_delete(&trashed).await?;
805            }
806        }
807        let affected = self
808            .repository
809            .bulk_hard_delete(&ids)
810            .await
811            .map_err(ServiceError::Repository)?;
812        for id in &ids {
813            let meta = EventMetadata::new(id, std::any::type_name::<E>());
814            let _ = self
815                .event_publisher
816                .publish(CrudEvent::HardDeleted {
817                    entity_id: id.to_string(),
818                    metadata: meta,
819                })
820                .await;
821        }
822        Ok(affected)
823    }
824
825    /// Full-update many entities (atomic). Each item is `(id, UpdateDto)`.
826    pub async fn bulk_update(&self, items: Vec<(String, U)>) -> ServiceResult<Vec<E>> {
827        check_batch_size(items.len())?;
828        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
829            return Err(ServiceError::Validation(format!(
830                "duplicate id '{dup}' in bulk update"
831            )));
832        }
833        let mut befores = Vec::with_capacity(items.len());
834        let mut prepared = Vec::with_capacity(items.len());
835        for (id, dto) in items {
836            let Some(before) = self
837                .repository
838                .find_by_id(&id)
839                .await
840                .map_err(ServiceError::Repository)?
841            else {
842                return Err(ServiceError::Validation(format!("id '{id}' not found")));
843            };
844            let mut updated = before.clone().apply_update(dto)?;
845            refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
846            self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
847            self.lifecycle.before_update(&mut updated).await?;
848            prepared.push((before.clone(), updated));
849            befores.push(before);
850        }
851        // Only the fields each item changed are written, as a single PUT does, so a column a
852        // concurrent verb moved is not written back with the value read before it.
853        let saved = self
854            .repository
855            .bulk_update_changed(prepared)
856            .await
857            .map_err(ServiceError::Repository)?;
858        self.publish_bulk_updates(befores, &saved).await?;
859        Ok(saved)
860    }
861
862    /// Fire `after_update` and a `Updated` event for each `(before, after)` pair.
863    /// Shared by [`bulk_update`](Self::bulk_update) and
864    /// [`bulk_partial_update`](Self::bulk_partial_update); events are best-effort.
865    async fn publish_bulk_updates(&self, befores: Vec<E>, saved: &[E]) -> ServiceResult<()> {
866        for (before, after) in befores.into_iter().zip(saved.iter()) {
867            self.lifecycle.after_update(after).await?;
868            let meta = EventMetadata::new(after.entity_id(), std::any::type_name::<E>());
869            let _ = self
870                .event_publisher
871                .publish(CrudEvent::Updated {
872                    before,
873                    after: after.clone(),
874                    metadata: meta,
875                })
876                .await;
877        }
878        Ok(())
879    }
880
881    /// Partial-update many entities (atomic). Each item is `(id, field_map)`.
882    pub async fn bulk_partial_update(
883        &self,
884        items: Vec<(String, HashMap<String, serde_json::Value>)>,
885    ) -> ServiceResult<Vec<E>>
886    where
887        E: serde::Serialize + serde::de::DeserializeOwned,
888    {
889        check_batch_size(items.len())?;
890        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
891            return Err(ServiceError::Validation(format!(
892                "duplicate id '{dup}' in bulk partial update"
893            )));
894        }
895        let mut befores = Vec::with_capacity(items.len());
896        let mut prepared = Vec::with_capacity(items.len());
897        for (id, fields) in items {
898            let Some(before) = self
899                .repository
900                .find_by_id(&id)
901                .await
902                .map_err(ServiceError::Repository)?
903            else {
904                return Err(ServiceError::Validation(format!("id '{id}' not found")));
905            };
906            let mut patched = merge_patch(&before, fields)?;
907            self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
908            self.lifecycle.before_update(&mut patched).await?;
909            prepared.push((before.clone(), patched));
910            befores.push(before);
911        }
912        let saved = self
913            .repository
914            .bulk_update_changed(prepared)
915            .await
916            .map_err(ServiceError::Repository)?;
917        self.publish_bulk_updates(befores, &saved).await?;
918        Ok(saved)
919    }
920
921    /// What [`bulk_partial_update`](Self::bulk_partial_update) would do, with
922    /// nothing written. Each row goes through the same steps the write takes —
923    /// it is found, the patch is merged and checked against the protected fields,
924    /// the write rules are consulted (quietly: a shadow rule logs on the real
925    /// write, not here) and the update hook runs — and its outcome is
926    /// kept instead of failing the batch, so one dialog can say which rows would
927    /// change and which would be refused. Only a batch that is too large or names
928    /// an id twice fails as a whole, as the write does.
929    pub async fn preview_bulk_partial_update(
930        &self,
931        items: Vec<(String, HashMap<String, serde_json::Value>)>,
932    ) -> ServiceResult<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>
933    where
934        E: serde::Serialize + serde::de::DeserializeOwned,
935    {
936        check_batch_size(items.len())?;
937        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
938            return Err(ServiceError::Validation(format!(
939                "duplicate id '{dup}' in bulk partial update"
940            )));
941        }
942        let mut rows = Vec::with_capacity(items.len());
943        for (id, fields) in items {
944            let outcome = self.preview_one(&id, fields).await;
945            rows.push(crate::http::BulkPreviewRow { id, outcome });
946        }
947        Ok(rows)
948    }
949
950    async fn preview_one(
951        &self,
952        id: &str,
953        fields: HashMap<String, serde_json::Value>,
954    ) -> ServiceResult<(E, E)>
955    where
956        E: serde::Serialize + serde::de::DeserializeOwned,
957    {
958        let Some(before) = self
959            .repository
960            .find_by_id(id)
961            .await
962            .map_err(ServiceError::Repository)?
963        else {
964            return Err(ServiceError::Validation(format!("id '{id}' not found")));
965        };
966        let mut patched = merge_patch(&before, fields)?;
967        self.consult_quietly(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched))
968            .await?;
969        self.lifecycle.before_update(&mut patched).await?;
970        Ok((before, patched))
971    }
972}
973
974// ─── Generic write protection ────────────────────────────────────────────────
975
976/// Serialized fields no generic write may change on any entity: the row's
977/// identity, and the audit block that records who created, changed and
978/// deleted it. Entities add their own through
979/// [`PersistentEntity::write_protected_fields`].
980pub const ALWAYS_WRITE_PROTECTED: &[&str] = &["id", "metadata"];
981
982fn as_object(entity: &impl serde::Serialize) -> ServiceResult<serde_json::Map<String, serde_json::Value>> {
983    match serde_json::to_value(entity).map_err(|e| ServiceError::Internal(e.to_string()))? {
984        serde_json::Value::Object(map) => Ok(map),
985        _ => Err(ServiceError::Internal("an entity must serialize to a JSON object".into())),
986    }
987}
988
989/// Refuse a generic write that gives a protected field a new value. Carrying
990/// the stored value unchanged is allowed, so a form that sends the whole
991/// record is not refused for fields it did not touch.
992///
993/// A state machine's verb writes through the same path, inside
994/// [`as_transition`](crate::write_guard::as_transition), and its whole point is
995/// to move the state field the entity protects from generic writes. Inside a
996/// transition the entity's [`PersistentEntity::state_fields`] are therefore let
997/// through — those and nothing else; the write guard still decides whether the
998/// move itself is allowed.
999fn refuse_protected_changes<E: PersistentEntity>(
1000    before: &serde_json::Map<String, serde_json::Value>,
1001    after: &serde_json::Map<String, serde_json::Value>,
1002) -> ServiceResult<()> {
1003    let in_transition = crate::write_guard::current_transition().is_some();
1004    let refused: Vec<crate::violation::Violation> = ALWAYS_WRITE_PROTECTED
1005        .iter()
1006        .chain(E::write_protected_fields())
1007        .filter(|key| !(in_transition && E::state_fields().contains(*key)))
1008        .filter(|key| before.get(**key) != after.get(**key))
1009        .map(|key| {
1010            crate::violation::Violation::new(
1011                *key,
1012                "field_not_writable",
1013                format!(
1014                    "field_not_writable: `{key}` cannot be changed by a generic write; \
1015                     it changes only through the operation that owns it"
1016                ),
1017            )
1018        })
1019        .collect();
1020    if refused.is_empty() {
1021        Ok(())
1022    } else {
1023        Err(ServiceError::Violations(refused))
1024    }
1025}
1026
1027/// Merge a partial update onto an entity.
1028///
1029/// The merge goes through the entity's JSON form, so a key the entity has no
1030/// field for would otherwise be dropped without a word and the edit lost. It
1031/// is refused instead: a key given a value that does not survive the round
1032/// trip is not a field of this record. A key set to `null` is exempt, since
1033/// an empty optional field may be left out of the JSON form entirely.
1034fn merge_patch<E: PersistentEntity>(
1035    before: &E,
1036    fields: HashMap<String, serde_json::Value>,
1037) -> ServiceResult<E> {
1038    let before_obj = as_object(before)?;
1039    let mut merged = before_obj.clone();
1040    let mut given = Vec::new();
1041    for (key, value) in fields {
1042        if !value.is_null() {
1043            given.push(key.clone());
1044        }
1045        merged.insert(key, value);
1046    }
1047    let patched: E = serde_json::from_value(serde_json::Value::Object(merged))
1048        .map_err(|e| ServiceError::Validation(e.to_string()))?;
1049    let after_obj = as_object(&patched)?;
1050
1051    let mut unknown: Vec<String> = given.into_iter().filter(|k| !after_obj.contains_key(k)).collect();
1052    if !unknown.is_empty() {
1053        unknown.sort();
1054        return Err(ServiceError::Violations(
1055            unknown
1056                .into_iter()
1057                .map(|k| {
1058                    let message = format!("unknown_field: `{k}` is not a field of this record");
1059                    crate::violation::Violation::new(k, "unknown_field", message)
1060                })
1061                .collect(),
1062        ));
1063    }
1064    refuse_protected_changes::<E>(&before_obj, &after_obj)?;
1065    Ok(patched)
1066}
1067
1068/// Maximum number of ids / items a single batch operation may contain. Enforced
1069/// here at the service layer (not only in the HTTP handlers) so every caller —
1070/// gRPC, background jobs, internal code — gets the same bound.
1071pub const MAX_BATCH_SIZE: usize = 1000;
1072
1073/// Reject an over-sized batch before any work is done.
1074fn check_batch_size(count: usize) -> Result<(), ServiceError> {
1075    if count > MAX_BATCH_SIZE {
1076        return Err(ServiceError::Validation(format!(
1077            "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1078        )));
1079    }
1080    Ok(())
1081}
1082
1083/// The refusal [`GenericCrudService::upsert`] answers with.
1084pub const UPSERT_UNSUPPORTED: &str =
1085    "upsert is not available for this resource: a create body names no record to match. \
1086     Create the record, or update it by id.";
1087
1088/// Place a service error on item `index` of a batch (see
1089/// [`RepositoryError::at_batch_index`]).
1090fn at_batch_index(err: ServiceError, index: usize) -> ServiceError {
1091    let item = |message: String| format!("item {index}: {message}");
1092    match err {
1093        ServiceError::Violations(violations) => ServiceError::Violations(
1094            violations
1095                .into_iter()
1096                .map(|mut v| {
1097                    v.path = crate::violation::batch_item_path(index, &v.path);
1098                    v
1099                })
1100                .collect(),
1101        ),
1102        ServiceError::Repository(e) => ServiceError::Repository(e.at_batch_index(index)),
1103        ServiceError::Validation(m) => ServiceError::Validation(item(m)),
1104        ServiceError::AlreadyExists(m) => ServiceError::AlreadyExists(item(m)),
1105        ServiceError::Internal(m) => ServiceError::Internal(item(m)),
1106        ServiceError::NotFound => ServiceError::Validation(item("not found".to_string())),
1107    }
1108}
1109
1110/// Drop duplicate ids while preserving first-seen order. Id-list batch ops issue
1111/// a SQL `IN (...)` whose distinct-row count would never match a list that
1112/// repeats an id, so a stray duplicate would otherwise fail the whole batch.
1113fn dedup_ids(ids: Vec<String>) -> Vec<String> {
1114    let mut seen = std::collections::HashSet::new();
1115    ids.into_iter().filter(|id| seen.insert(id.clone())).collect()
1116}
1117
1118/// Return the first id that appears more than once, if any. Used to reject
1119/// ambiguous bulk updates (the same id mapped to two different payloads).
1120fn first_duplicate_id(ids: impl IntoIterator<Item = String>) -> Option<String> {
1121    let mut seen = std::collections::HashSet::new();
1122    for id in ids {
1123        if !seen.insert(id.clone()) {
1124            return Some(id);
1125        }
1126    }
1127    None
1128}
1129
1130// ─── Blanket CrudService impl ────────────────────────────────────────────────
1131
1132/// Blanket impl so that any `GenericCrudService<E, C, U, R>` automatically
1133/// satisfies `CrudService<E, C, U>` without generated adapter structs.
1134///
1135/// Generated code emits only a type alias:
1136///   `pub type UserService = GenericCrudService<User, CreateUserDto, UpdateUserDto, UserRepository>;`
1137///
1138/// That alias now directly implements `CrudService` and can be passed to
1139/// `BackboneCrudHandler` without any additional boilerplate.
1140#[async_trait::async_trait]
1141impl<E, C, U, R> crate::http::CrudService<E, C, U> for GenericCrudService<E, C, U, R>
1142where
1143    E: PersistentEntity
1144        + Clone
1145        + serde::Serialize
1146        + serde::de::DeserializeOwned
1147        + FromCreateDto<C>
1148        + ApplyUpdateDto<U>
1149        + Send
1150        + Sync
1151        + 'static,
1152    C: Send + Sync + 'static,
1153    U: Send + Sync + 'static,
1154    R: CrudRepository<E> + Send + Sync + 'static,
1155{
1156    type Error = ServiceError;
1157
1158    fn violations_of(err: &ServiceError) -> Option<Vec<crate::violation::Violation>> {
1159        match err {
1160            ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => Some(v.clone()),
1161            _ => None,
1162        }
1163    }
1164
1165    fn status_of(err: &ServiceError) -> Option<axum::http::StatusCode> {
1166        Some(crate::http::service_error_status(err))
1167    }
1168
1169    fn supports_upsert() -> bool {
1170        false
1171    }
1172
1173    fn entity_name() -> &'static str {
1174        std::any::type_name::<E>()
1175    }
1176
1177    async fn fetch_related_json(&self, table: &str, ids: &[String]) -> Vec<serde_json::Value> {
1178        self.repository().fetch_related_json(table, ids).await
1179    }
1180
1181    async fn list(
1182        &self,
1183        page: u32,
1184        limit: u32,
1185        filters: HashMap<String, String>,
1186    ) -> Result<(Vec<E>, u64), ServiceError> {
1187        self.list(page, limit, filters).await
1188    }
1189
1190    async fn list_with_info(
1191        &self,
1192        page: u32,
1193        limit: u32,
1194        filters: HashMap<String, String>,
1195    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), ServiceError> {
1196        self.list_with_info(page, limit, filters).await
1197    }
1198
1199    async fn aggregate(
1200        &self,
1201        spec: &backbone_orm::repository::AggregateSpec,
1202        filters: HashMap<String, String>,
1203    ) -> Result<backbone_orm::repository::AggregateResult, ServiceError> {
1204        self.aggregate(spec, filters).await
1205    }
1206
1207    fn table_name(&self) -> Option<&str> {
1208        self.repository.table_name()
1209    }
1210
1211    async fn create(&self, dto: C) -> Result<E, ServiceError> {
1212        self.create(dto).await
1213    }
1214
1215    async fn get_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1216        self.get_by_id(id).await
1217    }
1218
1219    async fn update(&self, id: &str, dto: U) -> Result<Option<E>, ServiceError> {
1220        self.update(id, dto).await
1221    }
1222
1223    async fn partial_update(
1224        &self,
1225        id: &str,
1226        fields: HashMap<String, serde_json::Value>,
1227    ) -> Result<Option<E>, ServiceError> {
1228        self.partial_update(id, fields).await
1229    }
1230
1231    async fn soft_delete(&self, id: &str) -> Result<bool, ServiceError> {
1232        self.soft_delete(id).await
1233    }
1234
1235    async fn bulk_create(&self, items: Vec<C>) -> Result<Vec<E>, ServiceError> {
1236        self.bulk_create(items).await
1237    }
1238
1239    async fn upsert(&self, dto: C) -> Result<E, ServiceError> {
1240        self.upsert(dto).await
1241    }
1242
1243    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), ServiceError> {
1244        self.list_deleted(page, limit).await
1245    }
1246
1247    async fn restore(&self, id: &str) -> Result<Option<E>, ServiceError> {
1248        self.restore(id).await
1249    }
1250
1251    async fn empty_trash(&self) -> Result<u64, ServiceError> {
1252        self.empty_trash().await
1253    }
1254
1255    async fn get_deleted_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1256        self.get_deleted_by_id(id).await
1257    }
1258
1259    async fn permanent_delete(&self, id: &str) -> Result<bool, ServiceError> {
1260        self.permanent_delete(id).await
1261    }
1262
1263    async fn list_deleted_filtered(
1264        &self,
1265        page: u32,
1266        limit: u32,
1267        filters: HashMap<String, String>,
1268    ) -> Result<(Vec<E>, u64), ServiceError> {
1269        self.list_deleted_filtered(page, limit, filters).await
1270    }
1271
1272    async fn count_active(&self) -> Result<u64, ServiceError> {
1273        self.count_active().await
1274    }
1275
1276    async fn count_deleted(&self) -> Result<u64, ServiceError> {
1277        self.count_deleted().await
1278    }
1279
1280    async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1281        self.bulk_soft_delete(ids).await
1282    }
1283
1284    async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<E>, ServiceError> {
1285        self.bulk_restore(ids).await
1286    }
1287
1288    async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1289        self.bulk_permanent_delete(ids).await
1290    }
1291
1292    async fn restore_all(&self) -> Result<u64, ServiceError> {
1293        self.restore_all().await
1294    }
1295
1296    async fn bulk_update(&self, items: Vec<(String, U)>) -> Result<Vec<E>, ServiceError> {
1297        self.bulk_update(items).await
1298    }
1299
1300    async fn bulk_partial_update(
1301        &self,
1302        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1303    ) -> Result<Vec<E>, ServiceError> {
1304        self.bulk_partial_update(items).await
1305    }
1306
1307    async fn preview_bulk_partial_update(
1308        &self,
1309        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1310    ) -> Result<Option<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>, ServiceError> {
1311        self.preview_bulk_partial_update(items).await.map(Some)
1312    }
1313}
1314
1315// Make the service cloneable (needed when Arc-ing it and sharing across handlers).
1316impl<E, C, U, R> Clone for GenericCrudService<E, C, U, R>
1317where
1318    E: PersistentEntity + Clone,
1319    R: CrudRepository<E>,
1320{
1321    fn clone(&self) -> Self {
1322        Self {
1323            repository: self.repository.clone(),
1324            lifecycle: self.lifecycle.clone(),
1325            event_publisher: self.event_publisher.clone(),
1326            guard: self.guard.clone(),
1327            _phantom: PhantomData,
1328        }
1329    }
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335    use chrono::{DateTime, Utc};
1336    use serde::{Deserialize, Serialize};
1337
1338    // ── Minimal in-memory repository for testing ──────────────────────────
1339
1340    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1341    struct Widget {
1342        id: String,
1343        name: String,
1344        #[serde(skip_serializing_if = "Option::is_none")]
1345        note: Option<String>,
1346        secret_hash: String,
1347        /// A state-machine field: protected from generic writes, moved by verbs.
1348        #[serde(default)]
1349        state: String,
1350        #[serde(skip_serializing_if = "Option::is_none")]
1351        deleted_at: Option<DateTime<Utc>>,
1352        created_at: DateTime<Utc>,
1353        updated_at: DateTime<Utc>,
1354    }
1355
1356    impl crate::persistence::traits::PersistentEntity for Widget {
1357        fn write_protected_fields() -> &'static [&'static str] {
1358            &["secret_hash", "state"]
1359        }
1360        fn state_fields() -> &'static [&'static str] {
1361            &["state"]
1362        }
1363        fn entity_id(&self) -> String {
1364            self.id.clone()
1365        }
1366        fn set_entity_id(&mut self, id: String) {
1367            self.id = id;
1368        }
1369        fn created_at(&self) -> Option<DateTime<Utc>> {
1370            Some(self.created_at)
1371        }
1372        fn set_created_at(&mut self, ts: DateTime<Utc>) {
1373            self.created_at = ts;
1374        }
1375        fn updated_at(&self) -> Option<DateTime<Utc>> {
1376            Some(self.updated_at)
1377        }
1378        fn set_updated_at(&mut self, ts: DateTime<Utc>) {
1379            self.updated_at = ts;
1380        }
1381        fn deleted_at(&self) -> Option<DateTime<Utc>> {
1382            self.deleted_at
1383        }
1384        fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
1385            self.deleted_at = ts;
1386        }
1387    }
1388
1389    #[derive(Deserialize)]
1390    struct CreateWidgetDto {
1391        name: String,
1392    }
1393
1394    #[derive(Deserialize)]
1395    struct UpdateWidgetDto {
1396        name: String,
1397    }
1398
1399    impl backbone_orm::EntityRepoMeta for Widget {
1400        fn column_types() -> HashMap<String, String> {
1401            HashMap::new()
1402        }
1403        fn search_fields() -> &'static [&'static str] {
1404            &["name"]
1405        }
1406    }
1407
1408    /// A row id whose lookup fails the way a broken database does.
1409    const BROKEN_ID: &str = "00000000-0000-0000-0000-00000000dead";
1410
1411    impl FromCreateDto<CreateWidgetDto> for Widget {
1412        fn from_create_dto(dto: CreateWidgetDto) -> ServiceResult<Self> {
1413            Ok(Widget {
1414                id: uuid::Uuid::new_v4().to_string(),
1415                name: dto.name,
1416                note: None,
1417                secret_hash: "h0".into(),
1418                state: "draft".into(),
1419                deleted_at: None,
1420                created_at: Utc::now(),
1421                updated_at: Utc::now(),
1422            })
1423        }
1424    }
1425
1426    impl ApplyUpdateDto<UpdateWidgetDto> for Widget {
1427        fn apply_update(mut self, dto: UpdateWidgetDto) -> ServiceResult<Self> {
1428            self.name = dto.name;
1429            self.updated_at = Utc::now();
1430            Ok(self)
1431        }
1432    }
1433
1434    // Minimal in-memory CrudRepository
1435    struct InMemoryWidgetRepo {
1436        data: tokio::sync::Mutex<Vec<Widget>>,
1437        /// Every `update_changed` call, as (before, after).
1438        changed_writes: std::sync::Mutex<Vec<(Widget, Widget)>>,
1439        /// Every full `update` call.
1440        full_writes: std::sync::atomic::AtomicUsize,
1441        /// Make the next changed-columns write find the row gone (a concurrent delete).
1442        vanish_on_write: std::sync::atomic::AtomicBool,
1443        /// Every `bulk_create` call.
1444        bulk_creates: std::sync::atomic::AtomicUsize,
1445    }
1446
1447    impl InMemoryWidgetRepo {
1448        fn new() -> Self {
1449            Self {
1450                data: tokio::sync::Mutex::new(Vec::new()),
1451                changed_writes: std::sync::Mutex::new(Vec::new()),
1452                full_writes: std::sync::atomic::AtomicUsize::new(0),
1453                vanish_on_write: std::sync::atomic::AtomicBool::new(false),
1454                bulk_creates: std::sync::atomic::AtomicUsize::new(0),
1455            }
1456        }
1457    }
1458
1459    #[async_trait]
1460    impl crate::persistence::traits::CrudRepository<Widget> for InMemoryWidgetRepo {
1461        async fn create(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1462            self.data.lock().await.push(entity.clone());
1463            Ok(entity)
1464        }
1465
1466        async fn find_by_id(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1467            if id == BROKEN_ID {
1468                return Err(RepositoryError::DatabaseError(
1469                    "relation \"secret_schema.widgets\" is broken".into(),
1470                ));
1471            }
1472            Ok(self
1473                .data
1474                .lock()
1475                .await
1476                .iter()
1477                .find(|w| w.id == id && w.deleted_at.is_none())
1478                .cloned())
1479        }
1480
1481        async fn update(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1482            self.full_writes
1483                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1484            let mut data = self.data.lock().await;
1485            data.retain(|w| w.id != entity.id);
1486            data.push(entity.clone());
1487            Ok(entity)
1488        }
1489
1490        async fn update_changed(
1491            &self,
1492            before: &Widget,
1493            after: Widget,
1494        ) -> Result<Option<Widget>, RepositoryError> {
1495            self.changed_writes
1496                .lock()
1497                .unwrap()
1498                .push((before.clone(), after.clone()));
1499            if self
1500                .vanish_on_write
1501                .load(std::sync::atomic::Ordering::SeqCst)
1502            {
1503                return Ok(None);
1504            }
1505            if after.name == "taken" {
1506                return Err(RepositoryError::AlreadyExists(
1507                    "duplicate key value violates unique constraint \"widgets_name_key\"".into(),
1508                ));
1509            }
1510            let mut data = self.data.lock().await;
1511            data.retain(|w| w.id != after.id);
1512            data.push(after.clone());
1513            Ok(Some(after))
1514        }
1515
1516        async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1517            let mut data = self.data.lock().await;
1518            if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1519                w.deleted_at = Some(Utc::now());
1520                return Ok(true);
1521            }
1522            Ok(false)
1523        }
1524
1525        async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1526            Ok(self.data.lock().await.iter().find(|w| w.id == id).cloned())
1527        }
1528
1529        async fn restore(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1530            let mut data = self.data.lock().await;
1531            if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1532                w.deleted_at = None;
1533                return Ok(Some(w.clone()));
1534            }
1535            Ok(None)
1536        }
1537
1538        async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1539            let mut data = self.data.lock().await;
1540            let before = data.len();
1541            data.retain(|w| w.id != id);
1542            Ok(data.len() < before)
1543        }
1544
1545        async fn list(
1546            &self,
1547            page: u32,
1548            limit: u32,
1549        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1550            let data = self.data.lock().await;
1551            let active: Vec<_> = data.iter().filter(|w| w.deleted_at.is_none()).cloned().collect();
1552            let total = active.len() as u64;
1553            let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1554            let limit = limit as usize;
1555            let page = active.into_iter().skip(offset).take(limit).collect();
1556            Ok((page, total))
1557        }
1558
1559        async fn list_deleted(
1560            &self,
1561            page: u32,
1562            limit: u32,
1563        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1564            let data = self.data.lock().await;
1565            let deleted: Vec<_> = data.iter().filter(|w| w.deleted_at.is_some()).cloned().collect();
1566            let total = deleted.len() as u64;
1567            let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1568            let limit = limit as usize;
1569            let page = deleted.into_iter().skip(offset).take(limit).collect();
1570            Ok((page, total))
1571        }
1572
1573        async fn count(&self) -> Result<u64, RepositoryError> {
1574            let data = self.data.lock().await;
1575            Ok(data.iter().filter(|w| w.deleted_at.is_none()).count() as u64)
1576        }
1577
1578        async fn count_deleted(&self) -> Result<u64, RepositoryError> {
1579            let data = self.data.lock().await;
1580            Ok(data.iter().filter(|w| w.deleted_at.is_some()).count() as u64)
1581        }
1582
1583        async fn list_deleted_filtered(
1584            &self,
1585            page: u32,
1586            limit: u32,
1587            filters: HashMap<String, String>,
1588        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1589            let (rows, _) = self.list_deleted(page, limit).await?;
1590            let rows: Vec<Widget> = rows
1591                .into_iter()
1592                .filter(|w| filters.get("name").is_none_or(|n| *n == w.name))
1593                .collect();
1594            let total = rows.len() as u64;
1595            Ok((rows, total))
1596        }
1597
1598        async fn bulk_create(&self, entities: Vec<Widget>) -> Result<Vec<Widget>, RepositoryError> {
1599            self.bulk_creates
1600                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1601            let mut data = self.data.lock().await;
1602            data.extend(entities.clone());
1603            Ok(entities)
1604        }
1605
1606        async fn empty_trash(&self) -> Result<u64, RepositoryError> {
1607            let mut data = self.data.lock().await;
1608            let before = data.len();
1609            data.retain(|w| w.deleted_at.is_none());
1610            Ok((before - data.len()) as u64)
1611        }
1612    }
1613
1614    #[tokio::test]
1615    async fn create_and_get_roundtrip() {
1616        let repo = Arc::new(InMemoryWidgetRepo::new());
1617        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1618            GenericCrudService::with_repository(repo);
1619
1620        let widget = service
1621            .create(CreateWidgetDto {
1622                name: "sprocket".into(),
1623            })
1624            .await
1625            .unwrap();
1626
1627        let found = service.get_by_id(&widget.id).await.unwrap();
1628        assert!(found.is_some());
1629        assert_eq!(found.unwrap().name, "sprocket");
1630    }
1631
1632    #[tokio::test]
1633    async fn soft_delete_hides_from_list() {
1634        let repo = Arc::new(InMemoryWidgetRepo::new());
1635        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1636            GenericCrudService::with_repository(repo);
1637
1638        let w = service.create(CreateWidgetDto { name: "w".into() }).await.unwrap();
1639        service.soft_delete(&w.id).await.unwrap();
1640
1641        let (items, _) = service.list(1, 20, Default::default()).await.unwrap();
1642        assert!(items.is_empty());
1643
1644        let (deleted, _) = service.list_deleted(1, 20).await.unwrap();
1645        assert_eq!(deleted.len(), 1);
1646    }
1647
1648    #[tokio::test]
1649    async fn update_publishes_event_and_returns_new_entity() {
1650        let repo = Arc::new(InMemoryWidgetRepo::new());
1651        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1652            GenericCrudService::with_repository(repo);
1653
1654        let w = service.create(CreateWidgetDto { name: "old".into() }).await.unwrap();
1655        let updated = service
1656            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1657            .await
1658            .unwrap();
1659        assert_eq!(updated.unwrap().name, "new");
1660    }
1661
1662    // ── Generic writes go through the changed-columns path ───────────────────
1663
1664    #[tokio::test]
1665    async fn a_patch_writes_only_through_the_changed_columns_path() {
1666        let repo = Arc::new(InMemoryWidgetRepo::new());
1667        let service: GenericCrudService<
1668            Widget,
1669            CreateWidgetDto,
1670            UpdateWidgetDto,
1671            InMemoryWidgetRepo,
1672        > = GenericCrudService::with_repository(repo.clone());
1673        let w = service
1674            .create(CreateWidgetDto { name: "old".into() })
1675            .await
1676            .unwrap();
1677
1678        let mut fields = HashMap::new();
1679        fields.insert("note".to_string(), serde_json::json!("edited"));
1680        let saved = service
1681            .partial_update(&w.id, fields)
1682            .await
1683            .unwrap()
1684            .unwrap();
1685        assert_eq!(saved.note.as_deref(), Some("edited"));
1686
1687        let writes = repo.changed_writes.lock().unwrap().clone();
1688        assert_eq!(writes.len(), 1, "a PATCH must save through update_changed");
1689        assert_eq!(writes[0].0, w, "the write starts from the row as read");
1690        assert_eq!(
1691            repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1692            0
1693        );
1694    }
1695
1696    #[tokio::test]
1697    async fn a_put_writes_only_through_the_changed_columns_path() {
1698        let repo = Arc::new(InMemoryWidgetRepo::new());
1699        let service: GenericCrudService<
1700            Widget,
1701            CreateWidgetDto,
1702            UpdateWidgetDto,
1703            InMemoryWidgetRepo,
1704        > = GenericCrudService::with_repository(repo.clone());
1705        let w = service
1706            .create(CreateWidgetDto { name: "old".into() })
1707            .await
1708            .unwrap();
1709        service
1710            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1711            .await
1712            .unwrap()
1713            .unwrap();
1714        assert_eq!(repo.changed_writes.lock().unwrap().len(), 1);
1715        assert_eq!(
1716            repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1717            0
1718        );
1719    }
1720
1721    #[tokio::test]
1722    async fn a_write_whose_row_vanished_meanwhile_answers_not_found() {
1723        let repo = Arc::new(InMemoryWidgetRepo::new());
1724        let service: GenericCrudService<
1725            Widget,
1726            CreateWidgetDto,
1727            UpdateWidgetDto,
1728            InMemoryWidgetRepo,
1729        > = GenericCrudService::with_repository(repo.clone());
1730        let w = service
1731            .create(CreateWidgetDto { name: "old".into() })
1732            .await
1733            .unwrap();
1734        repo.vanish_on_write
1735            .store(true, std::sync::atomic::Ordering::SeqCst);
1736
1737        let mut fields = HashMap::new();
1738        fields.insert("note".to_string(), serde_json::json!("edited"));
1739        assert!(service
1740            .partial_update(&w.id, fields)
1741            .await
1742            .unwrap()
1743            .is_none());
1744        assert!(service
1745            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1746            .await
1747            .unwrap()
1748            .is_none());
1749    }
1750
1751    // ── A transition may move the state field, and only that ─────────────────
1752
1753    #[tokio::test]
1754    async fn a_transition_moves_its_protected_state_field() {
1755        let service = svc();
1756        let w = service
1757            .create(CreateWidgetDto { name: "w".into() })
1758            .await
1759            .unwrap();
1760        let mut fields = HashMap::new();
1761        fields.insert("state".to_string(), serde_json::json!("active"));
1762        let saved =
1763            crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1764                .await
1765                .expect("a verb moves its own state field")
1766                .unwrap();
1767        assert_eq!(saved.state, "active");
1768    }
1769
1770    #[tokio::test]
1771    async fn a_transition_still_cannot_move_another_protected_field() {
1772        let service = svc();
1773        let w = service
1774            .create(CreateWidgetDto { name: "w".into() })
1775            .await
1776            .unwrap();
1777        let mut fields = HashMap::new();
1778        fields.insert("state".to_string(), serde_json::json!("active"));
1779        fields.insert("secret_hash".to_string(), serde_json::json!("h1"));
1780        let err =
1781            crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1782                .await
1783                .unwrap_err();
1784        match err {
1785            ServiceError::Violations(v) => {
1786                assert_eq!(v.len(), 1, "{v:?}");
1787                assert_eq!(
1788                    (v[0].path.as_str(), v[0].code.as_str()),
1789                    ("secret_hash", "field_not_writable")
1790                );
1791            }
1792            other => panic!("expected a violation, got {other:?}"),
1793        }
1794    }
1795
1796    #[tokio::test]
1797    async fn a_generic_write_still_cannot_move_the_state_field() {
1798        let service = svc();
1799        let w = service
1800            .create(CreateWidgetDto { name: "w".into() })
1801            .await
1802            .unwrap();
1803        let mut fields = HashMap::new();
1804        fields.insert("state".to_string(), serde_json::json!("active"));
1805        let err = service.partial_update(&w.id, fields).await.unwrap_err();
1806        assert!(
1807            matches!(err, ServiceError::Violations(ref v) if v[0].path == "state"),
1808            "{err:?}"
1809        );
1810    }
1811
1812    // ── Batch operations ──────────────────────────────────────────────────────
1813
1814    fn svc() -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
1815        GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()))
1816    }
1817
1818    #[tokio::test]
1819    async fn bulk_soft_delete_success() {
1820        let service = svc();
1821        let mut ids = Vec::new();
1822        for n in 0..3 {
1823            ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1824        }
1825        let n = service.bulk_soft_delete(ids).await.unwrap();
1826        assert_eq!(n, 3);
1827        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1828        assert!(active.is_empty());
1829    }
1830
1831    #[tokio::test]
1832    async fn bulk_soft_delete_is_all_or_nothing_on_missing_id() {
1833        let service = svc();
1834        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1835        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1836
1837        // One id is missing → whole batch must be rejected with nothing deleted.
1838        let res = service
1839            .bulk_soft_delete(vec![a.id.clone(), b.id.clone(), "does-not-exist".into()])
1840            .await;
1841        assert!(res.is_err());
1842
1843        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1844        assert_eq!(active.len(), 2, "no rows should have been deleted");
1845    }
1846
1847    #[tokio::test]
1848    async fn bulk_restore_and_restore_all() {
1849        let service = svc();
1850        let mut ids = Vec::new();
1851        for n in 0..3 {
1852            ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1853        }
1854        service.bulk_soft_delete(ids.clone()).await.unwrap();
1855
1856        // Restore two explicitly.
1857        let restored = service.bulk_restore(vec![ids[0].clone(), ids[1].clone()]).await.unwrap();
1858        assert_eq!(restored.len(), 2);
1859
1860        // Restore the remaining one via restore_all.
1861        let n = service.restore_all().await.unwrap();
1862        assert_eq!(n, 1);
1863
1864        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1865        assert_eq!(active.len(), 3);
1866    }
1867
1868    #[tokio::test]
1869    async fn bulk_update_is_all_or_nothing_on_missing_id() {
1870        let service = svc();
1871        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1872
1873        let res = service
1874            .bulk_update(vec![
1875                (a.id.clone(), UpdateWidgetDto { name: "A2".into() }),
1876                ("missing".into(), UpdateWidgetDto { name: "X".into() }),
1877            ])
1878            .await;
1879        assert!(res.is_err());
1880
1881        // The valid row must be untouched because the batch aborted pre-write.
1882        let still = service.get_by_id(&a.id).await.unwrap().unwrap();
1883        assert_eq!(still.name, "a");
1884    }
1885
1886    #[tokio::test]
1887    async fn bulk_partial_update_success() {
1888        let service = svc();
1889        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1890        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1891
1892        let mut patch_a = std::collections::HashMap::new();
1893        patch_a.insert("name".to_string(), serde_json::json!("a2"));
1894        let mut patch_b = std::collections::HashMap::new();
1895        patch_b.insert("name".to_string(), serde_json::json!("b2"));
1896
1897        let saved = service
1898            .bulk_partial_update(vec![(a.id.clone(), patch_a), (b.id.clone(), patch_b)])
1899            .await
1900            .unwrap();
1901        assert_eq!(saved.len(), 2);
1902        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a2");
1903        assert_eq!(service.get_by_id(&b.id).await.unwrap().unwrap().name, "b2");
1904    }
1905
1906    #[tokio::test]
1907    async fn bulk_soft_delete_tolerates_duplicate_ids() {
1908        let service = svc();
1909        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1910        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1911
1912        // A repeated id must not fail the batch: ids are de-duplicated first.
1913        let n = service
1914            .bulk_soft_delete(vec![a.id.clone(), a.id.clone(), b.id.clone()])
1915            .await
1916            .unwrap();
1917        assert_eq!(n, 2, "two distinct rows soft-deleted");
1918
1919        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1920        assert!(active.is_empty());
1921    }
1922
1923    #[tokio::test]
1924    async fn bulk_update_rejects_duplicate_ids() {
1925        let service = svc();
1926        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1927
1928        // The same id mapped to two payloads is ambiguous → rejected, nothing written.
1929        let res = service
1930            .bulk_update(vec![
1931                (a.id.clone(), UpdateWidgetDto { name: "first".into() }),
1932                (a.id.clone(), UpdateWidgetDto { name: "second".into() }),
1933            ])
1934            .await;
1935        assert!(res.is_err());
1936        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
1937    }
1938
1939    #[tokio::test]
1940    async fn bulk_soft_delete_rejects_oversized_batch() {
1941        let service = svc();
1942        let ids: Vec<String> = (0..MAX_BATCH_SIZE + 1).map(|n| format!("id-{n}")).collect();
1943        // Enforced at the service layer, independent of any HTTP handler.
1944        assert!(service.bulk_soft_delete(ids).await.is_err());
1945    }
1946
1947    // ── Generic writes may not change protected or unknown fields ─────────
1948
1949    /// A full update that carries the protected field, the way a form that
1950    /// replaces the whole record would.
1951    struct RekeyWidgetDto {
1952        name: String,
1953        secret_hash: String,
1954    }
1955
1956    impl ApplyUpdateDto<RekeyWidgetDto> for Widget {
1957        fn apply_update(mut self, dto: RekeyWidgetDto) -> ServiceResult<Self> {
1958            self.name = dto.name;
1959            self.secret_hash = dto.secret_hash;
1960            Ok(self)
1961        }
1962    }
1963
1964    type RekeyService = GenericCrudService<Widget, CreateWidgetDto, RekeyWidgetDto, InMemoryWidgetRepo>;
1965
1966    fn patch(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
1967        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
1968    }
1969
1970    /// The refusal's message, whether the service refused with named
1971    /// violations or with a plain validation sentence.
1972    fn validation_message(res: ServiceResult<impl std::fmt::Debug>) -> String {
1973        match res {
1974            Err(ServiceError::Validation(m)) => m,
1975            Err(e @ ServiceError::Violations(_)) => e.to_string(),
1976            other => panic!("expected a validation error, got {other:?}"),
1977        }
1978    }
1979
1980    #[tokio::test]
1981    async fn patch_rejects_a_field_the_entity_does_not_have() {
1982        let service = svc();
1983        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1984
1985        let msg = validation_message(service.partial_update(&w.id, patch(&[("nmae", serde_json::json!("b"))])).await);
1986        assert!(msg.contains("unknown_field") && msg.contains("nmae"), "{msg}");
1987        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
1988    }
1989
1990    #[tokio::test]
1991    async fn patch_rejects_changing_the_id() {
1992        let service = svc();
1993        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1994
1995        let msg = validation_message(service.partial_update(&w.id, patch(&[("id", serde_json::json!("other"))])).await);
1996        assert!(msg.contains("field_not_writable") && msg.contains("id"), "{msg}");
1997        assert!(service.get_by_id(&w.id).await.unwrap().is_some());
1998    }
1999
2000    #[tokio::test]
2001    async fn patch_rejects_changing_a_field_the_entity_protects() {
2002        let service = svc();
2003        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2004
2005        let msg = validation_message(
2006            service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await,
2007        );
2008        assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2009        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().secret_hash, "h0");
2010    }
2011
2012    #[tokio::test]
2013    async fn patch_accepts_a_protected_field_echoed_unchanged() {
2014        let service = svc();
2015        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2016
2017        let saved = service
2018            .partial_update(&w.id, patch(&[("name", serde_json::json!("b")), ("secret_hash", serde_json::json!("h0"))]))
2019            .await
2020            .unwrap()
2021            .unwrap();
2022        assert_eq!(saved.name, "b");
2023    }
2024
2025    #[tokio::test]
2026    async fn patch_sets_and_clears_an_optional_field_that_is_not_serialized_when_empty() {
2027        let service = svc();
2028        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2029
2030        let set = service.partial_update(&w.id, patch(&[("note", serde_json::json!("hi"))])).await.unwrap().unwrap();
2031        assert_eq!(set.note.as_deref(), Some("hi"));
2032        let cleared = service.partial_update(&w.id, patch(&[("note", serde_json::Value::Null)])).await.unwrap().unwrap();
2033        assert_eq!(cleared.note, None);
2034    }
2035
2036    #[tokio::test]
2037    async fn put_rejects_changing_a_field_the_entity_protects() {
2038        let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2039        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2040
2041        let msg = validation_message(
2042            service.update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h1".into() }).await,
2043        );
2044        assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2045
2046        // The same form echoing the stored value is an ordinary edit.
2047        let saved = service
2048            .update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h0".into() })
2049            .await
2050            .unwrap()
2051            .unwrap();
2052        assert_eq!(saved.name, "b");
2053    }
2054
2055    #[tokio::test]
2056    async fn bulk_update_rejects_a_protected_change_before_writing_any_row() {
2057        let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2058        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2059        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2060
2061        let res = service
2062            .bulk_update(vec![
2063                (a.id.clone(), RekeyWidgetDto { name: "a2".into(), secret_hash: "h0".into() }),
2064                (b.id.clone(), RekeyWidgetDto { name: "b2".into(), secret_hash: "h1".into() }),
2065            ])
2066            .await;
2067        assert!(validation_message(res).contains("field_not_writable"));
2068        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2069    }
2070
2071    #[tokio::test]
2072    async fn bulk_partial_update_rejects_a_protected_change_before_writing_any_row() {
2073        let service = svc();
2074        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2075        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2076
2077        let res = service
2078            .bulk_partial_update(vec![
2079                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2080                (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2081            ])
2082            .await;
2083        assert!(validation_message(res).contains("field_not_writable"));
2084        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2085    }
2086
2087    #[tokio::test]
2088    async fn a_bulk_preview_says_what_each_row_would_do_and_writes_nothing() {
2089        let service = svc();
2090        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2091        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2092
2093        let rows = service
2094            .preview_bulk_partial_update(vec![
2095                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2096                (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2097                ("missing".into(), patch(&[("name", serde_json::json!("x"))])),
2098            ])
2099            .await
2100            .unwrap();
2101        assert_eq!(rows.len(), 3);
2102        let (before, after) = rows[0].outcome.as_ref().unwrap();
2103        assert_eq!((before.name.as_str(), after.name.as_str()), ("a", "a2"));
2104        let refused = rows[1].outcome.as_ref().err().unwrap().to_string();
2105        assert!(refused.contains("field_not_writable"), "{refused}");
2106        assert!(rows[2].outcome.is_err(), "a row that is not there is refused, not dropped");
2107        // One refused row does not refuse the others, and nothing was written.
2108        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2109    }
2110
2111    #[tokio::test]
2112    async fn a_bulk_preview_refuses_a_batch_the_write_would_refuse_whole() {
2113        let service = svc();
2114        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2115        let res = service
2116            .preview_bulk_partial_update(vec![
2117                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2118                (a.id.clone(), patch(&[("name", serde_json::json!("a3"))])),
2119            ])
2120            .await;
2121        assert!(validation_message(res.map(|r| r.len())).contains("duplicate id"));
2122    }
2123
2124    #[tokio::test]
2125    async fn bulk_partial_update_rejects_an_unknown_field() {
2126        let service = svc();
2127        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2128
2129        let res = service.bulk_partial_update(vec![(a.id.clone(), patch(&[("colour", serde_json::json!("red"))]))]).await;
2130        assert!(validation_message(res).contains("unknown_field"));
2131    }
2132
2133    // ── Events and trash lifecycle ────────────────────────────────────────
2134
2135    #[derive(Default)]
2136    struct RecordingPublisher {
2137        kinds: tokio::sync::Mutex<Vec<&'static str>>,
2138    }
2139
2140    #[async_trait]
2141    impl CrudEventPublisher<Widget> for RecordingPublisher {
2142        async fn publish(&self, event: CrudEvent<Widget>) -> Result<(), backbone_messaging::EventError> {
2143            let kind = match event {
2144                CrudEvent::Updated { .. } => "updated",
2145                CrudEvent::Patched { .. } => "patched",
2146                _ => "other",
2147            };
2148            self.kinds.lock().await.push(kind);
2149            Ok(())
2150        }
2151    }
2152
2153    #[tokio::test]
2154    async fn patch_publishes_an_updated_event() {
2155        let publisher = Arc::new(RecordingPublisher::default());
2156        let service = svc().with_event_publisher(publisher.clone());
2157        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2158        publisher.kinds.lock().await.clear();
2159
2160        service.partial_update(&w.id, patch(&[("name", serde_json::json!("b"))])).await.unwrap();
2161        assert_eq!(*publisher.kinds.lock().await, vec!["updated"]);
2162    }
2163
2164    /// A lifecycle that refuses every trash operation, so a test can tell
2165    /// whether the operation consulted it.
2166    struct RefuseTrash;
2167
2168    #[async_trait]
2169    impl ServiceLifecycle<Widget> for RefuseTrash {
2170        async fn before_restore(&self, _entity: &Widget) -> ServiceResult<()> {
2171            Err(ServiceError::Validation("restore refused".into()))
2172        }
2173        async fn before_hard_delete(&self, _entity: &Widget) -> ServiceResult<()> {
2174            Err(ServiceError::Validation("hard delete refused".into()))
2175        }
2176        async fn before_restore_all(&self) -> ServiceResult<()> {
2177            Err(ServiceError::Validation("restore all refused".into()))
2178        }
2179        async fn before_empty_trash(&self) -> ServiceResult<()> {
2180            Err(ServiceError::Validation("empty trash refused".into()))
2181        }
2182    }
2183
2184    #[tokio::test]
2185    async fn every_trash_operation_consults_the_lifecycle() {
2186        let repo = Arc::new(InMemoryWidgetRepo::new());
2187        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2188            GenericCrudService::with_lifecycle(repo, Arc::new(RefuseTrash));
2189        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2190        service.soft_delete(&w.id).await.unwrap();
2191
2192        assert!(service.restore(&w.id).await.is_err(), "restore");
2193        assert!(service.bulk_restore(vec![w.id.clone()]).await.is_err(), "bulk restore");
2194        assert!(service.restore_all().await.is_err(), "restore all");
2195        assert!(service.hard_delete(&w.id).await.is_err(), "hard delete");
2196        assert!(service.bulk_permanent_delete(vec![w.id.clone()]).await.is_err(), "bulk permanent delete");
2197        assert!(service.empty_trash().await.is_err(), "empty trash");
2198
2199        // Nothing moved: the row is still in the trash.
2200        let still = service.get_deleted_by_id(&w.id).await.unwrap().unwrap();
2201        assert!(still.deleted_at.is_some());
2202    }
2203
2204    // ── Named violations and the write guard ──────────────────────────────
2205
2206    #[tokio::test]
2207    async fn a_refused_field_is_a_violation_naming_its_path_and_code() {
2208        let service = svc();
2209        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2210
2211        let err = service
2212            .partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1")), ("colour", serde_json::json!("red"))]))
2213            .await
2214            .unwrap_err();
2215        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2216        let pairs: Vec<(&str, &str)> = v.iter().map(|x| (x.path.as_str(), x.code.as_str())).collect();
2217        assert_eq!(pairs, vec![("colour", "unknown_field")]);
2218        // The sentence clients already read is unchanged.
2219        assert!(err.to_string().starts_with("validation failed: unknown_field: `colour`"), "{err}");
2220
2221        let err = service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await.unwrap_err();
2222        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2223        assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("secret_hash", "field_not_writable"));
2224    }
2225
2226    /// Refuses any write whose resulting widget is named `forbidden`, and
2227    /// shadow-reports any named `watched`. Records every kind it was asked about.
2228    #[derive(Default)]
2229    struct NameGuard {
2230        seen: std::sync::Mutex<Vec<crate::write_guard::WriteKind>>,
2231    }
2232
2233    #[async_trait]
2234    impl crate::write_guard::WriteGuard<Widget> for NameGuard {
2235        async fn check(&self, ctx: &crate::write_guard::WriteCtx<'_, Widget>) -> crate::write_guard::GuardOutcome {
2236            self.seen.lock().unwrap().push(ctx.kind);
2237            let row = ctx.after.or(ctx.before).expect("a write concerns a row");
2238            let mut out = crate::write_guard::GuardOutcome::default();
2239            match row.name.as_str() {
2240                "forbidden" => out.refuse.push(crate::violation::Violation::new("name", "name_forbidden", "this name is refused")),
2241                "watched" => out.shadow.push(crate::violation::Violation::new("name", "name_watched", "would be refused")),
2242                _ => {}
2243            }
2244            out
2245        }
2246    }
2247
2248    fn guarded(guard: Arc<NameGuard>) -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2249        svc().with_guard(guard)
2250    }
2251
2252    #[tokio::test]
2253    async fn a_guard_refusal_stops_create_update_and_patch_with_nothing_written() {
2254        let guard = Arc::new(NameGuard::default());
2255        let service = guarded(guard.clone());
2256
2257        let err = service.create(CreateWidgetDto { name: "forbidden".into() }).await.unwrap_err();
2258        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2259        assert!(service.list(1, 20, Default::default()).await.unwrap().0.is_empty(), "nothing created");
2260
2261        let w = service.create(CreateWidgetDto { name: "ok".into() }).await.unwrap();
2262        assert!(service.update(&w.id, UpdateWidgetDto { name: "forbidden".into() }).await.is_err());
2263        assert!(service.partial_update(&w.id, patch(&[("name", serde_json::json!("forbidden"))])).await.is_err());
2264        assert!(service
2265            .bulk_partial_update(vec![(w.id.clone(), patch(&[("name", serde_json::json!("forbidden"))]))])
2266            .await
2267            .is_err());
2268        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "ok");
2269    }
2270
2271    #[tokio::test]
2272    async fn a_shadowed_rule_lets_the_write_through() {
2273        let service = guarded(Arc::new(NameGuard::default()));
2274        let w = service.create(CreateWidgetDto { name: "watched".into() }).await.unwrap();
2275        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "watched");
2276    }
2277
2278    #[tokio::test]
2279    async fn the_guard_is_asked_about_every_write_kind() {
2280        use crate::write_guard::WriteKind::*;
2281        let guard = Arc::new(NameGuard::default());
2282        let service = guarded(guard.clone());
2283        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2284        service.update(&w.id, UpdateWidgetDto { name: "b".into() }).await.unwrap();
2285        service.partial_update(&w.id, patch(&[("name", serde_json::json!("c"))])).await.unwrap();
2286        service.soft_delete(&w.id).await.unwrap();
2287        service.restore(&w.id).await.unwrap();
2288        service.soft_delete(&w.id).await.unwrap();
2289        service.hard_delete(&w.id).await.unwrap();
2290        assert_eq!(*guard.seen.lock().unwrap(), vec![Create, Update, Update, Delete, Restore, Delete, HardDelete]);
2291    }
2292
2293    #[test]
2294    fn the_generic_service_hands_its_violations_to_the_http_layer() {
2295        use crate::http::CrudService;
2296        type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2297        let v = vec![crate::violation::Violation::new("name", "x", "y")];
2298        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::Violations(v.clone())), Some(v.clone()));
2299        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::NotFound), None);
2300        // A constraint the database refused with reaches the HTTP layer as the same 422.
2301        let db = ServiceError::Repository(RepositoryError::Violations(v.clone()));
2302        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&db), Some(v));
2303    }
2304
2305    // ── Trash-wide writes ask the guard about every row ───────────────────
2306
2307    /// Trash two widgets, one renamed behind the service's back to a name the guard refuses.
2308    async fn trash_with_a_refused_row(
2309        service: &GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>,
2310    ) -> (String, String) {
2311        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2312        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2313        service.bulk_soft_delete(vec![a.clone(), b.clone()]).await.unwrap();
2314        let mut data = service.repository().data.lock().await;
2315        data.iter_mut().find(|w| w.id == b).unwrap().name = "forbidden".into();
2316        (a, b)
2317    }
2318
2319    #[tokio::test]
2320    async fn restore_all_asks_the_guard_about_every_row_and_restores_none_when_one_is_refused() {
2321        let guard = Arc::new(NameGuard::default());
2322        let service = guarded(guard.clone());
2323        trash_with_a_refused_row(&service).await;
2324
2325        let err = service.restore_all().await.unwrap_err();
2326        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2327        assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing restored");
2328        assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::Restore));
2329    }
2330
2331    #[tokio::test]
2332    async fn empty_trash_asks_the_guard_about_every_row_and_deletes_none_when_one_is_refused() {
2333        let guard = Arc::new(NameGuard::default());
2334        let service = guarded(guard.clone());
2335        trash_with_a_refused_row(&service).await;
2336
2337        let err = service.empty_trash().await.unwrap_err();
2338        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2339        assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing deleted");
2340        assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::HardDelete));
2341
2342        // Without the refused row the trash empties.
2343        let service = svc();
2344        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2345        service.soft_delete(&a).await.unwrap();
2346        assert_eq!(service.empty_trash().await.unwrap(), 1);
2347        assert_eq!(service.count_deleted().await.unwrap(), 0);
2348    }
2349
2350    // ── Upsert refuses instead of duplicating ──────────────────────────────
2351
2352    #[tokio::test]
2353    async fn upsert_refuses_rather_than_creating_a_duplicate() {
2354        use crate::http::CrudService;
2355        type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2356        let service = svc();
2357        assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2358        assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2359        assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2360        assert!(!<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::supports_upsert());
2361    }
2362
2363    // ── Bulk create: capped, one repository call, the failed item named ───
2364
2365    #[tokio::test]
2366    async fn bulk_create_is_one_repository_write_and_names_the_refused_item() {
2367        let service = guarded(Arc::new(NameGuard::default()));
2368        let names = ["a", "forbidden", "c"];
2369        let err = service
2370            .bulk_create(names.iter().map(|n| CreateWidgetDto { name: n.to_string() }).collect())
2371            .await
2372            .unwrap_err();
2373        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2374        assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("1/name", "name_forbidden"));
2375        assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2376
2377        let created = service
2378            .bulk_create(vec![CreateWidgetDto { name: "a".into() }, CreateWidgetDto { name: "b".into() }])
2379            .await
2380            .unwrap();
2381        assert_eq!(created.len(), 2);
2382        assert_eq!(
2383            service.repository().bulk_creates.load(std::sync::atomic::Ordering::SeqCst),
2384            1,
2385            "the batch is written by one all-or-nothing repository call"
2386        );
2387    }
2388
2389    #[tokio::test]
2390    async fn bulk_create_refuses_an_oversized_batch() {
2391        let service = svc();
2392        let dtos = (0..=MAX_BATCH_SIZE).map(|n| CreateWidgetDto { name: format!("w{n}") }).collect();
2393        assert!(matches!(service.bulk_create(dtos).await, Err(ServiceError::Validation(_))));
2394        assert_eq!(service.count().await.unwrap(), 0);
2395    }
2396
2397    // ── Bulk updates write only the changed columns ────────────────────────
2398
2399    #[tokio::test]
2400    async fn bulk_updates_write_through_the_changed_columns_path() {
2401        let service = svc();
2402        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2403        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2404        service
2405            .bulk_update(vec![
2406                (a.clone(), UpdateWidgetDto { name: "a2".into() }),
2407                (b.clone(), UpdateWidgetDto { name: "b2".into() }),
2408            ])
2409            .await
2410            .unwrap();
2411        service
2412            .bulk_partial_update(vec![(a.clone(), patch(&[("name", serde_json::json!("a3"))]))])
2413            .await
2414            .unwrap();
2415        let repo = service.repository();
2416        assert_eq!(repo.changed_writes.lock().unwrap().len(), 3);
2417        assert_eq!(repo.full_writes.load(std::sync::atomic::Ordering::SeqCst), 0, "no full-row write");
2418        assert_eq!(service.get_by_id(&a).await.unwrap().unwrap().name, "a3");
2419    }
2420
2421    // ── The trash list takes the list's filters ────────────────────────────
2422
2423    #[tokio::test]
2424    async fn the_trash_list_applies_filters() {
2425        let service = svc();
2426        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2427        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2428        service.bulk_soft_delete(vec![a, b]).await.unwrap();
2429        let filters = HashMap::from([("name".to_string(), "b".to_string())]);
2430        let (rows, total) = service.list_deleted_filtered(1, 20, filters).await.unwrap();
2431        assert_eq!((rows.len(), total), (1, 1));
2432        assert_eq!(rows[0].name, "b");
2433    }
2434
2435    // ── The generic HTTP surface ───────────────────────────────────────────
2436
2437    mod http_surface {
2438        use super::*;
2439        use axum::body::Body;
2440        use axum::http::{Request, StatusCode};
2441        use tower::ServiceExt;
2442
2443        type Svc = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2444
2445        fn app() -> (axum::Router, Arc<Svc>) {
2446            let service = Arc::new(svc());
2447            let router = crate::http::BackboneCrudHandler::<Svc, Widget, CreateWidgetDto, UpdateWidgetDto, Widget>::routes(
2448                service.clone(),
2449                "/widgets",
2450            );
2451            (router, service)
2452        }
2453
2454        async fn call(app: &axum::Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, serde_json::Value) {
2455            let mut req = Request::builder().method(method).uri(uri);
2456            if body.is_some() {
2457                req = req.header("content-type", "application/json");
2458            }
2459            let req = req.body(Body::from(body.unwrap_or("").to_string())).unwrap();
2460            let res = app.clone().oneshot(req).await.unwrap();
2461            let status = res.status();
2462            let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
2463            let json = if bytes.is_empty() {
2464                serde_json::Value::Null
2465            } else {
2466                serde_json::from_slice(&bytes).unwrap_or_else(|_| {
2467                    panic!("not a JSON body: {}", String::from_utf8_lossy(&bytes))
2468                })
2469            };
2470            (status, json)
2471        }
2472
2473        #[tokio::test]
2474        async fn a_malformed_path_id_is_a_400_naming_the_id() {
2475            let (app, _) = app();
2476            for (method, uri) in [("GET", "/widgets/not-a-uuid"), ("DELETE", "/widgets/not-a-uuid")] {
2477                let (status, body) = call(&app, method, uri, None).await;
2478                assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {uri}");
2479                assert_eq!(body["violations"][0]["code"], "invalid_id");
2480                assert_eq!(body["violations"][0]["path"], "id");
2481            }
2482        }
2483
2484        #[tokio::test]
2485        async fn a_server_fault_answers_generically_and_keeps_the_detail_out() {
2486            let (app, _) = app();
2487            let (status, body) = call(&app, "GET", &format!("/widgets/{BROKEN_ID}"), None).await;
2488            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
2489            assert_eq!(body["error"], crate::http::INTERNAL_ERROR_MESSAGE);
2490            assert!(!body.to_string().contains("secret_schema"), "{body}");
2491        }
2492
2493        #[tokio::test]
2494        async fn a_duplicate_on_put_and_patch_is_a_409_like_on_create() {
2495            let (app, service) = app();
2496            let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2497            let uri = format!("/widgets/{}", w.id);
2498            let (status, _) = call(&app, "PUT", &uri, Some(r#"{"name":"taken"}"#)).await;
2499            assert_eq!(status, StatusCode::CONFLICT);
2500            let (status, _) = call(&app, "PATCH", &uri, Some(r#"{"name":"taken"}"#)).await;
2501            assert_eq!(status, StatusCode::CONFLICT);
2502        }
2503
2504        #[tokio::test]
2505        async fn an_unknown_key_is_refused_on_create_put_and_bulk() {
2506            let (app, service) = app();
2507            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","colour":"red"}"#)).await;
2508            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2509            assert_eq!(body["violations"][0]["code"], "unknown_field");
2510            assert_eq!(body["violations"][0]["path"], "colour");
2511            assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2512
2513            // What the record's own response carries is tolerated.
2514            let (status, _) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","id":"x","metadata":{}}"#)).await;
2515            assert_eq!(status, StatusCode::CREATED);
2516
2517            let w = service.list(1, 1, HashMap::new()).await.unwrap().0.remove(0);
2518            let (status, body) = call(&app, "PUT", &format!("/widgets/{}", w.id), Some(r#"{"name":"b","colour":"red"}"#)).await;
2519            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2520            assert_eq!(body["violations"][0]["path"], "colour");
2521
2522            let (status, body) = call(&app, "POST", "/widgets/bulk", Some(r#"[{"name":"x"},{"name":"y","colour":"red"}]"#)).await;
2523            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2524            assert_eq!(body["violations"][0]["path"], "1/colour");
2525
2526            let bulk = format!(r#"[{{"id":"{}","name":"z","colour":"red"}}]"#, w.id);
2527            let (status, body) = call(&app, "PUT", "/widgets/bulk", Some(&bulk)).await;
2528            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2529            assert_eq!(body["violations"][0]["path"], "0/colour");
2530            assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a", "nothing written");
2531        }
2532
2533        #[tokio::test]
2534        async fn a_body_that_cannot_be_read_answers_in_the_json_envelope() {
2535            let (app, _) = app();
2536            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": "#)).await;
2537            assert_eq!(status, StatusCode::BAD_REQUEST);
2538            assert_eq!(body["success"], false);
2539            assert_eq!(body["violations"][0]["code"], "malformed_body");
2540
2541            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": 5}"#)).await;
2542            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2543            assert_eq!(body["violations"][0]["path"], "name");
2544        }
2545
2546        #[tokio::test]
2547        async fn the_generic_upsert_route_answers_501() {
2548            let (app, service) = app();
2549            let (status, _) = call(&app, "POST", "/widgets/upsert", Some(r#"{"name":"a"}"#)).await;
2550            assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
2551            assert_eq!(service.count().await.unwrap(), 0);
2552        }
2553
2554        #[tokio::test]
2555        async fn the_trash_route_reads_the_filters() {
2556            let (app, service) = app();
2557            let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2558            let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2559            service.bulk_soft_delete(vec![a, b]).await.unwrap();
2560            let (status, body) = call(&app, "GET", "/widgets/trash?name=b", None).await;
2561            assert_eq!(status, StatusCode::OK);
2562            assert_eq!(body["data"].as_array().unwrap().len(), 1);
2563            assert_eq!(body["data"][0]["name"], "b");
2564        }
2565    }
2566}