test(indexeddb): add IndexedDB-specific integration tests

Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
This commit is contained in:
Michael Goldenberg
2025-07-01 23:02:12 -04:00
committed by Ivan Enderlin
parent 2e7721b36c
commit c5436ed73e
2 changed files with 590 additions and 0 deletions

View File

@@ -0,0 +1,552 @@
// Copyright 2025 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License
use assert_matches::assert_matches;
use matrix_sdk_base::{
event_cache::{
store::{
integration_tests::{check_test_event, make_test_event},
EventCacheStore,
},
Gap,
},
linked_chunk::{ChunkContent, ChunkIdentifier, LinkedChunkId, Position, Update},
};
use matrix_sdk_test::DEFAULT_TEST_ROOM_ID;
use ruma::room_id;
use crate::event_cache_store::{
transaction::IndexeddbEventCacheStoreTransactionError, IndexeddbEventCacheStore,
IndexeddbEventCacheStoreError,
};
pub async fn test_linked_chunk_new_items_chunk(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk {
previous: None,
new: ChunkIdentifier::new(42),
next: None, // Note: the store must link the next entry itself.
},
Update::NewItemsChunk {
previous: Some(ChunkIdentifier::new(42)),
new: ChunkIdentifier::new(13),
next: Some(ChunkIdentifier::new(37)), /* But it's fine to explicitly pass
* the next link ahead of time. */
},
Update::NewItemsChunk {
previous: Some(ChunkIdentifier::new(13)),
new: ChunkIdentifier::new(37),
next: None,
},
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 3);
// Chunks are ordered from smaller to bigger IDs.
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(13));
assert_eq!(c.previous, Some(ChunkIdentifier::new(42)));
assert_eq!(c.next, Some(ChunkIdentifier::new(37)));
assert_matches!(c.content, ChunkContent::Items(events) => {
assert!(events.is_empty());
});
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(37));
assert_eq!(c.previous, Some(ChunkIdentifier::new(13)));
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert!(events.is_empty());
});
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, Some(ChunkIdentifier::new(13)));
assert_matches!(c.content, ChunkContent::Items(events) => {
assert!(events.is_empty());
});
}
pub async fn test_add_gap_chunk_and_delete_it_immediately(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![Update::NewGapChunk {
previous: None,
new: ChunkIdentifier::new(1),
next: None,
gap: Gap { prev_token: "cheese".to_owned() },
}];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let updates = vec![
Update::NewGapChunk {
previous: Some(ChunkIdentifier::new(1)),
new: ChunkIdentifier::new(3),
next: None,
gap: Gap { prev_token: "milk".to_owned() },
},
Update::RemoveChunk(ChunkIdentifier::new(3)),
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
}
pub async fn test_linked_chunk_new_gap_chunk(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![Update::NewGapChunk {
previous: None,
new: ChunkIdentifier::new(42),
next: None,
gap: Gap { prev_token: "raclette".to_owned() },
}];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
// Chunks are ordered from smaller to bigger IDs.
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Gap(gap) => {
assert_eq!(gap.prev_token, "raclette");
});
}
pub async fn test_linked_chunk_replace_item(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")],
},
Update::ReplaceItem {
at: Position::new(ChunkIdentifier::new(42), 1),
item: make_test_event(room_id, "yolo"),
},
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 2);
check_test_event(&events[0], "hello");
check_test_event(&events[1], "yolo");
});
}
pub async fn test_linked_chunk_remove_chunk(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewGapChunk {
previous: None,
new: ChunkIdentifier::new(42),
next: None,
gap: Gap { prev_token: "raclette".to_owned() },
},
Update::NewGapChunk {
previous: Some(ChunkIdentifier::new(42)),
new: ChunkIdentifier::new(43),
next: None,
gap: Gap { prev_token: "fondue".to_owned() },
},
Update::NewGapChunk {
previous: Some(ChunkIdentifier::new(43)),
new: ChunkIdentifier::new(44),
next: None,
gap: Gap { prev_token: "tartiflette".to_owned() },
},
Update::RemoveChunk(ChunkIdentifier::new(43)),
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 2);
// Chunks are ordered from smaller to bigger IDs.
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, Some(ChunkIdentifier::new(44)));
assert_matches!(c.content, ChunkContent::Gap(gap) => {
assert_eq!(gap.prev_token, "raclette");
});
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(44));
assert_eq!(c.previous, Some(ChunkIdentifier::new(42)));
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Gap(gap) => {
assert_eq!(gap.prev_token, "tartiflette");
});
}
pub async fn test_linked_chunk_push_items(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")],
},
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 2),
items: vec![make_test_event(room_id, "who?")],
},
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 3);
check_test_event(&events[0], "hello");
check_test_event(&events[1], "world");
check_test_event(&events[2], "who?");
});
}
pub async fn test_linked_chunk_remove_item(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![make_test_event(room_id, "hello"), make_test_event(room_id, "world")],
},
Update::RemoveItem { at: Position::new(ChunkIdentifier::new(42), 0) },
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 1);
check_test_event(&events[0], "world");
});
}
pub async fn test_linked_chunk_detach_last_items(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![
make_test_event(room_id, "hello"),
make_test_event(room_id, "world"),
make_test_event(room_id, "howdy"),
],
},
Update::DetachLastItems { at: Position::new(ChunkIdentifier::new(42), 1) },
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 1);
check_test_event(&events[0], "hello");
});
}
pub async fn test_linked_chunk_start_end_reattach_items(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
// Same updates and checks as test_linked_chunk_push_items, but with extra
// `StartReattachItems` and `EndReattachItems` updates, which must have no
// effects.
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![
make_test_event(room_id, "hello"),
make_test_event(room_id, "world"),
make_test_event(room_id, "howdy"),
],
},
Update::StartReattachItems,
Update::EndReattachItems,
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let mut chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert_eq!(chunks.len(), 1);
let c = chunks.remove(0);
assert_eq!(c.identifier, ChunkIdentifier::new(42));
assert_eq!(c.previous, None);
assert_eq!(c.next, None);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 3);
check_test_event(&events[0], "hello");
check_test_event(&events[1], "world");
check_test_event(&events[2], "howdy");
});
}
pub async fn test_linked_chunk_clear(store: IndexeddbEventCacheStore) {
let room_id = &DEFAULT_TEST_ROOM_ID;
let linked_chunk_id = LinkedChunkId::Room(room_id);
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::NewGapChunk {
previous: Some(ChunkIdentifier::new(42)),
new: ChunkIdentifier::new(54),
next: None,
gap: Gap { prev_token: "fondue".to_owned() },
},
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![
make_test_event(room_id, "hello"),
make_test_event(room_id, "world"),
make_test_event(room_id, "howdy"),
],
},
Update::Clear,
];
store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap();
let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert!(chunks.is_empty());
}
pub async fn test_linked_chunk_multiple_rooms(store: IndexeddbEventCacheStore) {
// Check that applying updates to one room doesn't affect the others.
// Use the same chunk identifier in both rooms to battle-test search.
let room_id1 = room_id!("!realcheeselovers:raclette.fr");
let linked_chunk_id1 = LinkedChunkId::Room(room_id1);
let updates1 = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![
make_test_event(room_id1, "best cheese is raclette"),
make_test_event(room_id1, "obviously"),
],
},
];
store.handle_linked_chunk_updates(linked_chunk_id1, updates1).await.unwrap();
let room_id2 = room_id!("!realcheeselovers:fondue.ch");
let linked_chunk_id2 = LinkedChunkId::Room(room_id2);
let updates2 = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::PushItems {
at: Position::new(ChunkIdentifier::new(42), 0),
items: vec![make_test_event(room_id2, "beaufort is the best")],
},
];
store.handle_linked_chunk_updates(linked_chunk_id2, updates2).await.unwrap();
// Check chunks from room 1.
let mut chunks1 = store.load_all_chunks(linked_chunk_id1).await.unwrap();
assert_eq!(chunks1.len(), 1);
let c = chunks1.remove(0);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 2);
check_test_event(&events[0], "best cheese is raclette");
check_test_event(&events[1], "obviously");
});
// Check chunks from room 2.
let mut chunks2 = store.load_all_chunks(linked_chunk_id2).await.unwrap();
assert_eq!(chunks2.len(), 1);
let c = chunks2.remove(0);
assert_matches!(c.content, ChunkContent::Items(events) => {
assert_eq!(events.len(), 1);
check_test_event(&events[0], "beaufort is the best");
});
}
pub async fn test_linked_chunk_update_is_a_transaction(store: IndexeddbEventCacheStore) {
let linked_chunk_id = LinkedChunkId::Room(*DEFAULT_TEST_ROOM_ID);
// Trigger a violation of the unique constraint on the (room id, chunk id)
// couple.
let updates = vec![
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
Update::NewItemsChunk { previous: None, new: ChunkIdentifier::new(42), next: None },
];
let err = store.handle_linked_chunk_updates(linked_chunk_id, updates).await.unwrap_err();
// The operation fails with a constraint violation error.
assert_matches!(
err,
IndexeddbEventCacheStoreError::Transaction(
IndexeddbEventCacheStoreTransactionError::DomException { .. }
)
);
// If the updates have been handled transactionally, then no new chunks should
// have been added; failure of the second update leads to the first one being
// rolled back.
let chunks = store.load_all_chunks(linked_chunk_id).await.unwrap();
assert!(chunks.is_empty());
}
/// Macro for generating tests for IndexedDB implementation of
/// [`EventCacheStore`]
///
/// The enclosing module must provide a function for constructing an
/// [`EventCacheStore`] which will be used in the generated tests. The function
/// must have the signature shown in the example below.
///
///
/// ## Usage Example:
/// ```no_run
/// # use matrix_sdk_base::event_cache::store::{
/// # EventCacheStore,
/// # EventCacheStoreError,
/// # MemoryStore as MyStore,
/// # };
///
/// #[cfg(test)]
/// mod tests {
/// use super::{EventCacheStore, EventCacheStoreResult, MyStore};
///
/// async fn get_event_cache_store(
/// ) -> Result<impl EventCacheStore, EventCacheStoreError> {
/// Ok(MyStore::new())
/// }
///
/// event_cache_store_integration_tests!();
/// }
/// ```
#[macro_export]
macro_rules! indexeddb_event_cache_store_integration_tests {
() => {
mod indexeddb_event_cache_store_integration_tests {
use matrix_sdk_test::async_test;
use super::get_event_cache_store;
#[async_test]
async fn test_linked_chunk_new_items_chunk() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_new_items_chunk(store).await
}
#[async_test]
async fn test_add_gap_chunk_and_delete_it_immediately() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_add_gap_chunk_and_delete_it_immediately(
store,
)
.await
}
#[async_test]
async fn test_linked_chunk_new_gap_chunk() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_new_gap_chunk(store).await
}
#[async_test]
async fn test_linked_chunk_replace_item() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_replace_item(store).await
}
#[async_test]
async fn test_linked_chunk_remove_chunk() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_remove_chunk(store).await
}
#[async_test]
async fn test_linked_chunk_push_items() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_push_items(store).await
}
#[async_test]
async fn test_linked_chunk_remove_item() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_remove_item(store).await
}
#[async_test]
async fn test_linked_chunk_detach_last_items() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_detach_last_items(store).await
}
#[async_test]
async fn test_linked_chunk_start_end_reattach_items() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_start_end_reattach_items(store)
.await
}
#[async_test]
async fn test_linked_chunk_clear() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_clear(store).await
}
#[async_test]
async fn test_linked_chunk_multiple_rooms() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_multiple_rooms(store).await
}
#[async_test]
async fn test_linked_chunk_update_is_a_transaction() {
let store = get_event_cache_store().await.expect("Failed to get event cache store");
$crate::event_cache_store::integration_tests::test_linked_chunk_update_is_a_transaction(store)
.await
}
}
};
}

