1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
96
97use std::future::Future;
98use std::pin::Pin;
99use std::sync::Arc;
100
101use chrono::{DateTime, Utc};
102use serde::{Deserialize, Serialize};
103use serde_json::Value;
104
105use crate::error::{AutumnError, AutumnResult};
106use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
107use crate::state::AppState;
108
109pub const NOTIFICATIONS_TABLE: &str = "notifications";
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Notification {
118 pub id: i64,
120 pub recipient_id: i64,
122 pub kind: String,
124 pub payload: Value,
126 pub read_at: Option<DateTime<Utc>>,
128 pub created_at: DateTime<Utc>,
130}
131
132impl Notification {
133 #[must_use]
135 pub const fn is_read(&self) -> bool {
136 self.read_at.is_some()
137 }
138}
139
140#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
144#[error("notification store error: {message}")]
145pub struct NotificationStoreError {
146 message: String,
147}
148
149impl NotificationStoreError {
150 #[must_use]
152 pub fn new(message: impl Into<String>) -> Self {
153 Self {
154 message: message.into(),
155 }
156 }
157}
158
159pub trait NotificationStore: Send + Sync + 'static {
177 fn notify(
179 &self,
180 recipient_id: i64,
181 kind: String,
182 payload: Value,
183 ) -> impl Future<Output = Result<Notification, NotificationStoreError>> + Send;
184
185 fn list(
189 &self,
190 recipient_id: i64,
191 query: &ListQuery,
192 page: &PageRequest,
193 ) -> impl Future<Output = Result<Page<Notification>, NotificationStoreError>> + Send;
194
195 fn unread_count(
197 &self,
198 recipient_id: i64,
199 ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
200
201 fn mark_read(
205 &self,
206 id: i64,
207 recipient_id: Option<i64>,
208 ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
209
210 fn mark_all_read(
213 &self,
214 recipient_id: i64,
215 ) -> impl Future<Output = Result<u64, NotificationStoreError>> + Send;
216}
217
218type BoxedFuture<'a, T> =
225 Pin<Box<dyn Future<Output = Result<T, NotificationStoreError>> + Send + 'a>>;
226
227pub(crate) trait BoxedNotificationStore: Send + Sync + 'static {
228 fn boxed_notify(
229 &self,
230 recipient_id: i64,
231 kind: String,
232 payload: Value,
233 ) -> BoxedFuture<'_, Notification>;
234 fn boxed_list(
235 &self,
236 recipient_id: i64,
237 query: ListQuery,
238 page: PageRequest,
239 ) -> BoxedFuture<'_, Page<Notification>>;
240 fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
241 fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64>;
242 fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64>;
243}
244
245impl<S: NotificationStore> BoxedNotificationStore for S {
246 fn boxed_notify(
247 &self,
248 recipient_id: i64,
249 kind: String,
250 payload: Value,
251 ) -> BoxedFuture<'_, Notification> {
252 Box::pin(NotificationStore::notify(self, recipient_id, kind, payload))
253 }
254
255 fn boxed_list(
256 &self,
257 recipient_id: i64,
258 query: ListQuery,
259 page: PageRequest,
260 ) -> BoxedFuture<'_, Page<Notification>> {
261 Box::pin(async move { NotificationStore::list(self, recipient_id, &query, &page).await })
262 }
263
264 fn boxed_unread_count(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
265 Box::pin(NotificationStore::unread_count(self, recipient_id))
266 }
267
268 fn boxed_mark_read(&self, id: i64, recipient_id: Option<i64>) -> BoxedFuture<'_, u64> {
269 Box::pin(NotificationStore::mark_read(self, id, recipient_id))
270 }
271
272 fn boxed_mark_all_read(&self, recipient_id: i64) -> BoxedFuture<'_, u64> {
273 Box::pin(NotificationStore::mark_all_read(self, recipient_id))
274 }
275}
276
277#[derive(Clone)]
286pub struct Notifications {
287 store: Arc<dyn BoxedNotificationStore>,
288 #[cfg(feature = "ws")]
289 channels: Option<crate::channels::Channels>,
290}
291
292impl Notifications {
293 #[must_use]
295 pub fn new(store: impl NotificationStore) -> Self {
296 Self {
297 store: Arc::new(store),
298 #[cfg(feature = "ws")]
299 channels: None,
300 }
301 }
302
303 #[must_use]
309 pub fn topic(recipient_id: i64) -> String {
310 let tenant = crate::tenancy::current_tenant_id();
311 if tenant.is_empty() {
312 format!("notifications:{recipient_id}")
313 } else {
314 format!("notifications:{tenant}:{recipient_id}")
315 }
316 }
317
318 pub async fn notify(
327 &self,
328 recipient_id: i64,
329 kind: impl Into<String>,
330 payload: Value,
331 ) -> AutumnResult<Notification> {
332 self.store
333 .boxed_notify(recipient_id, kind.into(), payload)
334 .await
335 .map_err(AutumnError::internal_server_error)
336 }
337
338 #[cfg(feature = "ws")]
352 pub async fn notify_with_push(
353 &self,
354 recipient_id: i64,
355 kind: impl Into<String>,
356 payload: Value,
357 ) -> AutumnResult<Notification> {
358 let notification = self.notify(recipient_id, kind, payload).await?;
359 if let Some(channels) = &self.channels
360 && let Ok(json) = serde_json::to_string(¬ification)
361 {
362 let _ = channels
365 .broadcast()
366 .publish(&Self::topic(recipient_id), json);
367 }
368 Ok(notification)
369 }
370
371 pub async fn list(
378 &self,
379 recipient_id: i64,
380 query: &ListQuery,
381 page: &PageRequest,
382 ) -> AutumnResult<Page<Notification>> {
383 self.store
384 .boxed_list(recipient_id, query.clone(), *page)
385 .await
386 .map_err(AutumnError::internal_server_error)
387 }
388
389 pub async fn unread_count(&self, recipient_id: i64) -> AutumnResult<u64> {
395 self.store
396 .boxed_unread_count(recipient_id)
397 .await
398 .map_err(AutumnError::internal_server_error)
399 }
400
401 pub async fn mark_read(&self, id: i64) -> AutumnResult<()> {
413 self.store
414 .boxed_mark_read(id, None)
415 .await
416 .map(|_| ())
417 .map_err(AutumnError::internal_server_error)
418 }
419
420 pub async fn mark_read_for(&self, recipient_id: i64, id: i64) -> AutumnResult<()> {
428 self.store
429 .boxed_mark_read(id, Some(recipient_id))
430 .await
431 .map(|_| ())
432 .map_err(AutumnError::internal_server_error)
433 }
434
435 pub async fn mark_all_read(&self, recipient_id: i64) -> AutumnResult<u64> {
442 self.store
443 .boxed_mark_all_read(recipient_id)
444 .await
445 .map_err(AutumnError::internal_server_error)
446 }
447
448 fn default_for(state: &AppState) -> Self {
452 #[cfg(feature = "db")]
453 if let Some(pool) = crate::db::DbState::pool(state) {
454 return Self::new(DbNotificationStore::new(pool.clone()));
455 }
456 Self::new(MemoryNotificationStore::new())
457 }
458}
459
460impl std::fmt::Debug for Notifications {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 f.debug_struct("Notifications").finish_non_exhaustive()
463 }
464}
465
466impl axum::extract::FromRequestParts<AppState> for Notifications {
467 type Rejection = std::convert::Infallible;
471
472 async fn from_request_parts(
473 _parts: &mut axum::http::request::Parts,
474 state: &AppState,
475 ) -> Result<Self, Self::Rejection> {
476 let service = state.extension_or_insert_with::<Self>(|| Self::default_for(state));
480 #[cfg_attr(not(feature = "ws"), allow(unused_mut))]
481 let mut service = (*service).clone();
482 #[cfg(feature = "ws")]
483 {
484 service.channels = Some(state.channels().clone());
485 }
486 Ok(service)
487 }
488}
489
490#[derive(Debug, Default)]
499pub struct MemoryNotificationStore {
500 inner: std::sync::Mutex<MemoryInner>,
501}
502
503#[derive(Debug, Default)]
504struct MemoryInner {
505 next_id: i64,
506 rows: Vec<(String, Notification)>,
508}
509
510impl MemoryNotificationStore {
511 #[must_use]
513 pub fn new() -> Self {
514 Self::default()
515 }
516
517 fn lock(&self) -> Result<std::sync::MutexGuard<'_, MemoryInner>, NotificationStoreError> {
518 self.inner
519 .lock()
520 .map_err(|_| NotificationStoreError::new("memory store mutex poisoned"))
521 }
522}
523
524impl NotificationStore for MemoryNotificationStore {
525 async fn notify(
526 &self,
527 recipient_id: i64,
528 kind: String,
529 payload: Value,
530 ) -> Result<Notification, NotificationStoreError> {
531 let tenant = crate::tenancy::current_tenant_id();
532 let mut inner = self.lock()?;
533 inner.next_id += 1;
534 let notification = Notification {
535 id: inner.next_id,
536 recipient_id,
537 kind,
538 payload,
539 read_at: None,
540 created_at: crate::time::ambient_now(),
541 };
542 inner.rows.push((tenant, notification.clone()));
543 drop(inner);
544 Ok(notification)
545 }
546
547 async fn list(
548 &self,
549 recipient_id: i64,
550 query: &ListQuery,
551 page: &PageRequest,
552 ) -> Result<Page<Notification>, NotificationStoreError> {
553 let filter = ListFilter::from_query(query);
554 let tenant = crate::tenancy::current_tenant_id();
555 let inner = self.lock()?;
556 let mut rows: Vec<Notification> = inner
557 .rows
558 .iter()
559 .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
560 .map(|(_, n)| n)
561 .filter(|n| !filter.unread_only || n.read_at.is_none())
562 .filter(|n| filter.kind.as_deref().is_none_or(|k| n.kind == k))
563 .cloned()
564 .collect();
565 drop(inner);
566
567 match filter.sort {
570 FeedSort::CreatedAt => rows.sort_by_key(|n| (n.created_at, n.id)),
571 FeedSort::Id => rows.sort_by_key(|n| n.id),
572 }
573 if filter.dir == SortDir::Desc {
574 rows.reverse();
575 }
576
577 let total = i64::try_from(rows.len()).unwrap_or(i64::MAX);
578 let start = usize::try_from(page.offset()).unwrap_or(usize::MAX);
579 let items: Vec<Notification> = rows
580 .into_iter()
581 .skip(start)
582 .take(usize::try_from(page.limit()).unwrap_or(0))
583 .collect();
584 Ok(Page::new(items, total, page))
585 }
586
587 async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
588 let tenant = crate::tenancy::current_tenant_id();
589 let inner = self.lock()?;
590 let count = inner
591 .rows
592 .iter()
593 .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
594 .filter(|(_, n)| n.read_at.is_none())
595 .count();
596 drop(inner);
597 Ok(count as u64)
598 }
599
600 async fn mark_read(
601 &self,
602 id: i64,
603 recipient_id: Option<i64>,
604 ) -> Result<u64, NotificationStoreError> {
605 let now = crate::time::ambient_now();
606 let tenant = crate::tenancy::current_tenant_id();
607 let mut inner = self.lock()?;
608 let marked = inner
609 .rows
610 .iter_mut()
611 .filter(|(t, n)| *t == tenant && n.id == id)
612 .filter(|(_, n)| recipient_id.is_none_or(|rid| n.recipient_id == rid))
613 .filter(|(_, n)| n.read_at.is_none())
614 .map(|(_, n)| n.read_at = Some(now))
615 .count();
616 drop(inner);
617 Ok(marked as u64)
618 }
619
620 async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
621 let now = crate::time::ambient_now();
622 let tenant = crate::tenancy::current_tenant_id();
623 let mut inner = self.lock()?;
624 let marked = inner
625 .rows
626 .iter_mut()
627 .filter(|(t, n)| *t == tenant && n.recipient_id == recipient_id)
628 .filter(|(_, n)| n.read_at.is_none())
629 .map(|(_, n)| n.read_at = Some(now))
630 .count();
631 drop(inner);
632 Ok(marked as u64)
633 }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639enum FeedSort {
640 Id,
641 CreatedAt,
642}
643
644struct ListFilter {
647 unread_only: bool,
648 kind: Option<String>,
649 sort: FeedSort,
650 dir: SortDir,
651}
652
653impl ListFilter {
654 fn from_query(query: &ListQuery) -> Self {
655 let mut unread_only = false;
656 let mut kind = None;
657 for (column, value) in query.filters() {
658 match column {
659 "unread" => unread_only = value == "true" || value == "1",
660 "kind" => kind = Some(value.to_owned()),
661 _ => {}
664 }
665 }
666 let (sort, dir) = match query.sort() {
670 Some("created_at") => (FeedSort::CreatedAt, query.direction()),
671 Some("id") => (FeedSort::Id, query.direction()),
672 None | Some(_) => (FeedSort::Id, SortDir::Desc),
674 };
675 Self {
676 unread_only,
677 kind,
678 sort,
679 dir,
680 }
681 }
682}
683
684#[cfg(feature = "db")]
687pub use self::db_store::DbNotificationStore;
688
689#[cfg(feature = "db")]
690mod db_store {
691 use super::{
692 FeedSort, ListFilter, Notification, NotificationStore, NotificationStoreError, Value,
693 };
694 use crate::db::RuntimeConnection;
695 use crate::pagination::{ListQuery, Page, PageRequest, SortDir};
696 use chrono::{DateTime, Utc};
697 use diesel::prelude::*;
698 use diesel_async::RunQueryDsl;
699 use diesel_async::pooled_connection::deadpool::Pool;
700
701 #[cfg(not(feature = "sqlite"))]
706 mod schema {
707 diesel::table! {
708 notifications (id) {
709 id -> BigInt,
710 tenant_id -> Text,
711 recipient_id -> BigInt,
712 kind -> Text,
713 payload -> Text,
714 read_at -> Nullable<Timestamptz>,
715 created_at -> Timestamptz,
716 }
717 }
718 }
719 #[cfg(feature = "sqlite")]
720 mod schema {
721 diesel::table! {
722 notifications (id) {
723 id -> BigInt,
724 tenant_id -> Text,
725 recipient_id -> BigInt,
726 kind -> Text,
727 payload -> Text,
728 read_at -> Nullable<TimestamptzSqlite>,
729 created_at -> TimestamptzSqlite,
730 }
731 }
732 }
733
734 use schema::notifications;
735
736 #[derive(Queryable, Selectable)]
737 #[diesel(table_name = notifications)]
738 struct NotificationRow {
739 id: i64,
740 recipient_id: i64,
741 kind: String,
742 payload: String,
743 read_at: Option<DateTime<Utc>>,
744 created_at: DateTime<Utc>,
745 }
746
747 impl From<NotificationRow> for Notification {
748 fn from(row: NotificationRow) -> Self {
749 let payload = match serde_json::from_str(&row.payload) {
753 Ok(parsed) => parsed,
754 Err(_) => Value::String(row.payload),
755 };
756 Self {
757 id: row.id,
758 recipient_id: row.recipient_id,
759 kind: row.kind,
760 payload,
761 read_at: row.read_at,
762 created_at: row.created_at,
763 }
764 }
765 }
766
767 #[derive(Insertable)]
768 #[diesel(table_name = notifications)]
769 struct NewNotificationRow {
770 tenant_id: String,
771 recipient_id: i64,
772 kind: String,
773 payload: String,
774 created_at: DateTime<Utc>,
775 }
776
777 #[derive(Clone)]
783 pub struct DbNotificationStore {
784 pool: Pool<RuntimeConnection>,
785 }
786
787 impl DbNotificationStore {
788 #[must_use]
790 pub const fn new(pool: Pool<RuntimeConnection>) -> Self {
791 Self { pool }
792 }
793
794 async fn conn(
795 &self,
796 ) -> Result<
797 diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>,
798 NotificationStoreError,
799 > {
800 self.pool
801 .get()
802 .await
803 .map_err(|e| NotificationStoreError::new(format!("checkout failed: {e}")))
804 }
805 }
806
807 fn store_err(e: &diesel::result::Error) -> NotificationStoreError {
808 let message = e.to_string();
809 if message.contains("tenant_id") && message.contains("column") {
814 return NotificationStoreError::new(format!(
815 "query failed: {e}. The `notifications` table has no `tenant_id` column — \
816 see docs/migrations/0.9.0.md"
817 ));
818 }
819 if message.contains("does not exist") || message.contains("no such table") {
820 return NotificationStoreError::new(format!(
821 "query failed: {e}. The `notifications` table is missing — scaffold it \
822 with `autumn generate notifications`, then apply it with `autumn migrate`"
823 ));
824 }
825 NotificationStoreError::new(format!("query failed: {e}"))
826 }
827
828 impl NotificationStore for DbNotificationStore {
829 async fn notify(
830 &self,
831 recipient_id: i64,
832 kind: String,
833 payload: Value,
834 ) -> Result<Notification, NotificationStoreError> {
835 let payload = serde_json::to_string(&payload)
836 .map_err(|e| NotificationStoreError::new(format!("payload serialization: {e}")))?;
837 let mut conn = self.conn().await?;
838 let row: NotificationRow = diesel::insert_into(notifications::table)
839 .values(NewNotificationRow {
840 tenant_id: crate::tenancy::current_tenant_id(),
841 recipient_id,
842 kind,
843 payload,
844 created_at: crate::time::ambient_now(),
845 })
846 .returning(NotificationRow::as_returning())
847 .get_result(&mut conn)
848 .await
849 .map_err(|e| store_err(&e))?;
850 Ok(row.into())
851 }
852
853 async fn list(
854 &self,
855 recipient_id: i64,
856 query: &ListQuery,
857 page: &PageRequest,
858 ) -> Result<Page<Notification>, NotificationStoreError> {
859 use notifications::dsl;
860
861 let filter = ListFilter::from_query(query);
862 let mut conn = self.conn().await?;
863
864 let tenant = crate::tenancy::current_tenant_id();
865 let mut count_query = dsl::notifications
866 .filter(dsl::tenant_id.eq(tenant.clone()))
867 .filter(dsl::recipient_id.eq(recipient_id))
868 .into_boxed();
869 let mut select_query = dsl::notifications
870 .filter(dsl::tenant_id.eq(tenant))
871 .filter(dsl::recipient_id.eq(recipient_id))
872 .into_boxed();
873 if filter.unread_only {
874 count_query = count_query.filter(dsl::read_at.is_null());
875 select_query = select_query.filter(dsl::read_at.is_null());
876 }
877 if let Some(kind) = &filter.kind {
878 count_query = count_query.filter(dsl::kind.eq(kind.clone()));
879 select_query = select_query.filter(dsl::kind.eq(kind.clone()));
880 }
881
882 let total: i64 = count_query
883 .count()
884 .get_result(&mut conn)
885 .await
886 .map_err(|e| store_err(&e))?;
887
888 select_query = match (filter.sort, filter.dir) {
889 (FeedSort::Id, SortDir::Asc) => select_query.order(dsl::id.asc()),
890 (FeedSort::Id, SortDir::Desc) => select_query.order(dsl::id.desc()),
891 (FeedSort::CreatedAt, SortDir::Asc) => {
892 select_query.order((dsl::created_at.asc(), dsl::id.asc()))
893 }
894 (FeedSort::CreatedAt, SortDir::Desc) => {
895 select_query.order((dsl::created_at.desc(), dsl::id.desc()))
896 }
897 };
898
899 let rows: Vec<NotificationRow> = select_query
900 .select(NotificationRow::as_select())
901 .limit(page.limit())
902 .offset(page.offset())
903 .load(&mut conn)
904 .await
905 .map_err(|e| store_err(&e))?;
906
907 Ok(Page::new(
908 rows.into_iter().map(Notification::from).collect(),
909 total,
910 page,
911 ))
912 }
913
914 async fn unread_count(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
915 use notifications::dsl;
916 let mut conn = self.conn().await?;
917 let count: i64 = dsl::notifications
918 .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
919 .filter(dsl::recipient_id.eq(recipient_id))
920 .filter(dsl::read_at.is_null())
921 .count()
922 .get_result(&mut conn)
923 .await
924 .map_err(|e| store_err(&e))?;
925 Ok(u64::try_from(count).unwrap_or(0))
926 }
927
928 async fn mark_read(
929 &self,
930 id: i64,
931 recipient_id: Option<i64>,
932 ) -> Result<u64, NotificationStoreError> {
933 use notifications::dsl;
934 let mut conn = self.conn().await?;
935 let now = crate::time::ambient_now();
936 let affected = match recipient_id {
940 Some(rid) => {
941 diesel::update(
942 dsl::notifications
943 .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
944 .filter(dsl::id.eq(id))
945 .filter(dsl::recipient_id.eq(rid))
946 .filter(dsl::read_at.is_null()),
947 )
948 .set(dsl::read_at.eq(Some(now)))
949 .execute(&mut conn)
950 .await
951 }
952 None => {
953 diesel::update(
954 dsl::notifications
955 .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
956 .filter(dsl::id.eq(id))
957 .filter(dsl::read_at.is_null()),
958 )
959 .set(dsl::read_at.eq(Some(now)))
960 .execute(&mut conn)
961 .await
962 }
963 }
964 .map_err(|e| store_err(&e))?;
965 Ok(affected as u64)
966 }
967
968 async fn mark_all_read(&self, recipient_id: i64) -> Result<u64, NotificationStoreError> {
969 use notifications::dsl;
970 let mut conn = self.conn().await?;
971 let affected = diesel::update(
972 dsl::notifications
973 .filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
974 .filter(dsl::recipient_id.eq(recipient_id))
975 .filter(dsl::read_at.is_null()),
976 )
977 .set(dsl::read_at.eq(Some(crate::time::ambient_now())))
978 .execute(&mut conn)
979 .await
980 .map_err(|e| store_err(&e))?;
981 Ok(affected as u64)
982 }
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989 use serde_json::json;
990
991 #[test]
992 fn list_filter_defaults_to_newest_first() {
993 let filter = ListFilter::from_query(&ListQuery::default());
994 assert!(!filter.unread_only);
995 assert!(filter.kind.is_none());
996 assert_eq!(filter.sort, FeedSort::Id);
997 assert_eq!(filter.dir, SortDir::Desc);
998 }
999
1000 #[test]
1001 fn list_filter_parses_unread_and_kind() {
1002 let query = ListQuery::new(None, SortDir::Asc, &[("unread", "1"), ("kind", "like")]);
1003 let filter = ListFilter::from_query(&query);
1004 assert!(filter.unread_only);
1005 assert_eq!(filter.kind.as_deref(), Some("like"));
1006 }
1007
1008 #[test]
1009 fn list_filter_ignores_unknown_sort_and_filters() {
1010 let query = ListQuery::new(Some("payload"), SortDir::Asc, &[("recipient_id", "9")]);
1011 let filter = ListFilter::from_query(&query);
1012 assert!(!filter.unread_only);
1013 assert!(filter.kind.is_none());
1014 assert_eq!(filter.sort, FeedSort::Id);
1015 assert_eq!(
1016 filter.dir,
1017 SortDir::Desc,
1018 "unknown sort falls back to newest-first"
1019 );
1020 }
1021
1022 #[test]
1023 fn explicit_sort_uses_requested_direction() {
1024 let query = ListQuery::new(Some("created_at"), SortDir::Asc, &[]);
1025 let filter = ListFilter::from_query(&query);
1026 assert_eq!(filter.sort, FeedSort::CreatedAt);
1027 assert_eq!(filter.dir, SortDir::Asc);
1028 }
1029
1030 #[tokio::test]
1031 async fn memory_store_round_trips_null_payload() {
1032 let notifications = Notifications::new(MemoryNotificationStore::new());
1035 let n = notifications
1036 .notify(1, "k", Value::Null)
1037 .await
1038 .expect("notify");
1039 assert_eq!(n.payload, Value::Null);
1040 let feed = notifications
1041 .list(1, &ListQuery::default(), &PageRequest::default())
1042 .await
1043 .expect("list");
1044 assert_eq!(feed.content[0].payload, Value::Null);
1045 }
1046
1047 #[tokio::test]
1048 async fn memory_store_round_trips_payload() {
1049 let notifications = Notifications::new(MemoryNotificationStore::new());
1050 let n = notifications
1051 .notify(1, "k", json!({"deep": {"value": [1, 2, 3]}}))
1052 .await
1053 .expect("notify");
1054 assert_eq!(n.payload, json!({"deep": {"value": [1, 2, 3]}}));
1055 }
1056}