use std::time::Duration;
use chrono::{DateTime, TimeDelta, Utc};
use serde_json::json;
use sqlx::types::Json;
use tokio::sync::{Mutex, MutexGuard};
use super::dialect::{NotificationPool, sql, stored_time};
use super::notification::DatabaseContent;
use super::store::DatabaseNotifications;
use super::stored::{ID_BYTES, NotificationId, StoredNotification};
static TABLE: Mutex<()> = Mutex::const_new(());
const CONNECTIONS: u32 = 4;
const PAGE: u32 = 50;
const SLACK: TimeDelta = TimeDelta::milliseconds(2);
#[cfg(feature = "db-postgres")]
const SET_CREATED_AT: &str = "UPDATE arcature_notifications SET created_at = $1 WHERE id = $2";
#[cfg(not(feature = "db-postgres"))]
const SET_CREATED_AT: &str = "UPDATE arcature_notifications SET created_at = ? WHERE id = ?";
#[cfg(feature = "db-postgres")]
const SET_READ_AT: &str = "UPDATE arcature_notifications SET read_at = $1 WHERE id = $2";
#[cfg(not(feature = "db-postgres"))]
const SET_READ_AT: &str = "UPDATE arcature_notifications SET read_at = ? WHERE id = ?";
struct Fixture {
store: DatabaseNotifications,
_exclusive: MutexGuard<'static, ()>,
}
impl Fixture {
fn store(&self) -> &DatabaseNotifications {
&self.store
}
fn pool(&self) -> &NotificationPool {
self.store.pool()
}
async fn ids(&self) -> Vec<Vec<u8>> {
sqlx::query_scalar::<_, Vec<u8>>("SELECT id FROM arcature_notifications")
.fetch_all(self.pool())
.await
.expect("read arcature_notifications")
}
async fn rows(&self) -> usize {
self.ids().await.len()
}
async fn insert_raw(
&self,
id: [u8; ID_BYTES],
notifiable_key: &str,
kind: &str,
created_at: DateTime<Utc>,
) -> u64 {
sqlx::query(sql::INSERT_NEW)
.bind(id.to_vec())
.bind(notifiable_key)
.bind(kind)
.bind(Json(json!({})))
.bind(stored_time(created_at))
.execute(self.pool())
.await
.expect("insert a notification by hand")
.rows_affected()
}
async fn set_created_at(&self, id: NotificationId, at: DateTime<Utc>) {
let affected = sqlx::query(SET_CREATED_AT)
.bind(stored_time(at))
.bind(id.as_bytes().to_vec())
.execute(self.pool())
.await
.expect("move the creation stamp")
.rows_affected();
assert_eq!(affected, 1, "the notification to re-date was not there");
}
async fn set_read_at(&self, id: NotificationId, at: DateTime<Utc>) {
let affected = sqlx::query(SET_READ_AT)
.bind(stored_time(at))
.bind(id.as_bytes().to_vec())
.execute(self.pool())
.await
.expect("move the read stamp")
.rows_affected();
assert_eq!(affected, 1, "the notification to re-date was not there");
}
async fn inbox(&self, notifiable_key: &str) -> Vec<StoredNotification> {
self.store()
.inbox(notifiable_key, PAGE)
.await
.expect("inbox")
}
async fn unread(&self, notifiable_key: &str) -> Vec<StoredNotification> {
self.store()
.unread(notifiable_key, PAGE)
.await
.expect("unread")
}
}
async fn notifications() -> Option<Fixture> {
use crate::test_kit::database::{
REQUIRE_TEST_DB_VAR, TEST_DB_URL_VAR, TestDatabaseError, test_database_required,
test_database_url,
};
let url = match test_database_url() {
Ok(url) => url,
Err(TestDatabaseError::NotConfigured) => {
assert!(
!test_database_required(),
"{REQUIRE_TEST_DB_VAR} is set, so {TEST_DB_URL_VAR} has to be too"
);
return None;
}
Err(error) => panic!("{error}"),
};
let exclusive = TABLE.lock().await;
let pool = sqlx::pool::PoolOptions::<crate::database::Driver>::new()
.max_connections(CONNECTIONS)
.acquire_timeout(Duration::from_secs(30))
.connect(&url)
.await
.unwrap_or_else(|error| panic!("connect to the test database: {error}"));
let store = DatabaseNotifications::new(pool);
store
.migrate()
.await
.unwrap_or_else(|error| panic!("migrate arcature_notifications: {error}"));
sqlx::query("DELETE FROM arcature_notifications")
.execute(store.pool())
.await
.unwrap_or_else(|error| panic!("empty arcature_notifications: {error}"));
Some(Fixture {
store,
_exclusive: exclusive,
})
}
macro_rules! with_notifications {
(|$fixture:ident| $body:block) => {
let Some($fixture) = notifications().await else {
return;
};
$body
};
}
fn same_instant(left: DateTime<Utc>, right: DateTime<Utc>, what: &str) {
assert!(
(left - right).abs() <= SLACK,
"{what}: {left} and {right} are more than {SLACK:?} apart"
);
}
fn kinds(listed: &[StoredNotification]) -> Vec<&str> {
listed.iter().map(StoredNotification::kind).collect()
}
#[tokio::test]
async fn a_stored_notification_comes_back_out_of_the_inbox() {
with_notifications!(|fixture| {
let content = DatabaseContent::new("invoice.paid", json!({"amount": 1200}));
let stored = fixture
.store()
.store("user:42", &content)
.await
.expect("store");
assert_eq!(stored.notifiable_key(), "user:42");
assert_eq!(stored.kind(), "invoice.paid");
assert!(stored.read_at().is_none());
assert!(!stored.is_read());
let listed = fixture.inbox("user:42").await;
assert_eq!(listed.len(), 1);
let read_back = &listed[0];
assert_eq!(read_back.id(), stored.id());
assert_eq!(read_back.notifiable_key(), "user:42");
assert_eq!(read_back.kind(), "invoice.paid");
assert_eq!(read_back.data(), &json!({"amount": 1200}));
assert!(read_back.read_at().is_none());
same_instant(read_back.created_at(), stored.created_at(), "created_at");
});
}
#[tokio::test]
async fn the_payload_survives_the_round_trip() {
with_notifications!(|fixture| {
let payload = json!({
"invoice": {"id": "INV-7", "total": 12.5, "paid": true},
"lines": [1, 2, 3],
"note": "p\u{159}\u{ed}li\u{161} \u{17e}lu\u{165}ou\u{10d}k\u{fd}",
"cancelled_at": null,
});
let stored = fixture
.store()
.store(
"user:42",
&DatabaseContent::new("invoice.paid", payload.clone()),
)
.await
.expect("store");
assert_eq!(stored.data(), &payload, "the value store returned");
let listed = fixture.inbox("user:42").await;
assert_eq!(listed[0].data(), &payload, "the value the database held");
});
}
#[tokio::test]
async fn the_inbox_is_newest_first_and_ties_break_on_id() {
with_notifications!(|fixture| {
let now = Utc::now();
fixture
.insert_raw(
[3u8; ID_BYTES],
"user:42",
"oldest",
now - TimeDelta::seconds(2),
)
.await;
fixture
.insert_raw([1u8; ID_BYTES], "user:42", "newest-low-id", now)
.await;
fixture
.insert_raw(
[2u8; ID_BYTES],
"user:42",
"middle",
now - TimeDelta::seconds(1),
)
.await;
fixture
.insert_raw([4u8; ID_BYTES], "user:42", "newest-high-id", now)
.await;
assert_eq!(
kinds(&fixture.inbox("user:42").await),
["newest-low-id", "newest-high-id", "middle", "oldest"]
);
assert_eq!(
kinds(&fixture.unread("user:42").await),
["newest-low-id", "newest-high-id", "middle", "oldest"]
);
});
}
#[tokio::test]
async fn the_inbox_is_scoped_to_one_recipient_and_honours_the_limit() {
with_notifications!(|fixture| {
let now = Utc::now();
for (index, id) in [(0i64, 1u8), (1, 2), (2, 3), (3, 4)] {
fixture
.insert_raw(
[id; ID_BYTES],
"user:42",
&format!("mine-{index}"),
now - TimeDelta::seconds(index),
)
.await;
}
fixture
.insert_raw([9u8; ID_BYTES], "user:7", "theirs", now)
.await;
assert_eq!(
kinds(&fixture.store().inbox("user:42", 3).await.expect("inbox")),
["mine-0", "mine-1", "mine-2"],
"the limit must take the newest, not an arbitrary three"
);
assert_eq!(kinds(&fixture.inbox("user:7").await), ["theirs"]);
assert!(fixture.inbox("user:nobody").await.is_empty());
assert_eq!(fixture.rows().await, 5);
});
}
#[tokio::test]
async fn the_badge_agrees_with_the_unread_listing() {
with_notifications!(|fixture| {
let mut ids = Vec::new();
for index in 0..3 {
ids.push(
fixture
.store()
.store(
"user:42",
&DatabaseContent::new(format!("kind-{index}"), json!({})),
)
.await
.expect("store")
.id(),
);
}
fixture
.store()
.store("user:7", &DatabaseContent::new("theirs", json!({})))
.await
.expect("store");
assert_eq!(
fixture
.store()
.unread_count("user:42")
.await
.expect("count"),
3
);
assert_eq!(fixture.unread("user:42").await.len(), 3);
assert!(
fixture
.store()
.mark_read("user:42", ids[0])
.await
.expect("mark read")
);
assert_eq!(
fixture
.store()
.unread_count("user:42")
.await
.expect("count"),
2
);
assert_eq!(fixture.unread("user:42").await.len(), 2);
assert_eq!(
fixture.inbox("user:42").await.len(),
3,
"still in the inbox"
);
assert_eq!(
fixture.store().unread_count("user:7").await.expect("count"),
1
);
let marked = fixture
.store()
.mark_all_read("user:42")
.await
.expect("mark all read");
assert_eq!(marked, 2, "the one already read is not marked twice");
assert_eq!(
fixture
.store()
.unread_count("user:42")
.await
.expect("count"),
0
);
assert!(fixture.unread("user:42").await.is_empty());
assert_eq!(
fixture.store().unread_count("user:7").await.expect("count"),
1
);
assert_eq!(
fixture
.store()
.unread_count("user:nobody")
.await
.expect("count"),
0
);
});
}
#[tokio::test]
async fn marking_read_twice_keeps_the_first_receipt() {
with_notifications!(|fixture| {
let stored = fixture
.store()
.store("user:42", &DatabaseContent::new("invoice.paid", json!({})))
.await
.expect("store");
assert!(
fixture
.store()
.mark_read("user:42", stored.id())
.await
.expect("mark read")
);
let first = fixture.inbox("user:42").await[0]
.read_at()
.expect("read_at is set once it is read");
fixture
.set_read_at(stored.id(), first - TimeDelta::seconds(3600))
.await;
let backdated = fixture.inbox("user:42").await[0]
.read_at()
.expect("still read");
assert!(
!fixture
.store()
.mark_read("user:42", stored.id())
.await
.expect("mark read"),
"a second marking has nothing to mark"
);
let after = fixture.inbox("user:42").await[0]
.read_at()
.expect("still read");
same_instant(after, backdated, "read_at after a second marking");
assert!(fixture.unread("user:42").await.is_empty());
});
}
#[tokio::test]
async fn marking_read_reaches_one_row_and_only_its_owner() {
with_notifications!(|fixture| {
let mine = fixture
.store()
.store("user:42", &DatabaseContent::new("mine", json!({})))
.await
.expect("store");
fixture
.store()
.store("user:42", &DatabaseContent::new("also mine", json!({})))
.await
.expect("store");
let theirs = fixture
.store()
.store("user:7", &DatabaseContent::new("theirs", json!({})))
.await
.expect("store");
assert!(
!fixture
.store()
.mark_read("user:42", theirs.id())
.await
.expect("mark read"),
"a foreign id must not match"
);
assert!(
!fixture
.store()
.mark_read("user:42", NotificationId::from_bytes([0u8; ID_BYTES]))
.await
.expect("mark read"),
"an id nobody minted must not match"
);
assert_eq!(
fixture.store().unread_count("user:7").await.expect("count"),
1
);
assert!(
fixture
.store()
.mark_read("user:42", mine.id())
.await
.expect("mark read")
);
assert_eq!(
kinds(&fixture.unread("user:42").await),
["also mine"],
"only the named row was marked"
);
});
}
#[tokio::test]
async fn deleting_reaches_one_row_and_only_its_owner() {
with_notifications!(|fixture| {
let mine = fixture
.store()
.store("user:42", &DatabaseContent::new("mine", json!({})))
.await
.expect("store");
let theirs = fixture
.store()
.store("user:7", &DatabaseContent::new("theirs", json!({})))
.await
.expect("store");
assert!(
!fixture
.store()
.delete("user:42", theirs.id())
.await
.expect("delete"),
"a foreign id must not match"
);
assert_eq!(fixture.rows().await, 2, "the foreign row survived");
assert!(
fixture
.store()
.delete("user:42", mine.id())
.await
.expect("delete")
);
assert!(
!fixture
.store()
.delete("user:42", mine.id())
.await
.expect("delete"),
"a second delete has nothing to delete"
);
assert_eq!(fixture.ids().await, vec![theirs.id().as_bytes().to_vec()]);
});
}
#[tokio::test]
async fn clearing_an_inbox_leaves_every_other_inbox() {
with_notifications!(|fixture| {
for index in 0..3 {
fixture
.store()
.store(
"user:42",
&DatabaseContent::new(format!("kind-{index}"), json!({})),
)
.await
.expect("store");
}
let theirs = fixture
.store()
.store("user:7", &DatabaseContent::new("theirs", json!({})))
.await
.expect("store");
let cleared = fixture
.store()
.delete_all_for("user:42")
.await
.expect("delete all");
assert_eq!(cleared, 3);
assert!(fixture.inbox("user:42").await.is_empty());
assert_eq!(fixture.ids().await, vec![theirs.id().as_bytes().to_vec()]);
assert_eq!(
fixture
.store()
.delete_all_for("user:42")
.await
.expect("delete all"),
0
);
});
}
#[tokio::test]
async fn pruning_reaches_read_rows_in_every_inbox_and_never_an_unread_one() {
with_notifications!(|fixture| {
let now = Utc::now();
let cutoff = now - TimeDelta::seconds(3600);
let old_mine = fixture
.store()
.store("user:42", &DatabaseContent::new("old and read", json!({})))
.await
.expect("store");
let old_theirs = fixture
.store()
.store(
"user:7",
&DatabaseContent::new("theirs, old and read", json!({})),
)
.await
.expect("store");
let recently_read = fixture
.store()
.store(
"user:42",
&DatabaseContent::new("read after the cutoff", json!({})),
)
.await
.expect("store");
let ancient_unread = fixture
.store()
.store(
"user:42",
&DatabaseContent::new("ancient and unread", json!({})),
)
.await
.expect("store");
fixture
.set_read_at(old_mine.id(), cutoff - TimeDelta::seconds(60))
.await;
fixture
.set_read_at(old_theirs.id(), cutoff - TimeDelta::seconds(60))
.await;
fixture
.set_read_at(recently_read.id(), cutoff + TimeDelta::seconds(60))
.await;
fixture
.set_created_at(ancient_unread.id(), cutoff - TimeDelta::seconds(86_400))
.await;
let pruned = fixture
.store()
.prune_read_before(cutoff)
.await
.expect("prune");
assert_eq!(
pruned, 2,
"the sweep crosses inboxes: one row of user:42's and one of user:7's"
);
let mut left = fixture.ids().await;
left.sort_unstable();
let mut expected = vec![
recently_read.id().as_bytes().to_vec(),
ancient_unread.id().as_bytes().to_vec(),
];
expected.sort_unstable();
assert_eq!(left, expected);
assert!(
fixture.inbox("user:7").await.is_empty(),
"the other recipient's read row went too"
);
assert_eq!(
kinds(&fixture.unread("user:42").await),
["ancient and unread"],
"an unread row survives however old it is"
);
});
}
#[tokio::test]
async fn an_id_that_is_already_taken_is_reported_as_zero_rows_rather_than_an_error() {
with_notifications!(|fixture| {
let now = Utc::now();
assert_eq!(
fixture
.insert_raw([7u8; ID_BYTES], "user:42", "first", now)
.await,
1
);
assert_eq!(
fixture
.insert_raw([7u8; ID_BYTES], "user:7", "second", now)
.await,
0,
"a taken id must be reported as zero rows, not raised as an error"
);
assert_eq!(kinds(&fixture.inbox("user:42").await), ["first"]);
assert!(fixture.inbox("user:7").await.is_empty());
});
}
#[tokio::test]
async fn migrating_twice_is_a_no_op() {
with_notifications!(|fixture| {
let stored = fixture
.store()
.store("user:42", &DatabaseContent::new("before", json!({"n": 1})))
.await
.expect("store");
fixture.store().migrate().await.expect("migrate again");
assert_eq!(fixture.rows().await, 1, "the second run kept the row");
let listed = fixture.inbox("user:42").await;
assert_eq!(listed[0].id(), stored.id());
assert_eq!(listed[0].data(), &json!({"n": 1}));
fixture
.store()
.store("user:42", &DatabaseContent::new("after", json!({})))
.await
.expect("store after a second migration");
assert_eq!(fixture.rows().await, 2);
});
}