- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
639 lines
19 KiB
Rust
639 lines
19 KiB
Rust
//! L12 tests for the memory use cases (ARCHITECTURE §14.5.1) with in-memory
|
|
//! port fakes (no real store/FS): CRUD (`CreateMemory`, `UpdateMemory`,
|
|
//! `GetMemory`, `ListMemories`, `DeleteMemory`) asserting the `MemorySaved` /
|
|
//! `MemoryDeleted` events, and the read-only navigation helpers
|
|
//! (`ReadMemoryIndex`, `ResolveMemoryLinks`) asserting they emit nothing.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::events::DomainEvent;
|
|
use domain::ports::{EventBus, EventStream, MemoryError, MemoryQuery, MemoryRecall, MemoryStore};
|
|
use domain::{
|
|
MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
|
ProjectPath,
|
|
};
|
|
|
|
use application::{
|
|
CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput,
|
|
ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory,
|
|
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory,
|
|
UpdateMemoryInput,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// In-memory memory store keyed by slug. Index and links are derived from the
|
|
/// stored notes (mirroring the real adapter), so navigation reads stay honest.
|
|
#[derive(Clone, Default)]
|
|
struct FakeMemories(Arc<Mutex<Vec<Memory>>>);
|
|
|
|
#[async_trait]
|
|
impl MemoryStore for FakeMemories {
|
|
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|m| m.slug() == slug)
|
|
.cloned()
|
|
.ok_or(MemoryError::NotFound)
|
|
}
|
|
|
|
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
|
let mut v = self.0.lock().unwrap();
|
|
if let Some(slot) = v.iter_mut().find(|m| m.slug() == memory.slug()) {
|
|
*slot = memory.clone();
|
|
} else {
|
|
v.push(memory.clone());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> {
|
|
let mut v = self.0.lock().unwrap();
|
|
let before = v.len();
|
|
v.retain(|m| m.slug() != slug);
|
|
if v.len() == before {
|
|
return Err(MemoryError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
|
Ok(self
|
|
.0
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.map(Memory::index_entry)
|
|
.collect())
|
|
}
|
|
|
|
async fn resolve_links(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
slug: &MemorySlug,
|
|
) -> Result<Vec<MemoryLink>, MemoryError> {
|
|
let v = self.0.lock().unwrap();
|
|
let source = v
|
|
.iter()
|
|
.find(|m| m.slug() == slug)
|
|
.ok_or(MemoryError::NotFound)?;
|
|
// Mirror the adapter: drop links whose target note does not exist.
|
|
Ok(source
|
|
.outgoing_links()
|
|
.into_iter()
|
|
.filter(|l| v.iter().any(|m| m.slug() == &l.target))
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn root() -> ProjectPath {
|
|
ProjectPath::new("/home/me/demo").unwrap()
|
|
}
|
|
|
|
fn slug(s: &str) -> MemorySlug {
|
|
MemorySlug::new(s).unwrap()
|
|
}
|
|
|
|
/// Builds and persists a note directly through the store (bypassing use cases).
|
|
async fn seed(store: &FakeMemories, name: &str, body: &str) {
|
|
let memory = Memory::new(
|
|
MemoryFrontmatter {
|
|
name: slug(name),
|
|
description: format!("{name} hook"),
|
|
r#type: MemoryType::Project,
|
|
},
|
|
MarkdownDoc::new(body),
|
|
)
|
|
.unwrap();
|
|
store.save(&root(), &memory).await.unwrap();
|
|
}
|
|
|
|
fn saved_slugs(events: &[DomainEvent]) -> Vec<MemorySlug> {
|
|
events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
DomainEvent::MemorySaved { slug } => Some(slug.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn deleted_slugs(events: &[DomainEvent]) -> Vec<MemorySlug> {
|
|
events
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
DomainEvent::MemoryDeleted { slug } => Some(slug.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CreateMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn create_persists_note_and_emits_memory_saved_with_slug() {
|
|
let store = FakeMemories::default();
|
|
let bus = SpyBus::default();
|
|
let out = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(CreateMemoryInput {
|
|
project_root: root(),
|
|
name: "design-decision".to_owned(),
|
|
description: "why we chose hexagonal".to_owned(),
|
|
r#type: MemoryType::Project,
|
|
content: "# body".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.memory.slug(), &slug("design-decision"));
|
|
// Persisted.
|
|
let listed = store.list(&root()).await.unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].slug(), &slug("design-decision"));
|
|
// Emits exactly one MemorySaved with the right slug.
|
|
assert_eq!(saved_slugs(&bus.events()), vec![slug("design-decision")]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_non_kebab_name_as_invalid() {
|
|
let store = FakeMemories::default();
|
|
let bus = SpyBus::default();
|
|
let err = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(CreateMemoryInput {
|
|
project_root: root(),
|
|
name: "Not Kebab".to_owned(),
|
|
description: "x".to_owned(),
|
|
r#type: MemoryType::User,
|
|
content: "# body".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "INVALID");
|
|
// Nothing persisted, nothing announced.
|
|
assert!(store.list(&root()).await.unwrap().is_empty());
|
|
assert!(bus.events().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_empty_description() {
|
|
let store = FakeMemories::default();
|
|
let bus = SpyBus::default();
|
|
let err = CreateMemory::new(Arc::new(store), Arc::new(bus))
|
|
.execute(CreateMemoryInput {
|
|
project_root: root(),
|
|
name: "ok-slug".to_owned(),
|
|
description: String::new(),
|
|
r#type: MemoryType::User,
|
|
content: "# body".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "INVALID");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_empty_content() {
|
|
let store = FakeMemories::default();
|
|
let bus = SpyBus::default();
|
|
let err = CreateMemory::new(Arc::new(store), Arc::new(bus))
|
|
.execute(CreateMemoryInput {
|
|
project_root: root(),
|
|
name: "ok-slug".to_owned(),
|
|
description: "a hook".to_owned(),
|
|
r#type: MemoryType::User,
|
|
content: String::new(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "INVALID");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// UpdateMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn update_replaces_content_and_emits_memory_saved() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "v1").await;
|
|
let bus = SpyBus::default();
|
|
|
|
let out = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(UpdateMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
description: "updated hook".to_owned(),
|
|
r#type: MemoryType::Reference,
|
|
content: "v2".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.memory.body.as_str(), "v2");
|
|
assert_eq!(out.memory.frontmatter.description, "updated hook");
|
|
assert_eq!(out.memory.frontmatter.r#type, MemoryType::Reference);
|
|
// Replace semantics: still a single note.
|
|
let listed = store.list(&root()).await.unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].body.as_str(), "v2");
|
|
assert_eq!(saved_slugs(&bus.events()), vec![slug("note-a")]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_revalidates_invariants_and_rejects_empty_content() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "v1").await;
|
|
let bus = SpyBus::default();
|
|
|
|
let err = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(UpdateMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
description: "still here".to_owned(),
|
|
r#type: MemoryType::Project,
|
|
content: String::new(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "INVALID");
|
|
// Original note untouched, no event.
|
|
assert_eq!(
|
|
store
|
|
.get(&root(), &slug("note-a"))
|
|
.await
|
|
.unwrap()
|
|
.body
|
|
.as_str(),
|
|
"v1"
|
|
);
|
|
assert!(bus.events().is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DeleteMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn delete_removes_note_and_emits_memory_deleted() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "body").await;
|
|
let bus = SpyBus::default();
|
|
|
|
DeleteMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
|
.execute(DeleteMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(store.list(&root()).await.unwrap().is_empty());
|
|
assert_eq!(deleted_slugs(&bus.events()), vec![slug("note-a")]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_unknown_slug_is_not_found_and_emits_nothing() {
|
|
let store = FakeMemories::default();
|
|
let bus = SpyBus::default();
|
|
|
|
let err = DeleteMemory::new(Arc::new(store), Arc::new(bus.clone()))
|
|
.execute(DeleteMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("ghost"),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "NOT_FOUND");
|
|
assert!(bus.events().is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn get_returns_the_note() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "hello").await;
|
|
|
|
let out = GetMemory::new(Arc::new(store))
|
|
.execute(GetMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.memory.slug(), &slug("note-a"));
|
|
assert_eq!(out.memory.body.as_str(), "hello");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_unknown_slug_is_not_found() {
|
|
let store = FakeMemories::default();
|
|
let err = GetMemory::new(Arc::new(store))
|
|
.execute(GetMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("ghost"),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ListMemories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn list_returns_all_notes() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "a").await;
|
|
seed(&store, "note-b", "b").await;
|
|
|
|
let out = ListMemories::new(Arc::new(store))
|
|
.execute(ListMemoriesInput {
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut slugs: Vec<_> = out.memories.iter().map(|m| m.slug().clone()).collect();
|
|
slugs.sort();
|
|
assert_eq!(slugs, vec![slug("note-a"), slug("note-b")]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_is_empty_when_no_notes() {
|
|
let store = FakeMemories::default();
|
|
let out = ListMemories::new(Arc::new(store))
|
|
.execute(ListMemoriesInput {
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(out.memories.is_empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ReadMemoryIndex
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn read_index_returns_one_row_per_note() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "a").await;
|
|
seed(&store, "note-b", "b").await;
|
|
|
|
let out = ReadMemoryIndex::new(Arc::new(store))
|
|
.execute(ReadMemoryIndexInput {
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(out.entries.len(), 2);
|
|
let mut rows: Vec<_> = out
|
|
.entries
|
|
.iter()
|
|
.map(|e| (e.slug.clone(), e.hook.clone()))
|
|
.collect();
|
|
rows.sort();
|
|
assert_eq!(
|
|
rows,
|
|
vec![
|
|
(slug("note-a"), "note-a hook".to_owned()),
|
|
(slug("note-b"), "note-b hook".to_owned()),
|
|
]
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ResolveMemoryLinks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn resolve_links_returns_outgoing_links_dropping_broken_ones() {
|
|
let store = FakeMemories::default();
|
|
// note-a links to note-b (exists) and to ghost (does not).
|
|
seed(&store, "note-a", "see [[note-b]] and [[ghost]]").await;
|
|
seed(&store, "note-b", "target body").await;
|
|
|
|
let out = ResolveMemoryLinks::new(Arc::new(store))
|
|
.execute(ResolveMemoryLinksInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Only the resolvable link survives; the broken one is dropped.
|
|
assert_eq!(
|
|
out.links,
|
|
vec![MemoryLink {
|
|
target: slug("note-b")
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_links_for_unknown_source_is_not_found() {
|
|
let store = FakeMemories::default();
|
|
let err = ResolveMemoryLinks::new(Arc::new(store))
|
|
.execute(ResolveMemoryLinksInput {
|
|
project_root: root(),
|
|
slug: slug("ghost"),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RecallMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// In-memory [`MemoryRecall`] fake: records the queries it received and returns a
|
|
/// canned set of entries. Lets us assert the use case delegates faithfully (same
|
|
/// `root`, the input `text`/`token_budget` forwarded) and surfaces the result.
|
|
#[derive(Clone, Default)]
|
|
struct FakeRecall {
|
|
entries: Arc<Vec<MemoryIndexEntry>>,
|
|
seen: Arc<Mutex<Vec<MemoryQuery>>>,
|
|
}
|
|
|
|
impl FakeRecall {
|
|
fn returning(entries: Vec<MemoryIndexEntry>) -> Self {
|
|
Self {
|
|
entries: Arc::new(entries),
|
|
seen: Arc::default(),
|
|
}
|
|
}
|
|
fn queries(&self) -> Vec<MemoryQuery> {
|
|
self.seen.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MemoryRecall for FakeRecall {
|
|
async fn recall(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
query: &MemoryQuery,
|
|
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
|
self.seen.lock().unwrap().push(query.clone());
|
|
Ok((*self.entries).clone())
|
|
}
|
|
}
|
|
|
|
fn entry(slug_str: &str, hook: &str) -> MemoryIndexEntry {
|
|
MemoryIndexEntry {
|
|
slug: slug(slug_str),
|
|
title: slug_str.to_owned(),
|
|
hook: hook.to_owned(),
|
|
r#type: MemoryType::Reference,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recall_delegates_to_port_and_returns_entries() {
|
|
let recalled = vec![entry("alpha", "first"), entry("beta", "second")];
|
|
let recall = FakeRecall::returning(recalled.clone());
|
|
|
|
let out = RecallMemory::new(Arc::new(recall.clone()))
|
|
.execute(RecallMemoryInput {
|
|
project_root: root(),
|
|
text: "current context".to_owned(),
|
|
token_budget: 42,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Returns exactly what the port produced, in order.
|
|
assert_eq!(out.entries, recalled);
|
|
// The query was forwarded verbatim (text + budget).
|
|
assert_eq!(
|
|
recall.queries(),
|
|
vec![MemoryQuery {
|
|
text: "current context".to_owned(),
|
|
token_budget: 42,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recall_empty_result_is_empty_not_error() {
|
|
let recall = FakeRecall::returning(Vec::new());
|
|
let out = RecallMemory::new(Arc::new(recall))
|
|
.execute(RecallMemoryInput {
|
|
project_root: root(),
|
|
text: String::new(),
|
|
token_budget: 0,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(out.entries.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recall_publishes_no_events() {
|
|
// RecallMemory takes no EventBus; assert a shared spy stays empty when other
|
|
// (mutating) use cases on the same bus are NOT exercised — i.e. recall is
|
|
// read-only by construction.
|
|
let recall = FakeRecall::returning(vec![entry("alpha", "first")]);
|
|
let bus = SpyBus::default();
|
|
|
|
RecallMemory::new(Arc::new(recall))
|
|
.execute(RecallMemoryInput {
|
|
project_root: root(),
|
|
text: "ctx".to_owned(),
|
|
token_budget: 10,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
bus.events().is_empty(),
|
|
"recall must not publish any DomainEvent"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Read use cases emit no events
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn read_use_cases_publish_no_events() {
|
|
let store = FakeMemories::default();
|
|
seed(&store, "note-a", "see [[note-a]]").await;
|
|
let bus = SpyBus::default();
|
|
|
|
// None of the read use cases take an EventBus — assert the shared spy stays
|
|
// empty after exercising every read path on the same store.
|
|
GetMemory::new(Arc::new(store.clone()))
|
|
.execute(GetMemoryInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
ListMemories::new(Arc::new(store.clone()))
|
|
.execute(ListMemoriesInput {
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
ReadMemoryIndex::new(Arc::new(store.clone()))
|
|
.execute(ReadMemoryIndexInput {
|
|
project_root: root(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
ResolveMemoryLinks::new(Arc::new(store))
|
|
.execute(ResolveMemoryLinksInput {
|
|
project_root: root(),
|
|
slug: slug("note-a"),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
bus.events().is_empty(),
|
|
"read use cases must not publish any DomainEvent"
|
|
);
|
|
}
|