View File

@@ -42,6 +42,8 @@ use crate::event_cache_store::{
mod builder;
mod error;
#[cfg(test)]
mod integration_tests;
mod migrations;
mod serializer;
mod transaction;
@@ -462,3 +464,39 @@ impl_event_cache_store! {
.map_err(IndexeddbEventCacheStoreError::MemoryStore)
}
}
#[cfg(test)]
mod tests {
use matrix_sdk_base::event_cache::store::{EventCacheStore, EventCacheStoreError};
use uuid::Uuid;
use crate::{
event_cache_store::IndexeddbEventCacheStore, indexeddb_event_cache_store_integration_tests,
};
mod unencrypted {
use super::*;
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
async fn get_event_cache_store() -> Result<IndexeddbEventCacheStore, EventCacheStoreError> {
let name = format!("test-event-cache-store-{}", Uuid::new_v4().as_hyphenated());
Ok(IndexeddbEventCacheStore::builder().database_name(name).build().await?)
}
indexeddb_event_cache_store_integration_tests!();
}
mod encrypted {
use super::*;
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
async fn get_event_cache_store() -> Result<IndexeddbEventCacheStore, EventCacheStoreError> {
let name = format!("test-event-cache-store-{}", Uuid::new_v4().as_hyphenated());
Ok(IndexeddbEventCacheStore::builder().database_name(name).build().await?)
}
indexeddb_event_cache_store_integration_tests!();
}
}