1use axum::{
5 Json,
6 extract::{FromRef, Path, Query, Request, State},
7 http::{HeaderMap, StatusCode},
8 middleware::Next,
9 response::IntoResponse,
10};
11use chrono::{Duration, Utc};
12use serde::Deserialize;
13use serde_json::json;
14use std::sync::Arc;
15use tokio::sync::Mutex;
16use uuid::Uuid;
17
18use crate::config::{ResolvedTtl, TierConfig};
19use crate::db;
20use crate::embeddings::Embedder;
21use crate::hnsw::VectorIndex;
22use crate::models::{
23 CreateMemory, ForgetQuery, LinkBody, ListQuery, Memory, MemoryLink, RecallBody, RecallQuery,
24 RegisterAgentBody, SearchQuery, Tier, UpdateMemory,
25};
26use crate::validate;
27
28pub type Db = Arc<Mutex<(rusqlite::Connection, std::path::PathBuf, ResolvedTtl, bool)>>;
29
30#[derive(Clone)]
40pub struct AppState {
41 pub db: Db,
42 pub embedder: Arc<Option<Embedder>>,
43 pub vector_index: Arc<Mutex<Option<VectorIndex>>>,
44 pub federation: Arc<Option<crate::federation::FederationConfig>>,
49 pub tier_config: Arc<TierConfig>,
53 pub scoring: Arc<crate::config::ResolvedScoring>,
61}
62
63impl FromRef<AppState> for Db {
64 fn from_ref(app: &AppState) -> Self {
65 app.db.clone()
66 }
67}
68
69const MAX_BULK_SIZE: usize = 1000;
70
71const BULK_FANOUT_CONCURRENCY: usize = 8;
79
80#[derive(Clone)]
82pub struct ApiKeyState {
83 pub key: Option<String>,
84}
85
86#[inline]
90fn percent_decode_lossy(input: &str) -> String {
91 let bytes = input.as_bytes();
92 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
93 let mut i = 0;
94 while i < bytes.len() {
95 if bytes[i] == b'%' && i + 2 < bytes.len() {
96 let h = (bytes[i + 1] as char).to_digit(16);
97 let l = (bytes[i + 2] as char).to_digit(16);
98 if let (Some(h), Some(l)) = (h, l) {
99 out.push(u8::try_from(h * 16 + l).unwrap_or(0));
102 i += 3;
103 continue;
104 }
105 }
106 out.push(bytes[i]);
107 i += 1;
108 }
109 String::from_utf8_lossy(&out).into_owned()
110}
111
112#[inline]
115fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
116 if a.len() != b.len() {
117 return false;
118 }
119 let mut diff: u8 = 0;
120 for (x, y) in a.iter().zip(b.iter()) {
121 diff |= x ^ y;
122 }
123 diff == 0
124}
125
126pub async fn api_key_auth(
130 State(auth): State<ApiKeyState>,
131 req: Request,
132 next: Next,
133) -> impl IntoResponse {
134 let Some(ref expected) = auth.key else {
135 return next.run(req).await.into_response();
137 };
138
139 if req.uri().path() == "/api/v1/health" {
141 return next.run(req).await.into_response();
142 }
143
144 if let Some(header_val) = req.headers().get("x-api-key")
146 && let Ok(val) = header_val.to_str()
147 && constant_time_eq(val.as_bytes(), expected.as_bytes())
148 {
149 return next.run(req).await.into_response();
150 }
151
152 if let Some(query) = req.uri().query() {
160 for pair in query.split('&') {
161 if let Some(val) = pair.strip_prefix("api_key=") {
162 let decoded = percent_decode_lossy(val);
163 if constant_time_eq(decoded.as_bytes(), expected.as_bytes()) {
164 return next.run(req).await.into_response();
165 }
166 }
167 }
168 }
169
170 (
171 StatusCode::UNAUTHORIZED,
172 Json(json!({"error": "missing or invalid API key"})),
173 )
174 .into_response()
175}
176
177pub async fn health(State(app): State<AppState>) -> impl IntoResponse {
178 let lock = app.db.lock().await;
179 let ok = db::health_check(&lock.0).unwrap_or(false);
180 drop(lock);
181 let embedder_ready = app.embedder.as_ref().is_some();
182 let federation_enabled = app.federation.as_ref().is_some();
183 let code = if ok {
184 StatusCode::OK
185 } else {
186 StatusCode::SERVICE_UNAVAILABLE
187 };
188 (
191 code,
192 Json(json!({
193 "status": if ok { "ok" } else { "error" },
194 "service": "ai-memory",
195 "version": env!("CARGO_PKG_VERSION"),
196 "embedder_ready": embedder_ready,
197 "federation_enabled": federation_enabled,
198 })),
199 )
200 .into_response()
201}
202
203pub async fn prometheus_metrics(State(state): State<Db>) -> impl IntoResponse {
208 {
209 let lock = state.lock().await;
210 if let Ok(stats) = db::stats(&lock.0, &lock.1) {
211 crate::metrics::registry()
212 .memories_gauge
213 .set(stats.total.try_into().unwrap_or(i64::MAX));
214 }
215 }
216 let body = crate::metrics::render();
217 (
218 StatusCode::OK,
219 [(
220 axum::http::header::CONTENT_TYPE,
221 "text/plain; version=0.0.4; charset=utf-8",
222 )],
223 body,
224 )
225 .into_response()
226}
227
228#[allow(clippy::too_many_lines)]
229pub async fn create_memory(
230 State(app): State<AppState>,
231 headers: HeaderMap,
232 Json(body): Json<CreateMemory>,
233) -> impl IntoResponse {
234 let state = app.db.clone();
235 if let Err(e) = validate::validate_create(&body) {
236 return (
237 StatusCode::BAD_REQUEST,
238 Json(json!({"error": e.to_string()})),
239 )
240 .into_response();
241 }
242
243 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
245 let agent_id =
246 match crate::identity::resolve_http_agent_id(body.agent_id.as_deref(), header_agent_id) {
247 Ok(id) => id,
248 Err(e) => {
249 return (
250 StatusCode::BAD_REQUEST,
251 Json(json!({"error": format!("invalid agent_id: {e}")})),
252 )
253 .into_response();
254 }
255 };
256 let mut metadata = body.metadata;
257 if let Some(obj) = metadata.as_object_mut() {
258 obj.insert("agent_id".to_string(), serde_json::Value::String(agent_id));
259 }
260 if let Some(ref s) = body.scope {
263 if let Err(e) = validate::validate_scope(s) {
264 return (
265 StatusCode::BAD_REQUEST,
266 Json(json!({"error": e.to_string()})),
267 )
268 .into_response();
269 }
270 if let Some(obj) = metadata.as_object_mut() {
271 obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
272 }
273 }
274
275 let embedding_text = format!("{} {}", body.title, body.content);
279 let embedding: Option<Vec<f32>> =
280 app.embedder
281 .as_ref()
282 .as_ref()
283 .and_then(|emb| match emb.embed(&embedding_text) {
284 Ok(v) => Some(v),
285 Err(e) => {
286 tracing::warn!("embedding generation failed: {e}");
287 None
288 }
289 });
290
291 let now = Utc::now();
292 let lock = state.lock().await;
293 let expires_at = body.expires_at.or_else(|| {
294 body.ttl_secs
295 .or(lock.2.ttl_for_tier(&body.tier))
296 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
297 });
298 let mem = Memory {
299 id: Uuid::new_v4().to_string(),
300 tier: body.tier,
301 namespace: body.namespace,
302 title: body.title,
303 content: body.content,
304 tags: body.tags,
305 priority: body.priority.clamp(1, 10),
306 confidence: body.confidence.clamp(0.0, 1.0),
307 source: body.source,
308 access_count: 0,
309 created_at: now.to_rfc3339(),
310 updated_at: now.to_rfc3339(),
311 last_accessed_at: None,
312 expires_at,
313 metadata,
314 };
315
316 {
318 use crate::models::{GovernanceDecision, GovernedAction};
319 let agent_for_gov = mem
320 .metadata
321 .get("agent_id")
322 .and_then(|v| v.as_str())
323 .unwrap_or_default()
324 .to_string();
325 let payload = serde_json::to_value(&mem).unwrap_or_default();
326 match db::enforce_governance(
327 &lock.0,
328 GovernedAction::Store,
329 &mem.namespace,
330 &agent_for_gov,
331 None,
332 None,
333 &payload,
334 ) {
335 Ok(GovernanceDecision::Allow) => {}
336 Ok(GovernanceDecision::Deny(reason)) => {
337 return (
338 StatusCode::FORBIDDEN,
339 Json(json!({"error": format!("store denied by governance: {reason}")})),
340 )
341 .into_response();
342 }
343 Ok(GovernanceDecision::Pending(pending_id)) => {
344 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
348 let namespace = mem.namespace.clone();
349 drop(lock);
350 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
351 match crate::federation::broadcast_pending_quorum(fed, pa).await {
352 Ok(tracker) => {
353 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
354 let payload =
355 crate::federation::QuorumNotMetPayload::from_err(&err);
356 return (
357 StatusCode::SERVICE_UNAVAILABLE,
358 [("Retry-After", "2")],
359 Json(serde_json::to_value(&payload).unwrap_or_default()),
360 )
361 .into_response();
362 }
363 }
364 Err(err) => {
365 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
366 return (
367 StatusCode::SERVICE_UNAVAILABLE,
368 [("Retry-After", "2")],
369 Json(serde_json::to_value(&payload).unwrap_or_default()),
370 )
371 .into_response();
372 }
373 }
374 }
375 return (
376 StatusCode::ACCEPTED,
377 Json(json!({
378 "status": "pending",
379 "pending_id": pending_id,
380 "reason": "governance requires approval",
381 "action": "store",
382 "namespace": namespace,
383 })),
384 )
385 .into_response();
386 }
387 Err(e) => {
388 tracing::error!("governance error: {e}");
389 return (
390 StatusCode::INTERNAL_SERVER_ERROR,
391 Json(json!({"error": "governance check failed"})),
392 )
393 .into_response();
394 }
395 }
396 }
397
398 let contradictions =
400 db::find_contradictions(&lock.0, &mem.title, &mem.namespace).unwrap_or_default();
401 let contradiction_ids: Vec<String> = contradictions
402 .iter()
403 .filter(|c| c.id != mem.id)
404 .map(|c| c.id.clone())
405 .collect();
406
407 match db::insert(&lock.0, &mem) {
408 Ok(actual_id) => {
409 if let Some(ref vec) = embedding
414 && let Err(e) = db::set_embedding(&lock.0, &actual_id, vec)
415 {
416 tracing::warn!("failed to store embedding for {actual_id}: {e}");
417 }
418 drop(lock);
420 if let Some(vec) = embedding {
421 let mut idx_lock = app.vector_index.lock().await;
422 if let Some(idx) = idx_lock.as_mut() {
423 idx.insert(actual_id.clone(), vec);
424 }
425 }
426 let resolved_agent_id = mem
428 .metadata
429 .get("agent_id")
430 .and_then(|v| v.as_str())
431 .map(str::to_string);
432 let mut response = json!({
433 "id": actual_id,
434 "tier": mem.tier,
435 "namespace": mem.namespace,
436 "title": mem.title,
437 "agent_id": resolved_agent_id,
438 });
439 if !contradiction_ids.is_empty() {
440 response["potential_contradictions"] = json!(contradiction_ids);
441 }
442 if let Some(fed) = app.federation.as_ref() {
449 let mut mem_echo = mem.clone();
450 mem_echo.id = actual_id.clone();
451 match crate::federation::broadcast_store_quorum(fed, &mem_echo).await {
452 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
453 Ok(got) => {
454 response["quorum_acks"] = json!(got);
455 return (StatusCode::CREATED, Json(response)).into_response();
456 }
457 Err(err) => {
458 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
459 return (
460 StatusCode::SERVICE_UNAVAILABLE,
461 [("Retry-After", "2")],
462 Json(serde_json::to_value(&payload).unwrap_or_default()),
463 )
464 .into_response();
465 }
466 },
467 Err(err) => {
468 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
469 return (
470 StatusCode::SERVICE_UNAVAILABLE,
471 [("Retry-After", "2")],
472 Json(serde_json::to_value(&payload).unwrap_or_default()),
473 )
474 .into_response();
475 }
476 }
477 }
478 (StatusCode::CREATED, Json(response)).into_response()
479 }
480 Err(e) => {
481 tracing::error!("handler error: {e}");
482 (
483 StatusCode::INTERNAL_SERVER_ERROR,
484 Json(json!({"error": "internal server error"})),
485 )
486 .into_response()
487 }
488 }
489}
490
491pub async fn register_agent(
492 State(app): State<AppState>,
493 Json(body): Json<RegisterAgentBody>,
494) -> impl IntoResponse {
495 if let Err(e) = validate::validate_agent_id(&body.agent_id) {
496 return (
497 StatusCode::BAD_REQUEST,
498 Json(json!({"error": e.to_string()})),
499 )
500 .into_response();
501 }
502 if let Err(e) = validate::validate_agent_type(&body.agent_type) {
503 return (
504 StatusCode::BAD_REQUEST,
505 Json(json!({"error": e.to_string()})),
506 )
507 .into_response();
508 }
509 let capabilities = body.capabilities.unwrap_or_default();
510 if let Err(e) = validate::validate_capabilities(&capabilities) {
511 return (
512 StatusCode::BAD_REQUEST,
513 Json(json!({"error": e.to_string()})),
514 )
515 .into_response();
516 }
517
518 let lock = app.db.lock().await;
519 let register_result =
520 db::register_agent(&lock.0, &body.agent_id, &body.agent_type, &capabilities);
521 let registered_mem = match ®ister_result {
526 Ok(id) => db::get(&lock.0, id).ok().flatten(),
527 Err(_) => None,
528 };
529 drop(lock);
530
531 match register_result {
532 Ok(id) => {
533 if let (Some(fed), Some(mem)) = (app.federation.as_ref(), registered_mem.as_ref()) {
534 match crate::federation::broadcast_store_quorum(fed, mem).await {
535 Ok(tracker) => {
536 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
537 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
538 return (
539 StatusCode::SERVICE_UNAVAILABLE,
540 [("Retry-After", "2")],
541 Json(serde_json::to_value(&payload).unwrap_or_default()),
542 )
543 .into_response();
544 }
545 }
546 Err(e) => {
547 tracing::warn!("register_agent fanout error (local committed): {e:?}");
548 }
549 }
550 }
551 (
552 StatusCode::CREATED,
553 Json(json!({
554 "registered": true,
555 "id": id,
556 "agent_id": body.agent_id,
557 "agent_type": body.agent_type,
558 "capabilities": capabilities,
559 })),
560 )
561 .into_response()
562 }
563 Err(e) => {
564 tracing::error!("handler error: {e}");
565 (
566 StatusCode::INTERNAL_SERVER_ERROR,
567 Json(json!({"error": "internal server error"})),
568 )
569 .into_response()
570 }
571 }
572}
573
574#[derive(Deserialize)]
579pub struct PendingListQuery {
580 #[serde(default)]
581 pub status: Option<String>,
582 #[serde(default = "default_pending_limit")]
583 pub limit: Option<usize>,
584}
585
586#[allow(clippy::unnecessary_wraps)]
587fn default_pending_limit() -> Option<usize> {
588 Some(100)
589}
590
591pub async fn list_pending(
592 State(state): State<Db>,
593 Query(p): Query<PendingListQuery>,
594) -> impl IntoResponse {
595 let limit = p.limit.unwrap_or(100).min(1000);
596 let lock = state.lock().await;
597 match db::list_pending_actions(&lock.0, p.status.as_deref(), limit) {
598 Ok(items) => Json(json!({"count": items.len(), "pending": items})).into_response(),
599 Err(e) => {
600 tracing::error!("handler error: {e}");
601 (
602 StatusCode::INTERNAL_SERVER_ERROR,
603 Json(json!({"error": "internal server error"})),
604 )
605 .into_response()
606 }
607 }
608}
609
610#[allow(clippy::too_many_lines)]
611pub async fn approve_pending(
612 State(app): State<AppState>,
613 headers: HeaderMap,
614 Path(id): Path<String>,
615) -> impl IntoResponse {
616 use crate::db::ApproveOutcome;
617 use crate::models::PendingDecision;
618 let state = app.db.clone();
619 if let Err(e) = validate::validate_id(&id) {
620 return (
621 StatusCode::BAD_REQUEST,
622 Json(json!({"error": e.to_string()})),
623 )
624 .into_response();
625 }
626 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
627 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
628 Ok(a) => a,
629 Err(e) => {
630 return (
631 StatusCode::BAD_REQUEST,
632 Json(json!({"error": format!("invalid agent_id: {e}")})),
633 )
634 .into_response();
635 }
636 };
637 let lock = state.lock().await;
638 match db::approve_with_approver_type(&lock.0, &id, &agent_id) {
639 Ok(ApproveOutcome::Approved) => match db::execute_pending_action(&lock.0, &id) {
640 Ok(memory_id) => {
641 let produced_mem = memory_id
646 .as_deref()
647 .and_then(|mid| db::get(&lock.0, mid).ok().flatten());
648 drop(lock);
649 if let Some(fed) = app.federation.as_ref() {
650 let decision = PendingDecision {
651 id: id.clone(),
652 approved: true,
653 decider: agent_id.clone(),
654 };
655 match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await
656 {
657 Ok(tracker) => {
658 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
659 let payload =
660 crate::federation::QuorumNotMetPayload::from_err(&err);
661 return (
662 StatusCode::SERVICE_UNAVAILABLE,
663 [("Retry-After", "2")],
664 Json(serde_json::to_value(&payload).unwrap_or_default()),
665 )
666 .into_response();
667 }
668 }
669 Err(err) => {
670 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
671 return (
672 StatusCode::SERVICE_UNAVAILABLE,
673 [("Retry-After", "2")],
674 Json(serde_json::to_value(&payload).unwrap_or_default()),
675 )
676 .into_response();
677 }
678 }
679 if let Some(ref mem) = produced_mem
684 && let Some(resp) = fanout_or_503(&app, mem).await
685 {
686 return resp;
687 }
688 }
689 Json(json!({
690 "approved": true,
691 "id": id,
692 "decided_by": agent_id,
693 "executed": true,
694 "memory_id": memory_id,
695 }))
696 .into_response()
697 }
698 Err(e) => {
699 tracing::error!("execute pending error: {e}");
700 (
701 StatusCode::INTERNAL_SERVER_ERROR,
702 Json(json!({"error": "approved but execution failed"})),
703 )
704 .into_response()
705 }
706 },
707 Ok(ApproveOutcome::Pending { votes, quorum }) => (
708 StatusCode::ACCEPTED,
709 Json(json!({
710 "approved": false,
711 "status": "pending",
712 "id": id,
713 "votes": votes,
714 "quorum": quorum,
715 "reason": "consensus threshold not yet reached",
716 })),
717 )
718 .into_response(),
719 Ok(ApproveOutcome::Rejected(reason)) => (
720 StatusCode::FORBIDDEN,
721 Json(json!({"error": format!("approve rejected: {reason}")})),
722 )
723 .into_response(),
724 Err(e) => {
725 tracing::error!("handler error: {e}");
726 (
727 StatusCode::INTERNAL_SERVER_ERROR,
728 Json(json!({"error": "internal server error"})),
729 )
730 .into_response()
731 }
732 }
733}
734
735pub async fn reject_pending(
736 State(app): State<AppState>,
737 headers: HeaderMap,
738 Path(id): Path<String>,
739) -> impl IntoResponse {
740 use crate::models::PendingDecision;
741 let state = app.db.clone();
742 if let Err(e) = validate::validate_id(&id) {
743 return (
744 StatusCode::BAD_REQUEST,
745 Json(json!({"error": e.to_string()})),
746 )
747 .into_response();
748 }
749 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
750 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
751 Ok(a) => a,
752 Err(e) => {
753 return (
754 StatusCode::BAD_REQUEST,
755 Json(json!({"error": format!("invalid agent_id: {e}")})),
756 )
757 .into_response();
758 }
759 };
760 let lock = state.lock().await;
761 match db::decide_pending_action(&lock.0, &id, false, &agent_id) {
762 Ok(true) => {
763 drop(lock);
764 if let Some(fed) = app.federation.as_ref() {
766 let decision = PendingDecision {
767 id: id.clone(),
768 approved: false,
769 decider: agent_id.clone(),
770 };
771 match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await {
772 Ok(tracker) => {
773 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
774 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
775 return (
776 StatusCode::SERVICE_UNAVAILABLE,
777 [("Retry-After", "2")],
778 Json(serde_json::to_value(&payload).unwrap_or_default()),
779 )
780 .into_response();
781 }
782 }
783 Err(err) => {
784 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
785 return (
786 StatusCode::SERVICE_UNAVAILABLE,
787 [("Retry-After", "2")],
788 Json(serde_json::to_value(&payload).unwrap_or_default()),
789 )
790 .into_response();
791 }
792 }
793 }
794 Json(json!({"rejected": true, "id": id, "decided_by": agent_id})).into_response()
795 }
796 Ok(false) => (
797 StatusCode::NOT_FOUND,
798 Json(json!({"error": "pending action not found or already decided"})),
799 )
800 .into_response(),
801 Err(e) => {
802 tracing::error!("handler error: {e}");
803 (
804 StatusCode::INTERNAL_SERVER_ERROR,
805 Json(json!({"error": "internal server error"})),
806 )
807 .into_response()
808 }
809 }
810}
811
812pub async fn list_agents(State(state): State<Db>) -> impl IntoResponse {
813 let lock = state.lock().await;
814 match db::list_agents(&lock.0) {
815 Ok(agents) => (
816 StatusCode::OK,
817 Json(json!({"count": agents.len(), "agents": agents})),
818 )
819 .into_response(),
820 Err(e) => {
821 tracing::error!("handler error: {e}");
822 (
823 StatusCode::INTERNAL_SERVER_ERROR,
824 Json(json!({"error": "internal server error"})),
825 )
826 .into_response()
827 }
828 }
829}
830
831pub async fn get_memory(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
832 if let Err(e) = validate::validate_id(&id) {
833 return (
834 StatusCode::BAD_REQUEST,
835 Json(json!({"error": e.to_string()})),
836 )
837 .into_response();
838 }
839 let lock = state.lock().await;
840 match db::resolve_id(&lock.0, &id) {
841 Ok(Some(mem)) => {
842 let links = db::get_links(&lock.0, &mem.id).unwrap_or_default();
843 Json(json!({"memory": mem, "links": links})).into_response()
844 }
845 Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
846 Err(e) => {
847 let msg = e.to_string();
848 if msg.contains("ambiguous ID prefix") {
849 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
850 }
851 tracing::error!("handler error: {e}");
852 (
853 StatusCode::INTERNAL_SERVER_ERROR,
854 Json(json!({"error": "internal server error"})),
855 )
856 .into_response()
857 }
858 }
859}
860
861#[allow(clippy::too_many_lines)]
862pub async fn update_memory(
863 State(app): State<AppState>,
864 Path(id): Path<String>,
865 Json(body): Json<UpdateMemory>,
866) -> impl IntoResponse {
867 let state = app.db.clone();
868 if let Err(e) = validate::validate_id(&id) {
869 return (
870 StatusCode::BAD_REQUEST,
871 Json(json!({"error": e.to_string()})),
872 )
873 .into_response();
874 }
875 if let Err(e) = validate::validate_update(&body) {
876 return (
877 StatusCode::BAD_REQUEST,
878 Json(json!({"error": e.to_string()})),
879 )
880 .into_response();
881 }
882 let lock = state.lock().await;
883 let resolved_id = match db::resolve_id(&lock.0, &id) {
885 Ok(Some(mem)) => mem.id,
886 Ok(None) => {
887 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
888 }
889 Err(e) => {
890 let msg = e.to_string();
891 if msg.contains("ambiguous ID prefix") {
892 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
893 }
894 tracing::error!("handler error: {e}");
895 return (
896 StatusCode::INTERNAL_SERVER_ERROR,
897 Json(json!({"error": "internal server error"})),
898 )
899 .into_response();
900 }
901 };
902 let preserved_metadata = body.metadata.as_ref().map(|new_meta| {
905 let existing_meta = db::get(&lock.0, &resolved_id).ok().flatten().map_or_else(
906 || serde_json::Value::Object(serde_json::Map::new()),
907 |m| m.metadata,
908 );
909 crate::identity::preserve_agent_id(&existing_meta, new_meta)
910 });
911 match db::update(
912 &lock.0,
913 &resolved_id,
914 body.title.as_deref(),
915 body.content.as_deref(),
916 body.tier.as_ref(),
917 body.namespace.as_deref(),
918 body.tags.as_ref(),
919 body.priority,
920 body.confidence,
921 body.expires_at.as_deref(),
922 preserved_metadata.as_ref(),
923 ) {
924 Ok((true, _)) => {
925 let mem = db::get(&lock.0, &resolved_id).ok().flatten();
926 let content_changed = body.title.is_some() || body.content.is_some();
931 let mut lock_opt = Some(lock);
932 if content_changed && let Some(ref m) = mem {
933 let text = format!("{} {}", m.title, m.content);
934 if let Some(emb) = app.embedder.as_ref().as_ref() {
935 match emb.embed(&text) {
936 Ok(vec) => {
937 if let Some(ref l) = lock_opt
938 && let Err(e) = db::set_embedding(&l.0, &resolved_id, &vec)
939 {
940 tracing::warn!(
941 "failed to refresh embedding for {resolved_id}: {e}"
942 );
943 }
944 lock_opt.take();
946 let mut idx_lock = app.vector_index.lock().await;
947 if let Some(idx) = idx_lock.as_mut() {
948 idx.remove(&resolved_id);
949 idx.insert(resolved_id.clone(), vec);
950 }
951 }
952 Err(e) => tracing::warn!("embedding regeneration failed: {e}"),
953 }
954 }
955 }
956 drop(lock_opt);
959 if let (Some(fed), Some(m)) = (app.federation.as_ref(), mem.as_ref())
963 && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
964 && let Err(err) = crate::federation::finalise_quorum(&tracker)
965 {
966 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
967 return (
968 StatusCode::SERVICE_UNAVAILABLE,
969 [("Retry-After", "2")],
970 Json(serde_json::to_value(&payload).unwrap_or_default()),
971 )
972 .into_response();
973 }
974 Json(json!(mem)).into_response()
975 }
976 Ok((false, _)) => {
977 (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
978 }
979 Err(e) => {
980 let msg = e.to_string();
981 if msg.contains("already exists in namespace") {
982 return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
983 }
984 tracing::error!("handler error: {e}");
985 (
986 StatusCode::INTERNAL_SERVER_ERROR,
987 Json(json!({"error": "internal server error"})),
988 )
989 .into_response()
990 }
991 }
992}
993
994#[allow(clippy::too_many_lines)]
995pub async fn delete_memory(
996 State(app): State<AppState>,
997 headers: HeaderMap,
998 Path(id): Path<String>,
999) -> impl IntoResponse {
1000 let state = app.db.clone();
1001 if let Err(e) = validate::validate_id(&id) {
1002 return (
1003 StatusCode::BAD_REQUEST,
1004 Json(json!({"error": e.to_string()})),
1005 )
1006 .into_response();
1007 }
1008 let lock = state.lock().await;
1009 let target = match db::resolve_id(&lock.0, &id) {
1011 Ok(Some(m)) => m,
1012 Ok(None) => {
1013 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1014 }
1015 Err(e) => {
1016 let msg = e.to_string();
1017 if msg.contains("ambiguous ID prefix") {
1018 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1019 }
1020 tracing::error!("handler error: {e}");
1021 return (
1022 StatusCode::INTERNAL_SERVER_ERROR,
1023 Json(json!({"error": "internal server error"})),
1024 )
1025 .into_response();
1026 }
1027 };
1028
1029 {
1031 use crate::models::{GovernanceDecision, GovernedAction};
1032 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1033 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1034 Ok(a) => a,
1035 Err(e) => {
1036 return (
1037 StatusCode::BAD_REQUEST,
1038 Json(json!({"error": format!("invalid agent_id: {e}")})),
1039 )
1040 .into_response();
1041 }
1042 };
1043 let mem_owner = target
1044 .metadata
1045 .get("agent_id")
1046 .and_then(|v| v.as_str())
1047 .map(str::to_string);
1048 let payload = json!({"id": target.id, "title": target.title});
1049 match db::enforce_governance(
1050 &lock.0,
1051 GovernedAction::Delete,
1052 &target.namespace,
1053 &agent_id,
1054 Some(&target.id),
1055 mem_owner.as_deref(),
1056 &payload,
1057 ) {
1058 Ok(GovernanceDecision::Allow) => {}
1059 Ok(GovernanceDecision::Deny(reason)) => {
1060 return (
1061 StatusCode::FORBIDDEN,
1062 Json(json!({"error": format!("delete denied by governance: {reason}")})),
1063 )
1064 .into_response();
1065 }
1066 Ok(GovernanceDecision::Pending(pending_id)) => {
1067 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1070 let target_id = target.id.clone();
1071 drop(lock);
1072 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1073 match crate::federation::broadcast_pending_quorum(fed, pa).await {
1074 Ok(tracker) => {
1075 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1076 let payload =
1077 crate::federation::QuorumNotMetPayload::from_err(&err);
1078 return (
1079 StatusCode::SERVICE_UNAVAILABLE,
1080 [("Retry-After", "2")],
1081 Json(serde_json::to_value(&payload).unwrap_or_default()),
1082 )
1083 .into_response();
1084 }
1085 }
1086 Err(err) => {
1087 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1088 return (
1089 StatusCode::SERVICE_UNAVAILABLE,
1090 [("Retry-After", "2")],
1091 Json(serde_json::to_value(&payload).unwrap_or_default()),
1092 )
1093 .into_response();
1094 }
1095 }
1096 }
1097 return (
1098 StatusCode::ACCEPTED,
1099 Json(json!({
1100 "status": "pending",
1101 "pending_id": pending_id,
1102 "reason": "governance requires approval",
1103 "action": "delete",
1104 "memory_id": target_id,
1105 })),
1106 )
1107 .into_response();
1108 }
1109 Err(e) => {
1110 tracing::error!("governance error: {e}");
1111 return (
1112 StatusCode::INTERNAL_SERVER_ERROR,
1113 Json(json!({"error": "governance check failed"})),
1114 )
1115 .into_response();
1116 }
1117 }
1118 }
1119
1120 let delete_outcome = db::delete(&lock.0, &target.id);
1121 drop(lock);
1124 match delete_outcome {
1125 Ok(true) => {
1126 if let Some(fed) = app.federation.as_ref()
1128 && let Ok(tracker) =
1129 crate::federation::broadcast_delete_quorum(fed, &target.id).await
1130 && let Err(err) = crate::federation::finalise_quorum(&tracker)
1131 {
1132 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1133 return (
1134 StatusCode::SERVICE_UNAVAILABLE,
1135 [("Retry-After", "2")],
1136 Json(serde_json::to_value(&payload).unwrap_or_default()),
1137 )
1138 .into_response();
1139 }
1140 Json(json!({"deleted": true})).into_response()
1141 }
1142 _ => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
1143 }
1144}
1145
1146#[allow(clippy::too_many_lines)]
1147pub async fn promote_memory(
1148 State(app): State<AppState>,
1149 headers: HeaderMap,
1150 Path(id): Path<String>,
1151) -> impl IntoResponse {
1152 let state = app.db.clone();
1153 if let Err(e) = validate::validate_id(&id) {
1154 return (
1155 StatusCode::BAD_REQUEST,
1156 Json(json!({"error": e.to_string()})),
1157 )
1158 .into_response();
1159 }
1160 let lock = state.lock().await;
1161 let target = match db::resolve_id(&lock.0, &id) {
1163 Ok(Some(mem)) => mem,
1164 Ok(None) => {
1165 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1166 }
1167 Err(e) => {
1168 let msg = e.to_string();
1169 if msg.contains("ambiguous ID prefix") {
1170 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1171 }
1172 tracing::error!("handler error: {e}");
1173 return (
1174 StatusCode::INTERNAL_SERVER_ERROR,
1175 Json(json!({"error": "internal server error"})),
1176 )
1177 .into_response();
1178 }
1179 };
1180 {
1182 use crate::models::{GovernanceDecision, GovernedAction};
1183 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1184 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1185 Ok(a) => a,
1186 Err(e) => {
1187 return (
1188 StatusCode::BAD_REQUEST,
1189 Json(json!({"error": format!("invalid agent_id: {e}")})),
1190 )
1191 .into_response();
1192 }
1193 };
1194 let mem_owner = target
1195 .metadata
1196 .get("agent_id")
1197 .and_then(|v| v.as_str())
1198 .map(str::to_string);
1199 let payload = json!({"id": target.id});
1200 match db::enforce_governance(
1201 &lock.0,
1202 GovernedAction::Promote,
1203 &target.namespace,
1204 &agent_id,
1205 Some(&target.id),
1206 mem_owner.as_deref(),
1207 &payload,
1208 ) {
1209 Ok(GovernanceDecision::Allow) => {}
1210 Ok(GovernanceDecision::Deny(reason)) => {
1211 return (
1212 StatusCode::FORBIDDEN,
1213 Json(json!({"error": format!("promote denied by governance: {reason}")})),
1214 )
1215 .into_response();
1216 }
1217 Ok(GovernanceDecision::Pending(pending_id)) => {
1218 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1220 let target_id = target.id.clone();
1221 drop(lock);
1222 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1223 match crate::federation::broadcast_pending_quorum(fed, pa).await {
1224 Ok(tracker) => {
1225 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1226 let payload =
1227 crate::federation::QuorumNotMetPayload::from_err(&err);
1228 return (
1229 StatusCode::SERVICE_UNAVAILABLE,
1230 [("Retry-After", "2")],
1231 Json(serde_json::to_value(&payload).unwrap_or_default()),
1232 )
1233 .into_response();
1234 }
1235 }
1236 Err(err) => {
1237 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1238 return (
1239 StatusCode::SERVICE_UNAVAILABLE,
1240 [("Retry-After", "2")],
1241 Json(serde_json::to_value(&payload).unwrap_or_default()),
1242 )
1243 .into_response();
1244 }
1245 }
1246 }
1247 return (
1248 StatusCode::ACCEPTED,
1249 Json(json!({
1250 "status": "pending",
1251 "pending_id": pending_id,
1252 "reason": "governance requires approval",
1253 "action": "promote",
1254 "memory_id": target_id,
1255 })),
1256 )
1257 .into_response();
1258 }
1259 Err(e) => {
1260 tracing::error!("governance error: {e}");
1261 return (
1262 StatusCode::INTERNAL_SERVER_ERROR,
1263 Json(json!({"error": "governance check failed"})),
1264 )
1265 .into_response();
1266 }
1267 }
1268 }
1269
1270 let resolved_id = target.id.clone();
1271 match db::update(
1272 &lock.0,
1273 &resolved_id,
1274 None,
1275 None,
1276 Some(&Tier::Long),
1277 None,
1278 None,
1279 None,
1280 None,
1281 None,
1282 None,
1283 ) {
1284 Ok((true, _)) => {
1285 if let Err(e) = lock.0.execute(
1286 "UPDATE memories SET expires_at = NULL WHERE id = ?1",
1287 rusqlite::params![resolved_id],
1288 ) {
1289 tracing::error!("promote clear expiry failed: {e}");
1290 return (
1291 StatusCode::INTERNAL_SERVER_ERROR,
1292 Json(json!({"error": "internal server error"})),
1293 )
1294 .into_response();
1295 }
1296 let promoted_mem = db::get(&lock.0, &resolved_id).ok().flatten();
1299 drop(lock);
1300 if let (Some(fed), Some(m)) = (app.federation.as_ref(), promoted_mem.as_ref())
1301 && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
1302 && let Err(err) = crate::federation::finalise_quorum(&tracker)
1303 {
1304 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1305 return (
1306 StatusCode::SERVICE_UNAVAILABLE,
1307 [("Retry-After", "2")],
1308 Json(serde_json::to_value(&payload).unwrap_or_default()),
1309 )
1310 .into_response();
1311 }
1312 Json(json!({"promoted": true, "id": resolved_id, "tier": "long"})).into_response()
1313 }
1314 Ok((false, _)) => {
1315 (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
1316 }
1317 Err(e) => {
1318 tracing::error!("handler error: {e}");
1319 (
1320 StatusCode::INTERNAL_SERVER_ERROR,
1321 Json(json!({"error": "internal server error"})),
1322 )
1323 .into_response()
1324 }
1325 }
1326}
1327
1328pub async fn list_memories(
1329 State(state): State<Db>,
1330 Query(p): Query<ListQuery>,
1331) -> impl IntoResponse {
1332 if let Some(ref aid) = p.agent_id
1334 && let Err(e) = validate::validate_agent_id(aid)
1335 {
1336 return (
1337 StatusCode::BAD_REQUEST,
1338 Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1339 )
1340 .into_response();
1341 }
1342 let lock = state.lock().await;
1343 let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1350 match db::list(
1351 &lock.0,
1352 p.namespace.as_deref(),
1353 p.tier.as_ref(),
1354 limit,
1355 p.offset.unwrap_or(0),
1356 p.min_priority,
1357 p.since.as_deref(),
1358 p.until.as_deref(),
1359 p.tags.as_deref(),
1360 p.agent_id.as_deref(),
1361 ) {
1362 Ok(mems) => Json(json!({"memories": mems, "count": mems.len()})).into_response(),
1363 Err(e) => {
1364 tracing::error!("handler error: {e}");
1365 (
1366 StatusCode::INTERNAL_SERVER_ERROR,
1367 Json(json!({"error": "internal server error"})),
1368 )
1369 .into_response()
1370 }
1371 }
1372}
1373
1374pub async fn search_memories(
1375 State(state): State<Db>,
1376 Query(p): Query<SearchQuery>,
1377) -> impl IntoResponse {
1378 if p.q.trim().is_empty() {
1379 return (
1380 StatusCode::BAD_REQUEST,
1381 Json(json!({"error": "query is required"})),
1382 )
1383 .into_response();
1384 }
1385 if let Some(ref aid) = p.agent_id
1387 && let Err(e) = validate::validate_agent_id(aid)
1388 {
1389 return (
1390 StatusCode::BAD_REQUEST,
1391 Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1392 )
1393 .into_response();
1394 }
1395 if let Some(ref a) = p.as_agent
1397 && let Err(e) = validate::validate_namespace(a)
1398 {
1399 return (
1400 StatusCode::BAD_REQUEST,
1401 Json(json!({"error": format!("invalid as_agent: {e}")})),
1402 )
1403 .into_response();
1404 }
1405 let lock = state.lock().await;
1406 let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1409 match db::search(
1410 &lock.0,
1411 &p.q,
1412 p.namespace.as_deref(),
1413 p.tier.as_ref(),
1414 limit,
1415 p.min_priority,
1416 p.since.as_deref(),
1417 p.until.as_deref(),
1418 p.tags.as_deref(),
1419 p.agent_id.as_deref(),
1420 p.as_agent.as_deref(),
1421 ) {
1422 Ok(r) => Json(json!({"results": r, "count": r.len(), "query": p.q})).into_response(),
1423 Err(e) => {
1424 tracing::error!("handler error: {e}");
1425 (
1426 StatusCode::INTERNAL_SERVER_ERROR,
1427 Json(json!({"error": "internal server error"})),
1428 )
1429 .into_response()
1430 }
1431 }
1432}
1433
1434pub async fn recall_memories_get(
1435 State(app): State<AppState>,
1436 Query(p): Query<RecallQuery>,
1437) -> impl IntoResponse {
1438 let ctx = p.context.unwrap_or_default();
1439 if ctx.trim().is_empty() {
1440 return (
1441 StatusCode::BAD_REQUEST,
1442 Json(json!({"error": "context is required"})),
1443 )
1444 .into_response();
1445 }
1446 if p.budget_tokens == Some(0) {
1448 return (
1449 StatusCode::BAD_REQUEST,
1450 Json(json!({"error": "budget_tokens must be >= 1"})),
1451 )
1452 .into_response();
1453 }
1454 if let Some(ref a) = p.as_agent
1455 && let Err(e) = validate::validate_namespace(a)
1456 {
1457 return (
1458 StatusCode::BAD_REQUEST,
1459 Json(json!({"error": format!("invalid as_agent: {e}")})),
1460 )
1461 .into_response();
1462 }
1463 let limit = p.limit.unwrap_or(10).min(50);
1464 recall_response(
1465 &app,
1466 &ctx,
1467 p.namespace.as_deref(),
1468 limit,
1469 p.tags.as_deref(),
1470 p.since.as_deref(),
1471 p.until.as_deref(),
1472 p.as_agent.as_deref(),
1473 p.budget_tokens,
1474 )
1475 .await
1476}
1477
1478pub async fn recall_memories_post(
1479 State(app): State<AppState>,
1480 Json(body): Json<RecallBody>,
1481) -> impl IntoResponse {
1482 if body.context.trim().is_empty() {
1483 return (
1484 StatusCode::BAD_REQUEST,
1485 Json(json!({"error": "context is required"})),
1486 )
1487 .into_response();
1488 }
1489 if body.budget_tokens == Some(0) {
1490 return (
1491 StatusCode::BAD_REQUEST,
1492 Json(json!({"error": "budget_tokens must be >= 1"})),
1493 )
1494 .into_response();
1495 }
1496 if let Some(ref a) = body.as_agent
1497 && let Err(e) = validate::validate_namespace(a)
1498 {
1499 return (
1500 StatusCode::BAD_REQUEST,
1501 Json(json!({"error": format!("invalid as_agent: {e}")})),
1502 )
1503 .into_response();
1504 }
1505 let limit = body.limit.unwrap_or(10).min(50);
1506 recall_response(
1507 &app,
1508 &body.context,
1509 body.namespace.as_deref(),
1510 limit,
1511 body.tags.as_deref(),
1512 body.since.as_deref(),
1513 body.until.as_deref(),
1514 body.as_agent.as_deref(),
1515 body.budget_tokens,
1516 )
1517 .await
1518}
1519
1520#[allow(clippy::too_many_arguments)]
1529async fn recall_response(
1530 app: &AppState,
1531 context: &str,
1532 namespace: Option<&str>,
1533 limit: usize,
1534 tags: Option<&str>,
1535 since: Option<&str>,
1536 until: Option<&str>,
1537 as_agent: Option<&str>,
1538 budget_tokens: Option<usize>,
1539) -> axum::response::Response {
1540 let query_emb: Option<Vec<f32>> = if let Some(emb) = app.embedder.as_ref().as_ref() {
1543 match emb.embed(context) {
1544 Ok(v) => Some(v),
1545 Err(e) => {
1546 tracing::warn!("recall: embedder query failed, falling back to keyword-only: {e}");
1547 None
1548 }
1549 }
1550 } else {
1551 None
1552 };
1553
1554 let lock = app.db.lock().await;
1555 let short_extend = lock.2.short_extend_secs;
1556 let mid_extend = lock.2.mid_extend_secs;
1557
1558 let (result, mode) = if let Some(ref qe) = query_emb {
1559 let vi_guard = app.vector_index.lock().await;
1560 let vi_ref = vi_guard.as_ref();
1561 let r = db::recall_hybrid(
1562 &lock.0,
1563 context,
1564 qe,
1565 namespace,
1566 limit,
1567 tags,
1568 since,
1569 until,
1570 vi_ref,
1571 short_extend,
1572 mid_extend,
1573 as_agent,
1574 budget_tokens,
1575 app.scoring.as_ref(),
1576 );
1577 drop(vi_guard);
1578 (r, "hybrid")
1579 } else {
1580 let r = db::recall(
1581 &lock.0,
1582 context,
1583 namespace,
1584 limit,
1585 tags,
1586 since,
1587 until,
1588 short_extend,
1589 mid_extend,
1590 as_agent,
1591 budget_tokens,
1592 );
1593 (r, "keyword")
1594 };
1595
1596 match result {
1597 Ok((r, tokens_used)) => {
1598 let scored: Vec<serde_json::Value> = r
1599 .iter()
1600 .map(|(m, s)| {
1601 let mut v = serde_json::to_value(m).unwrap_or_default();
1602 if let Some(obj) = v.as_object_mut() {
1603 obj.insert("score".to_string(), json!((*s * 1000.0).round() / 1000.0));
1604 }
1605 v
1606 })
1607 .collect();
1608 let mut resp = json!({
1609 "memories": scored,
1610 "count": scored.len(),
1611 "tokens_used": tokens_used,
1612 "mode": mode,
1613 });
1614 if let Some(b) = budget_tokens {
1615 resp["budget_tokens"] = json!(b);
1616 }
1617 Json(resp).into_response()
1618 }
1619 Err(e) => {
1620 tracing::error!("handler error: {e}");
1621 (
1622 StatusCode::INTERNAL_SERVER_ERROR,
1623 Json(json!({"error": "internal server error"})),
1624 )
1625 .into_response()
1626 }
1627 }
1628}
1629
1630pub async fn forget_memories(
1631 State(state): State<Db>,
1632 Json(body): Json<ForgetQuery>,
1633) -> impl IntoResponse {
1634 let lock = state.lock().await;
1635 match db::forget(
1636 &lock.0,
1637 body.namespace.as_deref(),
1638 body.pattern.as_deref(),
1639 body.tier.as_ref(),
1640 lock.3, ) {
1642 Ok(n) => Json(json!({"deleted": n})).into_response(),
1643 Err(e) => (
1644 StatusCode::BAD_REQUEST,
1645 Json(json!({"error": e.to_string()})),
1646 )
1647 .into_response(),
1648 }
1649}
1650
1651#[derive(Deserialize)]
1652pub struct ContradictionsQuery {
1653 pub topic: Option<String>,
1657 pub namespace: Option<String>,
1659 pub limit: Option<usize>,
1661}
1662
1663#[allow(clippy::too_many_lines)]
1684pub async fn detect_contradictions(
1685 State(state): State<Db>,
1686 Query(q): Query<ContradictionsQuery>,
1687) -> impl IntoResponse {
1688 if q.topic.is_none() && q.namespace.is_none() {
1689 return (
1690 StatusCode::BAD_REQUEST,
1691 Json(json!({"error": "at least one of `topic` or `namespace` is required"})),
1692 )
1693 .into_response();
1694 }
1695 if let Some(ref ns) = q.namespace
1696 && let Err(e) = validate::validate_namespace(ns)
1697 {
1698 return (
1699 StatusCode::BAD_REQUEST,
1700 Json(json!({"error": e.to_string()})),
1701 )
1702 .into_response();
1703 }
1704 let limit = q.limit.unwrap_or(50).min(MAX_BULK_SIZE);
1707 let lock = state.lock().await;
1708 let all = match db::list(
1709 &lock.0,
1710 q.namespace.as_deref(),
1711 None,
1712 limit,
1713 0,
1714 None,
1715 None,
1716 None,
1717 None,
1718 None,
1719 ) {
1720 Ok(v) => v,
1721 Err(e) => {
1722 tracing::error!("detect_contradictions list error: {e}");
1723 return (
1724 StatusCode::INTERNAL_SERVER_ERROR,
1725 Json(json!({"error": "internal server error"})),
1726 )
1727 .into_response();
1728 }
1729 };
1730
1731 let candidates: Vec<Memory> = match q.topic.as_deref() {
1735 Some(t) => all
1736 .into_iter()
1737 .filter(|m| {
1738 m.metadata
1739 .get("topic")
1740 .and_then(|v| v.as_str())
1741 .is_some_and(|s| s == t)
1742 || m.title == t
1743 })
1744 .collect(),
1745 None => all,
1746 };
1747
1748 let candidate_ids: std::collections::HashSet<String> =
1750 candidates.iter().map(|m| m.id.clone()).collect();
1751 let mut existing_links: Vec<serde_json::Value> = Vec::new();
1752 for id in &candidate_ids {
1753 if let Ok(links) = db::get_links(&lock.0, id) {
1754 for link in links {
1755 if link.relation.contains("contradict")
1756 && candidate_ids.contains(&link.source_id)
1757 && candidate_ids.contains(&link.target_id)
1758 {
1759 existing_links.push(json!({
1760 "source_id": link.source_id,
1761 "target_id": link.target_id,
1762 "relation": link.relation,
1763 "synthesized": false,
1764 }));
1765 }
1766 }
1767 }
1768 }
1769 existing_links.sort_by_key(|v| {
1771 (
1772 v.get("source_id")
1773 .and_then(|s| s.as_str())
1774 .unwrap_or("")
1775 .to_string(),
1776 v.get("target_id")
1777 .and_then(|s| s.as_str())
1778 .unwrap_or("")
1779 .to_string(),
1780 v.get("relation")
1781 .and_then(|s| s.as_str())
1782 .unwrap_or("")
1783 .to_string(),
1784 )
1785 });
1786 existing_links.dedup_by_key(|v| {
1787 (
1788 v.get("source_id")
1789 .and_then(|s| s.as_str())
1790 .unwrap_or("")
1791 .to_string(),
1792 v.get("target_id")
1793 .and_then(|s| s.as_str())
1794 .unwrap_or("")
1795 .to_string(),
1796 v.get("relation")
1797 .and_then(|s| s.as_str())
1798 .unwrap_or("")
1799 .to_string(),
1800 )
1801 });
1802
1803 let mut synth_links: Vec<serde_json::Value> = Vec::new();
1808 for (i, a) in candidates.iter().enumerate() {
1809 for b in candidates.iter().skip(i + 1) {
1810 let same_topic = match q.topic.as_deref() {
1811 Some(_) => true,
1812 None => a.title == b.title,
1813 };
1814 if same_topic && a.content != b.content && a.id != b.id {
1815 synth_links.push(json!({
1816 "source_id": a.id,
1817 "target_id": b.id,
1818 "relation": "contradicts",
1819 "synthesized": true,
1820 }));
1821 }
1822 }
1823 }
1824
1825 let mut links = existing_links;
1826 links.extend(synth_links);
1827
1828 Json(json!({
1829 "memories": candidates,
1830 "links": links,
1831 }))
1832 .into_response()
1833}
1834
1835pub async fn list_namespaces(State(state): State<Db>) -> impl IntoResponse {
1836 let lock = state.lock().await;
1837 match db::list_namespaces(&lock.0) {
1838 Ok(ns) => Json(json!({"namespaces": ns})).into_response(),
1839 Err(e) => {
1840 tracing::error!("handler error: {e}");
1841 (
1842 StatusCode::INTERNAL_SERVER_ERROR,
1843 Json(json!({"error": "internal server error"})),
1844 )
1845 .into_response()
1846 }
1847 }
1848}
1849
1850#[derive(Debug, Deserialize)]
1852pub struct TaxonomyQuery {
1853 pub prefix: Option<String>,
1856 pub depth: Option<usize>,
1859 pub limit: Option<usize>,
1862}
1863
1864pub async fn get_taxonomy(
1869 State(state): State<Db>,
1870 Query(p): Query<TaxonomyQuery>,
1871) -> impl IntoResponse {
1872 let prefix_owned: Option<String> = p
1873 .prefix
1874 .as_deref()
1875 .map(str::trim)
1876 .filter(|s| !s.is_empty())
1877 .map(|s| s.trim_end_matches('/').to_string());
1878 if let Some(pref) = prefix_owned.as_deref()
1879 && let Err(e) = validate::validate_namespace(pref)
1880 {
1881 return (
1882 StatusCode::BAD_REQUEST,
1883 Json(json!({"error": format!("invalid namespace_prefix: {e}")})),
1884 )
1885 .into_response();
1886 }
1887 let depth = p
1888 .depth
1889 .unwrap_or(crate::models::MAX_NAMESPACE_DEPTH)
1890 .min(crate::models::MAX_NAMESPACE_DEPTH);
1891 let limit = p.limit.unwrap_or(1000).clamp(1, 10_000);
1892 let lock = state.lock().await;
1893 match db::get_taxonomy(&lock.0, prefix_owned.as_deref(), depth, limit) {
1894 Ok(tax) => Json(json!({
1895 "tree": tax.tree,
1896 "total_count": tax.total_count,
1897 "truncated": tax.truncated,
1898 }))
1899 .into_response(),
1900 Err(e) => {
1901 tracing::error!("handler error: {e}");
1902 (
1903 StatusCode::INTERNAL_SERVER_ERROR,
1904 Json(json!({"error": "internal server error"})),
1905 )
1906 .into_response()
1907 }
1908 }
1909}
1910
1911#[derive(Debug, Deserialize)]
1913pub struct CheckDuplicateBody {
1914 pub title: String,
1915 pub content: String,
1916 pub namespace: Option<String>,
1919 pub threshold: Option<f32>,
1923}
1924
1925pub async fn check_duplicate(
1931 State(app): State<AppState>,
1932 Json(body): Json<CheckDuplicateBody>,
1933) -> impl IntoResponse {
1934 if let Err(e) = validate::validate_title(&body.title) {
1935 return (
1936 StatusCode::BAD_REQUEST,
1937 Json(json!({"error": format!("invalid title: {e}")})),
1938 )
1939 .into_response();
1940 }
1941 if let Err(e) = validate::validate_content(&body.content) {
1942 return (
1943 StatusCode::BAD_REQUEST,
1944 Json(json!({"error": format!("invalid content: {e}")})),
1945 )
1946 .into_response();
1947 }
1948 let namespace = body
1949 .namespace
1950 .as_deref()
1951 .map(str::trim)
1952 .filter(|s| !s.is_empty());
1953 if let Some(ns) = namespace
1954 && let Err(e) = validate::validate_namespace(ns)
1955 {
1956 return (
1957 StatusCode::BAD_REQUEST,
1958 Json(json!({"error": format!("invalid namespace: {e}")})),
1959 )
1960 .into_response();
1961 }
1962 let threshold = body.threshold.unwrap_or(db::DUPLICATE_THRESHOLD_DEFAULT);
1963
1964 let embedding_text = format!("{} {}", body.title, body.content);
1968 let query_embedding = match app.embedder.as_ref().as_ref() {
1969 Some(emb) => match emb.embed(&embedding_text) {
1970 Ok(v) => v,
1971 Err(e) => {
1972 tracing::warn!("embedding generation failed: {e}");
1973 return (
1974 StatusCode::SERVICE_UNAVAILABLE,
1975 Json(json!({"error": "embedder failed to encode input"})),
1976 )
1977 .into_response();
1978 }
1979 },
1980 None => {
1981 return (
1982 StatusCode::SERVICE_UNAVAILABLE,
1983 Json(json!({
1984 "error": "memory_check_duplicate requires the embedder; daemon must be started with semantic tier or above"
1985 })),
1986 )
1987 .into_response();
1988 }
1989 };
1990
1991 let lock = app.db.lock().await;
1992 let check = match db::check_duplicate(&lock.0, &query_embedding, namespace, threshold) {
1993 Ok(c) => c,
1994 Err(e) => {
1995 tracing::error!("handler error: {e}");
1996 return (
1997 StatusCode::INTERNAL_SERVER_ERROR,
1998 Json(json!({"error": "internal server error"})),
1999 )
2000 .into_response();
2001 }
2002 };
2003
2004 let nearest_json = check.nearest.as_ref().map(|m| {
2005 json!({
2006 "id": m.id,
2007 "title": m.title,
2008 "namespace": m.namespace,
2009 "similarity": (m.similarity * 1000.0).round() / 1000.0,
2010 })
2011 });
2012 let suggested_merge = if check.is_duplicate {
2013 check.nearest.as_ref().map(|m| m.id.clone())
2014 } else {
2015 None
2016 };
2017
2018 Json(json!({
2019 "is_duplicate": check.is_duplicate,
2020 "threshold": check.threshold,
2021 "nearest": nearest_json,
2022 "suggested_merge": suggested_merge,
2023 "candidates_scanned": check.candidates_scanned,
2024 }))
2025 .into_response()
2026}
2027
2028#[derive(Debug, Deserialize)]
2030pub struct EntityRegisterBody {
2031 pub canonical_name: String,
2032 pub namespace: String,
2033 #[serde(default)]
2036 pub aliases: Vec<String>,
2037 #[serde(default)]
2040 pub metadata: serde_json::Value,
2041 pub agent_id: Option<String>,
2045}
2046
2047#[derive(Debug, Deserialize)]
2050pub struct EntityByAliasQuery {
2051 pub alias: String,
2052 pub namespace: Option<String>,
2053}
2054
2055pub async fn entity_register(
2059 State(state): State<Db>,
2060 headers: HeaderMap,
2061 Json(body): Json<EntityRegisterBody>,
2062) -> impl IntoResponse {
2063 if let Err(e) = validate::validate_title(&body.canonical_name) {
2064 return (
2065 StatusCode::BAD_REQUEST,
2066 Json(json!({"error": format!("invalid canonical_name: {e}")})),
2067 )
2068 .into_response();
2069 }
2070 if let Err(e) = validate::validate_namespace(&body.namespace) {
2071 return (
2072 StatusCode::BAD_REQUEST,
2073 Json(json!({"error": format!("invalid namespace: {e}")})),
2074 )
2075 .into_response();
2076 }
2077
2078 let agent_id = body
2079 .agent_id
2080 .as_deref()
2081 .or_else(|| headers.get("x-agent-id").and_then(|v| v.to_str().ok()))
2082 .map(str::trim)
2083 .filter(|s| !s.is_empty())
2084 .map(str::to_string);
2085 if let Some(aid) = agent_id.as_deref()
2086 && let Err(e) = validate::validate_agent_id(aid)
2087 {
2088 return (
2089 StatusCode::BAD_REQUEST,
2090 Json(json!({"error": format!("invalid agent_id: {e}")})),
2091 )
2092 .into_response();
2093 }
2094
2095 let extra_metadata = if body.metadata.is_object() {
2096 body.metadata.clone()
2097 } else {
2098 json!({})
2099 };
2100
2101 let lock = state.lock().await;
2102 match db::entity_register(
2103 &lock.0,
2104 &body.canonical_name,
2105 &body.namespace,
2106 &body.aliases,
2107 &extra_metadata,
2108 agent_id.as_deref(),
2109 ) {
2110 Ok(reg) => {
2111 let status = if reg.created {
2112 StatusCode::CREATED
2113 } else {
2114 StatusCode::OK
2115 };
2116 (
2117 status,
2118 Json(json!({
2119 "entity_id": reg.entity_id,
2120 "canonical_name": reg.canonical_name,
2121 "namespace": reg.namespace,
2122 "aliases": reg.aliases,
2123 "created": reg.created,
2124 })),
2125 )
2126 .into_response()
2127 }
2128 Err(e) => {
2129 let msg = e.to_string();
2133 if msg.contains("non-entity memory") {
2134 return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
2135 }
2136 tracing::error!("handler error: {e}");
2137 (
2138 StatusCode::INTERNAL_SERVER_ERROR,
2139 Json(json!({"error": "internal server error"})),
2140 )
2141 .into_response()
2142 }
2143 }
2144}
2145
2146pub async fn entity_get_by_alias(
2152 State(state): State<Db>,
2153 Query(p): Query<EntityByAliasQuery>,
2154) -> impl IntoResponse {
2155 let alias = p.alias.trim();
2156 if alias.is_empty() {
2157 return (
2158 StatusCode::BAD_REQUEST,
2159 Json(json!({"error": "alias is required"})),
2160 )
2161 .into_response();
2162 }
2163 let namespace = p
2164 .namespace
2165 .as_deref()
2166 .map(str::trim)
2167 .filter(|s| !s.is_empty());
2168 if let Some(ns) = namespace
2169 && let Err(e) = validate::validate_namespace(ns)
2170 {
2171 return (
2172 StatusCode::BAD_REQUEST,
2173 Json(json!({"error": format!("invalid namespace: {e}")})),
2174 )
2175 .into_response();
2176 }
2177
2178 let lock = state.lock().await;
2179 match db::entity_get_by_alias(&lock.0, alias, namespace) {
2180 Ok(Some(rec)) => Json(json!({
2181 "found": true,
2182 "entity_id": rec.entity_id,
2183 "canonical_name": rec.canonical_name,
2184 "namespace": rec.namespace,
2185 "aliases": rec.aliases,
2186 }))
2187 .into_response(),
2188 Ok(None) => Json(json!({
2189 "found": false,
2190 "entity_id": null,
2191 "canonical_name": null,
2192 "namespace": null,
2193 "aliases": [],
2194 }))
2195 .into_response(),
2196 Err(e) => {
2197 tracing::error!("handler error: {e}");
2198 (
2199 StatusCode::INTERNAL_SERVER_ERROR,
2200 Json(json!({"error": "internal server error"})),
2201 )
2202 .into_response()
2203 }
2204 }
2205}
2206
2207#[derive(Debug, Deserialize)]
2209pub struct KgTimelineQuery {
2210 pub source_id: String,
2211 pub since: Option<String>,
2212 pub until: Option<String>,
2213 pub limit: Option<usize>,
2214}
2215
2216pub async fn kg_timeline(
2220 State(state): State<Db>,
2221 Query(p): Query<KgTimelineQuery>,
2222) -> impl IntoResponse {
2223 if let Err(e) = validate::validate_id(&p.source_id) {
2224 return (
2225 StatusCode::BAD_REQUEST,
2226 Json(json!({"error": format!("invalid source_id: {e}")})),
2227 )
2228 .into_response();
2229 }
2230 let since = p.since.as_deref().map(str::trim).filter(|s| !s.is_empty());
2231 let until = p.until.as_deref().map(str::trim).filter(|s| !s.is_empty());
2232 if let Some(s) = since
2233 && let Err(e) = validate::validate_expires_at_format(s)
2234 {
2235 return (
2236 StatusCode::BAD_REQUEST,
2237 Json(json!({"error": format!("invalid since: {e}")})),
2238 )
2239 .into_response();
2240 }
2241 if let Some(u) = until
2242 && let Err(e) = validate::validate_expires_at_format(u)
2243 {
2244 return (
2245 StatusCode::BAD_REQUEST,
2246 Json(json!({"error": format!("invalid until: {e}")})),
2247 )
2248 .into_response();
2249 }
2250
2251 let lock = state.lock().await;
2252 match db::kg_timeline(&lock.0, &p.source_id, since, until, p.limit) {
2253 Ok(events) => {
2254 let events_json: Vec<serde_json::Value> = events
2255 .iter()
2256 .map(|e| {
2257 json!({
2258 "target_id": e.target_id,
2259 "relation": e.relation,
2260 "valid_from": e.valid_from,
2261 "valid_until": e.valid_until,
2262 "observed_by": e.observed_by,
2263 "title": e.title,
2264 "target_namespace": e.target_namespace,
2265 })
2266 })
2267 .collect();
2268 Json(json!({
2269 "source_id": p.source_id,
2270 "events": events_json,
2271 "count": events.len(),
2272 }))
2273 .into_response()
2274 }
2275 Err(e) => {
2276 tracing::error!("handler error: {e}");
2277 (
2278 StatusCode::INTERNAL_SERVER_ERROR,
2279 Json(json!({"error": "internal server error"})),
2280 )
2281 .into_response()
2282 }
2283 }
2284}
2285
2286#[derive(Debug, Deserialize)]
2290pub struct KgInvalidateBody {
2291 pub source_id: String,
2292 pub target_id: String,
2293 pub relation: String,
2294 pub valid_until: Option<String>,
2295}
2296
2297pub async fn kg_invalidate(
2301 State(state): State<Db>,
2302 Json(body): Json<KgInvalidateBody>,
2303) -> impl IntoResponse {
2304 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2305 return (
2306 StatusCode::BAD_REQUEST,
2307 Json(json!({"error": e.to_string()})),
2308 )
2309 .into_response();
2310 }
2311 let valid_until = body
2312 .valid_until
2313 .as_deref()
2314 .map(str::trim)
2315 .filter(|s| !s.is_empty());
2316 if let Some(ts) = valid_until
2317 && let Err(e) = validate::validate_expires_at_format(ts)
2318 {
2319 return (
2320 StatusCode::BAD_REQUEST,
2321 Json(json!({"error": format!("invalid valid_until: {e}")})),
2322 )
2323 .into_response();
2324 }
2325
2326 let lock = state.lock().await;
2327 match db::invalidate_link(
2328 &lock.0,
2329 &body.source_id,
2330 &body.target_id,
2331 &body.relation,
2332 valid_until,
2333 ) {
2334 Ok(Some(res)) => (
2335 StatusCode::OK,
2336 Json(json!({
2337 "found": true,
2338 "source_id": body.source_id,
2339 "target_id": body.target_id,
2340 "relation": body.relation,
2341 "valid_until": res.valid_until,
2342 "previous_valid_until": res.previous_valid_until,
2343 })),
2344 )
2345 .into_response(),
2346 Ok(None) => (
2347 StatusCode::NOT_FOUND,
2348 Json(json!({
2349 "found": false,
2350 "source_id": body.source_id,
2351 "target_id": body.target_id,
2352 "relation": body.relation,
2353 })),
2354 )
2355 .into_response(),
2356 Err(e) => {
2357 tracing::error!("handler error: {e}");
2358 (
2359 StatusCode::INTERNAL_SERVER_ERROR,
2360 Json(json!({"error": "internal server error"})),
2361 )
2362 .into_response()
2363 }
2364 }
2365}
2366
2367#[derive(Debug, Deserialize)]
2373pub struct KgQueryBody {
2374 pub source_id: String,
2375 pub max_depth: Option<usize>,
2376 pub valid_at: Option<String>,
2377 pub allowed_agents: Option<Vec<String>>,
2378 pub limit: Option<usize>,
2379}
2380
2381pub async fn kg_query(State(state): State<Db>, Json(body): Json<KgQueryBody>) -> impl IntoResponse {
2388 if let Err(e) = validate::validate_id(&body.source_id) {
2389 return (
2390 StatusCode::BAD_REQUEST,
2391 Json(json!({"error": format!("invalid source_id: {e}")})),
2392 )
2393 .into_response();
2394 }
2395 let max_depth = body.max_depth.unwrap_or(1);
2396 let valid_at = body
2397 .valid_at
2398 .as_deref()
2399 .map(str::trim)
2400 .filter(|s| !s.is_empty());
2401 if let Some(t) = valid_at
2402 && let Err(e) = validate::validate_expires_at_format(t)
2403 {
2404 return (
2405 StatusCode::BAD_REQUEST,
2406 Json(json!({"error": format!("invalid valid_at: {e}")})),
2407 )
2408 .into_response();
2409 }
2410 let allowed_agents: Option<Vec<String>> = body.allowed_agents.as_ref().map(|v| {
2411 v.iter()
2412 .map(|s| s.trim().to_string())
2413 .filter(|s| !s.is_empty())
2414 .collect()
2415 });
2416 if let Some(agents) = allowed_agents.as_ref() {
2417 for a in agents {
2418 if let Err(e) = validate::validate_agent_id(a) {
2419 return (
2420 StatusCode::BAD_REQUEST,
2421 Json(json!({"error": format!("invalid allowed_agents entry: {e}")})),
2422 )
2423 .into_response();
2424 }
2425 }
2426 }
2427
2428 let lock = state.lock().await;
2429 match db::kg_query(
2430 &lock.0,
2431 &body.source_id,
2432 max_depth,
2433 valid_at,
2434 allowed_agents.as_deref(),
2435 body.limit,
2436 ) {
2437 Ok(nodes) => {
2438 let memories_json: Vec<serde_json::Value> = nodes
2439 .iter()
2440 .map(|n| {
2441 json!({
2442 "target_id": n.target_id,
2443 "relation": n.relation,
2444 "valid_from": n.valid_from,
2445 "valid_until": n.valid_until,
2446 "observed_by": n.observed_by,
2447 "title": n.title,
2448 "target_namespace": n.target_namespace,
2449 "depth": n.depth,
2450 "path": n.path,
2451 })
2452 })
2453 .collect();
2454 let paths_json: Vec<&str> = nodes.iter().map(|n| n.path.as_str()).collect();
2455 Json(json!({
2456 "source_id": body.source_id,
2457 "max_depth": max_depth,
2458 "memories": memories_json,
2459 "paths": paths_json,
2460 "count": nodes.len(),
2461 }))
2462 .into_response()
2463 }
2464 Err(e) => {
2465 let msg = e.to_string();
2469 if msg.contains("max_depth") {
2470 return (
2471 StatusCode::UNPROCESSABLE_ENTITY,
2472 Json(json!({"error": msg})),
2473 )
2474 .into_response();
2475 }
2476 tracing::error!("handler error: {e}");
2477 (
2478 StatusCode::INTERNAL_SERVER_ERROR,
2479 Json(json!({"error": "internal server error"})),
2480 )
2481 .into_response()
2482 }
2483 }
2484}
2485
2486pub async fn create_link(
2487 State(app): State<AppState>,
2488 Json(body): Json<LinkBody>,
2489) -> impl IntoResponse {
2490 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2491 return (
2492 StatusCode::BAD_REQUEST,
2493 Json(json!({"error": e.to_string()})),
2494 )
2495 .into_response();
2496 }
2497 let lock = app.db.lock().await;
2498 let create_result = db::create_link(&lock.0, &body.source_id, &body.target_id, &body.relation);
2499 drop(lock);
2502 match create_result {
2503 Ok(()) => {
2504 if let Some(fed) = app.federation.as_ref() {
2506 let link = crate::models::MemoryLink {
2507 source_id: body.source_id.clone(),
2508 target_id: body.target_id.clone(),
2509 relation: body.relation.clone(),
2510 created_at: chrono::Utc::now().to_rfc3339(),
2511 };
2512 match crate::federation::broadcast_link_quorum(fed, &link).await {
2513 Ok(tracker) => {
2514 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2515 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2516 return (
2517 StatusCode::SERVICE_UNAVAILABLE,
2518 [("Retry-After", "2")],
2519 Json(serde_json::to_value(&payload).unwrap_or_default()),
2520 )
2521 .into_response();
2522 }
2523 }
2524 Err(e) => {
2525 tracing::warn!("link fanout error (local committed): {e:?}");
2526 }
2527 }
2528 }
2529 (StatusCode::CREATED, Json(json!({"linked": true}))).into_response()
2530 }
2531 Err(e) => {
2532 tracing::error!("handler error: {e}");
2533 (
2534 StatusCode::INTERNAL_SERVER_ERROR,
2535 Json(json!({"error": "internal server error"})),
2536 )
2537 .into_response()
2538 }
2539 }
2540}
2541
2542pub async fn delete_link(
2549 State(app): State<AppState>,
2550 Json(body): Json<LinkBody>,
2551) -> impl IntoResponse {
2552 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2553 return (
2554 StatusCode::BAD_REQUEST,
2555 Json(json!({"error": e.to_string()})),
2556 )
2557 .into_response();
2558 }
2559 let lock = app.db.lock().await;
2560 let delete_result = db::delete_link(&lock.0, &body.source_id, &body.target_id);
2561 drop(lock);
2562 match delete_result {
2563 Ok(removed) => Json(json!({"deleted": removed})).into_response(),
2564 Err(e) => {
2565 tracing::error!("handler error: {e}");
2566 (
2567 StatusCode::INTERNAL_SERVER_ERROR,
2568 Json(json!({"error": "internal server error"})),
2569 )
2570 .into_response()
2571 }
2572 }
2573}
2574
2575pub async fn get_links(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
2576 if let Err(e) = validate::validate_id(&id) {
2577 return (
2578 StatusCode::BAD_REQUEST,
2579 Json(json!({"error": e.to_string()})),
2580 )
2581 .into_response();
2582 }
2583 let lock = state.lock().await;
2584 match db::get_links(&lock.0, &id) {
2585 Ok(links) => Json(json!({"links": links})).into_response(),
2586 Err(e) => {
2587 tracing::error!("handler error: {e}");
2588 (
2589 StatusCode::INTERNAL_SERVER_ERROR,
2590 Json(json!({"error": "internal server error"})),
2591 )
2592 .into_response()
2593 }
2594 }
2595}
2596
2597pub async fn get_stats(State(state): State<Db>) -> impl IntoResponse {
2598 let lock = state.lock().await;
2599 match db::stats(&lock.0, &lock.1) {
2600 Ok(s) => Json(json!(s)).into_response(),
2601 Err(e) => {
2602 tracing::error!("handler error: {e}");
2603 (
2604 StatusCode::INTERNAL_SERVER_ERROR,
2605 Json(json!({"error": "internal server error"})),
2606 )
2607 .into_response()
2608 }
2609 }
2610}
2611
2612pub async fn run_gc(State(state): State<Db>) -> impl IntoResponse {
2613 let lock = state.lock().await;
2614 match db::gc(&lock.0, lock.3) {
2615 Ok(n) => Json(json!({"expired_deleted": n})).into_response(),
2616 Err(e) => {
2617 tracing::error!("handler error: {e}");
2618 (
2619 StatusCode::INTERNAL_SERVER_ERROR,
2620 Json(json!({"error": "internal server error"})),
2621 )
2622 .into_response()
2623 }
2624 }
2625}
2626
2627pub async fn export_memories(State(state): State<Db>) -> impl IntoResponse {
2628 let lock = state.lock().await;
2629 match (db::export_all(&lock.0), db::export_links(&lock.0)) {
2630 (Ok(memories), Ok(links)) => {
2631 let count = memories.len();
2632 Json(json!({"memories": memories, "links": links, "count": count, "exported_at": Utc::now().to_rfc3339()})).into_response()
2633 }
2634 (Err(e), _) | (_, Err(e)) => {
2635 tracing::error!("export error: {e}");
2636 (
2637 StatusCode::INTERNAL_SERVER_ERROR,
2638 Json(json!({"error": "internal server error"})),
2639 )
2640 .into_response()
2641 }
2642 }
2643}
2644
2645pub async fn import_memories(
2646 State(state): State<Db>,
2647 Json(body): Json<ImportBody>,
2648) -> impl IntoResponse {
2649 if body.memories.len() > MAX_BULK_SIZE {
2650 return (
2651 StatusCode::BAD_REQUEST,
2652 Json(json!({"error": format!("import limited to {} memories", MAX_BULK_SIZE)})),
2653 )
2654 .into_response();
2655 }
2656 let lock = state.lock().await;
2657 let mut imported = 0usize;
2658 let mut errors = Vec::new();
2659 for mem in body.memories {
2660 if let Err(e) = validate::validate_memory(&mem) {
2661 errors.push(format!("{}: {}", mem.id, e));
2662 continue;
2663 }
2664 match db::insert(&lock.0, &mem) {
2665 Ok(_) => imported += 1,
2666 Err(e) => errors.push(format!("{}: {}", mem.id, e)),
2667 }
2668 }
2669 for link in body.links.unwrap_or_default() {
2670 if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
2671 continue;
2672 }
2673 let _ = db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation);
2674 }
2675 Json(json!({"imported": imported, "errors": errors})).into_response()
2676}
2677
2678#[derive(serde::Deserialize)]
2679pub struct ImportBody {
2680 pub memories: Vec<Memory>,
2681 #[serde(default)]
2682 pub links: Option<Vec<MemoryLink>>,
2683}
2684
2685#[derive(serde::Deserialize)]
2686pub struct ConsolidateBody {
2687 pub ids: Vec<String>,
2688 pub title: String,
2689 pub summary: String,
2690 #[serde(default = "default_ns")]
2691 pub namespace: String,
2692 #[serde(default)]
2693 pub tier: Option<Tier>,
2694 #[serde(default)]
2697 pub agent_id: Option<String>,
2698}
2699fn default_ns() -> String {
2700 "global".to_string()
2701}
2702
2703pub async fn consolidate_memories(
2704 State(app): State<AppState>,
2705 headers: HeaderMap,
2706 Json(body): Json<ConsolidateBody>,
2707) -> impl IntoResponse {
2708 if let Err(e) =
2709 validate::validate_consolidate(&body.ids, &body.title, &body.summary, &body.namespace)
2710 {
2711 return (
2712 StatusCode::BAD_REQUEST,
2713 Json(json!({"error": e.to_string()})),
2714 )
2715 .into_response();
2716 }
2717 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
2718 let consolidator_agent_id =
2719 match crate::identity::resolve_http_agent_id(body.agent_id.as_deref(), header_agent_id) {
2720 Ok(id) => id,
2721 Err(e) => {
2722 return (
2723 StatusCode::BAD_REQUEST,
2724 Json(json!({"error": format!("invalid agent_id: {e}")})),
2725 )
2726 .into_response();
2727 }
2728 };
2729 let lock = app.db.lock().await;
2730 let tier = body.tier.unwrap_or(Tier::Long);
2731 let source_ids = body.ids.clone();
2732 let consolidate_result = db::consolidate(
2733 &lock.0,
2734 &body.ids,
2735 &body.title,
2736 &body.summary,
2737 &body.namespace,
2738 &tier,
2739 "consolidation",
2740 &consolidator_agent_id,
2741 );
2742 let new_mem = match &consolidate_result {
2746 Ok(new_id) => db::get(&lock.0, new_id).ok().flatten(),
2747 Err(_) => None,
2748 };
2749 drop(lock);
2752 match consolidate_result {
2753 Ok(new_id) => {
2754 if let (Some(fed), Some(mem)) = (app.federation.as_ref(), new_mem) {
2758 match crate::federation::broadcast_consolidate_quorum(fed, &mem, &source_ids).await
2759 {
2760 Ok(tracker) => {
2761 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2762 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2763 return (
2764 StatusCode::SERVICE_UNAVAILABLE,
2765 [("Retry-After", "2")],
2766 Json(serde_json::to_value(&payload).unwrap_or_default()),
2767 )
2768 .into_response();
2769 }
2770 }
2771 Err(e) => {
2772 tracing::warn!("consolidate fanout error (local committed): {e:?}");
2773 }
2774 }
2775 }
2776 (
2777 StatusCode::CREATED,
2778 Json(json!({"id": new_id, "consolidated": body.ids.len()})),
2779 )
2780 .into_response()
2781 }
2782 Err(e) => {
2783 tracing::error!("handler error: {e}");
2784 (
2785 StatusCode::INTERNAL_SERVER_ERROR,
2786 Json(json!({"error": "internal server error"})),
2787 )
2788 .into_response()
2789 }
2790 }
2791}
2792
2793pub async fn bulk_create(
2794 State(app): State<AppState>,
2795 Json(bodies): Json<Vec<CreateMemory>>,
2796) -> impl IntoResponse {
2797 if bodies.len() > MAX_BULK_SIZE {
2798 return (
2799 StatusCode::BAD_REQUEST,
2800 Json(json!({"error": format!("bulk operations limited to {} items", MAX_BULK_SIZE)})),
2801 )
2802 .into_response();
2803 }
2804 let now = Utc::now();
2805 let mut created_mems: Vec<Memory> = Vec::new();
2810 let mut errors: Vec<String> = Vec::new();
2811 {
2812 let lock = app.db.lock().await;
2813 for body in bodies {
2814 if let Err(e) = validate::validate_create(&body) {
2815 errors.push(format!("{}: {}", body.title, e));
2816 continue;
2817 }
2818 let expires_at = body.expires_at.or_else(|| {
2819 body.ttl_secs
2820 .or(lock.2.ttl_for_tier(&body.tier))
2821 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
2822 });
2823 let mem = Memory {
2824 id: Uuid::new_v4().to_string(),
2825 tier: body.tier,
2826 namespace: body.namespace,
2827 title: body.title,
2828 content: body.content,
2829 tags: body.tags,
2830 priority: body.priority.clamp(1, 10),
2831 confidence: body.confidence.clamp(0.0, 1.0),
2832 source: body.source,
2833 access_count: 0,
2834 created_at: now.to_rfc3339(),
2835 updated_at: now.to_rfc3339(),
2836 last_accessed_at: None,
2837 expires_at,
2838 metadata: body.metadata,
2839 };
2840 match db::insert(&lock.0, &mem) {
2841 Ok(_) => created_mems.push(mem),
2842 Err(e) => errors.push(e.to_string()),
2843 }
2844 }
2845 }
2846 if let Some(fed) = app.federation.as_ref() {
2884 let sem = Arc::new(tokio::sync::Semaphore::new(BULK_FANOUT_CONCURRENCY));
2885 let mut joins: tokio::task::JoinSet<(String, Result<(), String>)> =
2886 tokio::task::JoinSet::new();
2887 for mem in &created_mems {
2888 let fed = fed.clone();
2889 let mem = mem.clone();
2890 let sem = sem.clone();
2891 joins.spawn(async move {
2892 let Ok(_permit) = sem.acquire_owned().await else {
2898 return (mem.id.clone(), Err("fanout semaphore closed".to_string()));
2899 };
2900 let id = mem.id.clone();
2901 let outcome = match crate::federation::broadcast_store_quorum(&fed, &mem).await {
2902 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
2903 Ok(_) => Ok(()),
2904 Err(err) => Err(err.to_string()),
2905 },
2906 Err(e) => {
2907 tracing::warn!(
2908 "bulk_create: fanout for {id} failed (local committed): {e:?}"
2909 );
2910 Ok(())
2911 }
2912 };
2913 (id, outcome)
2914 });
2915 }
2916 while let Some(res) = joins.join_next().await {
2917 match res {
2918 Ok((id, Err(err))) => errors.push(format!("{id}: {err}")),
2919 Ok((_, Ok(()))) => {}
2920 Err(e) => tracing::warn!("bulk_create: fanout task join error: {e:?}"),
2921 }
2922 }
2923
2924 if !created_mems.is_empty() {
2939 let catchup_errors = crate::federation::bulk_catchup_push(fed, &created_mems).await;
2940 for (peer_id, err) in catchup_errors {
2941 errors.push(format!("catchup to {peer_id}: {err}"));
2942 }
2943 }
2944 }
2945 Json(json!({"created": created_mems.len(), "errors": errors})).into_response()
2946}
2947
2948#[derive(Debug, Deserialize)]
2953pub struct ArchiveListQuery {
2954 pub namespace: Option<String>,
2955 #[serde(default = "default_archive_limit")]
2956 pub limit: Option<usize>,
2957 #[serde(default)]
2958 pub offset: Option<usize>,
2959}
2960
2961#[allow(clippy::unnecessary_wraps)]
2962fn default_archive_limit() -> Option<usize> {
2963 Some(50)
2964}
2965
2966pub async fn list_archive(
2967 State(state): State<Db>,
2968 Query(q): Query<ArchiveListQuery>,
2969) -> impl IntoResponse {
2970 if matches!(q.limit, Some(0)) {
2975 return (
2976 StatusCode::BAD_REQUEST,
2977 Json(json!({"error": "limit must be >= 1"})),
2978 )
2979 .into_response();
2980 }
2981 let lock = state.lock().await;
2982 let limit = q.limit.unwrap_or(50).clamp(1, 1000);
2983 let offset = q.offset.unwrap_or(0);
2984 match db::list_archived(&lock.0, q.namespace.as_deref(), limit, offset) {
2985 Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
2986 Err(e) => {
2987 tracing::error!("handler error: {e}");
2988 (
2989 StatusCode::INTERNAL_SERVER_ERROR,
2990 Json(json!({"error": "internal server error"})),
2991 )
2992 .into_response()
2993 }
2994 }
2995}
2996
2997pub async fn restore_archive(
2998 State(app): State<AppState>,
2999 Path(id): Path<String>,
3000) -> impl IntoResponse {
3001 if let Err(e) = validate::validate_id(&id) {
3002 return (
3003 StatusCode::BAD_REQUEST,
3004 Json(json!({"error": e.to_string()})),
3005 )
3006 .into_response();
3007 }
3008 let restored = {
3009 let lock = app.db.lock().await;
3010 match db::restore_archived(&lock.0, &id) {
3011 Ok(v) => v,
3012 Err(e) => {
3013 tracing::error!("handler error: {e}");
3014 return (
3015 StatusCode::INTERNAL_SERVER_ERROR,
3016 Json(json!({"error": "internal server error"})),
3017 )
3018 .into_response();
3019 }
3020 }
3021 };
3022 if !restored {
3023 return (
3024 StatusCode::NOT_FOUND,
3025 Json(json!({"error": "not found in archive"})),
3026 )
3027 .into_response();
3028 }
3029
3030 if let Some(fed) = app.federation.as_ref() {
3038 match crate::federation::broadcast_restore_quorum(fed, &id).await {
3039 Ok(tracker) => {
3040 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3041 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3042 return (
3043 StatusCode::SERVICE_UNAVAILABLE,
3044 [("Retry-After", "2")],
3045 Json(serde_json::to_value(&payload).unwrap_or_default()),
3046 )
3047 .into_response();
3048 }
3049 }
3050 Err(e) => {
3051 tracing::warn!("restore fanout error (local committed): {e:?}");
3054 }
3055 }
3056 }
3057
3058 Json(json!({"restored": true, "id": id})).into_response()
3059}
3060
3061#[derive(Debug, Deserialize)]
3062pub struct PurgeQuery {
3063 pub older_than_days: Option<i64>,
3064}
3065
3066pub async fn purge_archive(
3067 State(state): State<Db>,
3068 Query(q): Query<PurgeQuery>,
3069) -> impl IntoResponse {
3070 let lock = state.lock().await;
3071 match db::purge_archive(&lock.0, q.older_than_days) {
3072 Ok(n) => Json(json!({"purged": n})).into_response(),
3073 Err(e) => {
3074 tracing::error!("handler error: {e}");
3075 (
3076 StatusCode::INTERNAL_SERVER_ERROR,
3077 Json(json!({"error": "internal server error"})),
3078 )
3079 .into_response()
3080 }
3081 }
3082}
3083
3084pub async fn archive_stats(State(state): State<Db>) -> impl IntoResponse {
3085 let lock = state.lock().await;
3086 match db::archive_stats(&lock.0) {
3087 Ok(archive_stats) => Json(archive_stats).into_response(),
3088 Err(e) => {
3089 tracing::error!("handler error: {e}");
3090 (
3091 StatusCode::INTERNAL_SERVER_ERROR,
3092 Json(json!({"error": "internal server error"})),
3093 )
3094 .into_response()
3095 }
3096 }
3097}
3098
3099#[derive(Debug, Deserialize)]
3101pub struct ArchiveByIdsBody {
3102 pub ids: Vec<String>,
3103 #[serde(default)]
3104 pub reason: Option<String>,
3105}
3106
3107pub async fn archive_by_ids(
3126 State(app): State<AppState>,
3127 Json(body): Json<ArchiveByIdsBody>,
3128) -> impl IntoResponse {
3129 if body.ids.len() > MAX_BULK_SIZE {
3131 return (
3132 StatusCode::BAD_REQUEST,
3133 Json(json!({"error": format!("archive limited to {} ids per request", MAX_BULK_SIZE)})),
3134 )
3135 .into_response();
3136 }
3137 for id in &body.ids {
3139 if let Err(e) = validate::validate_id(id) {
3140 return (
3141 StatusCode::BAD_REQUEST,
3142 Json(json!({"error": format!("invalid id {id}: {e}")})),
3143 )
3144 .into_response();
3145 }
3146 }
3147 let reason = body.reason.as_deref().unwrap_or("archive").to_string();
3148 let mut archived: Vec<String> = Vec::new();
3149 let mut missing: Vec<String> = Vec::new();
3150
3151 for id in &body.ids {
3152 let moved = {
3155 let lock = app.db.lock().await;
3156 match db::archive_memory(&lock.0, id, Some(&reason)) {
3157 Ok(v) => v,
3158 Err(e) => {
3159 tracing::error!("archive_by_ids: archive_memory({id}) failed: {e}");
3160 return (
3161 StatusCode::INTERNAL_SERVER_ERROR,
3162 Json(json!({"error": "internal server error"})),
3163 )
3164 .into_response();
3165 }
3166 }
3167 };
3168 if !moved {
3169 missing.push(id.clone());
3174 continue;
3175 }
3176
3177 if let Some(fed) = app.federation.as_ref() {
3181 match crate::federation::broadcast_archive_quorum(fed, id).await {
3182 Ok(tracker) => {
3183 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3184 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3185 return (
3186 StatusCode::SERVICE_UNAVAILABLE,
3187 [("Retry-After", "2")],
3188 Json(serde_json::to_value(&payload).unwrap_or_default()),
3189 )
3190 .into_response();
3191 }
3192 }
3193 Err(e) => {
3194 tracing::warn!("archive fanout error (local committed): {e:?}");
3197 }
3198 }
3199 }
3200 archived.push(id.clone());
3201 }
3202
3203 (
3204 StatusCode::OK,
3205 Json(json!({
3206 "archived": archived,
3207 "missing": missing,
3208 "count": archived.len(),
3209 "reason": reason,
3210 })),
3211 )
3212 .into_response()
3213}
3214
3215#[derive(Deserialize)]
3225pub struct SyncPushBody {
3226 pub sender_agent_id: String,
3230 #[serde(default)]
3234 #[allow(dead_code)] pub sender_clock: crate::models::VectorClock,
3236 pub memories: Vec<Memory>,
3239 #[serde(default)]
3246 pub deletions: Vec<String>,
3247 #[serde(default)]
3252 pub archives: Vec<String>,
3253 #[serde(default)]
3259 pub restores: Vec<String>,
3260 #[serde(default)]
3265 pub links: Vec<MemoryLink>,
3266 #[serde(default)]
3272 pub pendings: Vec<crate::models::PendingAction>,
3273 #[serde(default)]
3277 pub pending_decisions: Vec<crate::models::PendingDecision>,
3278 #[serde(default)]
3284 pub namespace_meta: Vec<crate::models::NamespaceMetaEntry>,
3285 #[serde(default)]
3291 pub namespace_meta_clears: Vec<String>,
3292 #[serde(default)]
3294 pub dry_run: bool,
3295}
3296
3297#[derive(Deserialize)]
3298pub struct SyncSinceQuery {
3299 pub since: Option<String>,
3301 pub limit: Option<usize>,
3303 pub peer: Option<String>,
3306}
3307
3308#[allow(clippy::too_many_lines)]
3309pub async fn sync_push(
3310 State(app): State<AppState>,
3311 headers: HeaderMap,
3312 Json(body): Json<SyncPushBody>,
3313) -> impl IntoResponse {
3314 let state = app.db.clone();
3315 if let Err(e) = validate::validate_agent_id(&body.sender_agent_id) {
3316 return (
3317 StatusCode::BAD_REQUEST,
3318 Json(json!({"error": format!("invalid sender_agent_id: {e}")})),
3319 )
3320 .into_response();
3321 }
3322 if body.memories.len() > MAX_BULK_SIZE {
3326 return (
3327 StatusCode::BAD_REQUEST,
3328 Json(json!({
3329 "error": format!("sync_push limited to {} memories per request", MAX_BULK_SIZE)
3330 })),
3331 )
3332 .into_response();
3333 }
3334 if body.deletions.len() > MAX_BULK_SIZE {
3335 return (
3336 StatusCode::BAD_REQUEST,
3337 Json(json!({
3338 "error": format!("sync_push limited to {} deletions per request", MAX_BULK_SIZE)
3339 })),
3340 )
3341 .into_response();
3342 }
3343 if body.archives.len() > MAX_BULK_SIZE {
3344 return (
3345 StatusCode::BAD_REQUEST,
3346 Json(json!({
3347 "error": format!("sync_push limited to {} archives per request", MAX_BULK_SIZE)
3348 })),
3349 )
3350 .into_response();
3351 }
3352 if body.restores.len() > MAX_BULK_SIZE {
3353 return (
3354 StatusCode::BAD_REQUEST,
3355 Json(json!({
3356 "error": format!("sync_push limited to {} restores per request", MAX_BULK_SIZE)
3357 })),
3358 )
3359 .into_response();
3360 }
3361 if body.pendings.len() > MAX_BULK_SIZE {
3362 return (
3363 StatusCode::BAD_REQUEST,
3364 Json(json!({
3365 "error": format!("sync_push limited to {} pendings per request", MAX_BULK_SIZE)
3366 })),
3367 )
3368 .into_response();
3369 }
3370 if body.pending_decisions.len() > MAX_BULK_SIZE {
3371 return (
3372 StatusCode::BAD_REQUEST,
3373 Json(json!({
3374 "error": format!(
3375 "sync_push limited to {} pending_decisions per request",
3376 MAX_BULK_SIZE
3377 )
3378 })),
3379 )
3380 .into_response();
3381 }
3382 if body.namespace_meta.len() > MAX_BULK_SIZE {
3383 return (
3384 StatusCode::BAD_REQUEST,
3385 Json(json!({
3386 "error": format!(
3387 "sync_push limited to {} namespace_meta per request",
3388 MAX_BULK_SIZE
3389 )
3390 })),
3391 )
3392 .into_response();
3393 }
3394 if body.namespace_meta_clears.len() > MAX_BULK_SIZE {
3395 return (
3396 StatusCode::BAD_REQUEST,
3397 Json(json!({
3398 "error": format!(
3399 "sync_push limited to {} namespace_meta_clears per request",
3400 MAX_BULK_SIZE
3401 )
3402 })),
3403 )
3404 .into_response();
3405 }
3406 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
3409 let local_agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
3410 Ok(id) => id,
3411 Err(e) => {
3412 return (
3413 StatusCode::BAD_REQUEST,
3414 Json(json!({"error": format!("invalid x-agent-id: {e}")})),
3415 )
3416 .into_response();
3417 }
3418 };
3419
3420 let lock = state.lock().await;
3421 let mut applied = 0usize;
3422 let mut noop = 0usize;
3423 let mut skipped = 0usize;
3424 let mut deleted = 0usize;
3425 let mut archived = 0usize;
3426 let mut restored = 0usize;
3427 let mut latest_seen: Option<String> = None;
3428
3429 let mut embedding_refresh: Vec<(String, String)> = Vec::new();
3439 for mem in &body.memories {
3440 if let Err(e) = validate::validate_memory(mem) {
3441 tracing::warn!("sync_push: skipping memory {} ({}): {e}", mem.id, mem.title);
3442 skipped += 1;
3443 continue;
3444 }
3445 if latest_seen
3446 .as_deref()
3447 .is_none_or(|current| mem.updated_at.as_str() > current)
3448 {
3449 latest_seen = Some(mem.updated_at.clone());
3450 }
3451 if body.dry_run {
3452 noop += 1;
3453 continue;
3454 }
3455 match db::insert_if_newer(&lock.0, mem) {
3456 Ok(actual_id) => {
3457 applied += 1;
3458 embedding_refresh.push((actual_id, format!("{} {}", mem.title, mem.content)));
3459 }
3460 Err(e) => {
3461 tracing::warn!("sync_push: insert_if_newer failed for {}: {e}", mem.id);
3462 skipped += 1;
3463 }
3464 }
3465 }
3466
3467 for del_id in &body.deletions {
3471 if validate::validate_id(del_id).is_err() {
3472 skipped += 1;
3473 continue;
3474 }
3475 if body.dry_run {
3476 noop += 1;
3477 continue;
3478 }
3479 match db::delete(&lock.0, del_id) {
3480 Ok(true) => deleted += 1,
3481 Ok(false) => noop += 1,
3482 Err(e) => {
3483 tracing::warn!("sync_push: delete failed for {del_id}: {e}");
3484 skipped += 1;
3485 }
3486 }
3487 }
3488
3489 for arch_id in &body.archives {
3494 if validate::validate_id(arch_id).is_err() {
3495 skipped += 1;
3496 continue;
3497 }
3498 if body.dry_run {
3499 noop += 1;
3500 continue;
3501 }
3502 match db::archive_memory(&lock.0, arch_id, Some("sync_push")) {
3503 Ok(true) => archived += 1,
3504 Ok(false) => noop += 1,
3505 Err(e) => {
3506 tracing::warn!("sync_push: archive_memory failed for {arch_id}: {e}");
3507 skipped += 1;
3508 }
3509 }
3510 }
3511
3512 for res_id in &body.restores {
3518 if validate::validate_id(res_id).is_err() {
3519 skipped += 1;
3520 continue;
3521 }
3522 if body.dry_run {
3523 noop += 1;
3524 continue;
3525 }
3526 match db::restore_archived(&lock.0, res_id) {
3527 Ok(true) => restored += 1,
3528 Ok(false) => noop += 1,
3529 Err(e) => {
3530 tracing::warn!("sync_push: restore_archived failed for {res_id}: {e}");
3531 skipped += 1;
3532 }
3533 }
3534 }
3535
3536 let mut links_applied = 0usize;
3541 for link in &body.links {
3542 if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
3543 skipped += 1;
3544 continue;
3545 }
3546 if body.dry_run {
3547 noop += 1;
3548 continue;
3549 }
3550 match db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation) {
3551 Ok(()) => links_applied += 1,
3552 Err(e) => {
3553 tracing::warn!(
3554 "sync_push: create_link failed ({} -> {} / {}): {e}",
3555 link.source_id,
3556 link.target_id,
3557 link.relation
3558 );
3559 skipped += 1;
3560 }
3561 }
3562 }
3563
3564 let mut pendings_applied = 0usize;
3568 for pa in &body.pendings {
3569 if validate::validate_id(&pa.id).is_err() {
3570 skipped += 1;
3571 continue;
3572 }
3573 if body.dry_run {
3574 noop += 1;
3575 continue;
3576 }
3577 match db::upsert_pending_action(&lock.0, pa) {
3578 Ok(()) => pendings_applied += 1,
3579 Err(e) => {
3580 tracing::warn!("sync_push: upsert_pending_action failed for {}: {e}", pa.id);
3581 skipped += 1;
3582 }
3583 }
3584 }
3585
3586 let mut pending_decisions_applied = 0usize;
3591 for dec in &body.pending_decisions {
3592 if validate::validate_id(&dec.id).is_err() {
3593 skipped += 1;
3594 continue;
3595 }
3596 if body.dry_run {
3597 noop += 1;
3598 continue;
3599 }
3600 match db::decide_pending_action(&lock.0, &dec.id, dec.approved, &dec.decider) {
3601 Ok(true) => {
3602 pending_decisions_applied += 1;
3603 if dec.approved {
3607 match db::execute_pending_action(&lock.0, &dec.id) {
3608 Ok(_) => {}
3609 Err(e) => {
3610 tracing::warn!(
3611 "sync_push: execute_pending_action failed for {}: {e}",
3612 dec.id
3613 );
3614 }
3615 }
3616 }
3617 }
3618 Ok(false) => noop += 1, Err(e) => {
3620 tracing::warn!(
3621 "sync_push: decide_pending_action failed for {}: {e}",
3622 dec.id
3623 );
3624 skipped += 1;
3625 }
3626 }
3627 }
3628
3629 let mut namespace_meta_applied = 0usize;
3635 for entry in &body.namespace_meta {
3636 if validate::validate_namespace(&entry.namespace).is_err()
3637 || validate::validate_id(&entry.standard_id).is_err()
3638 {
3639 skipped += 1;
3640 continue;
3641 }
3642 if body.dry_run {
3643 noop += 1;
3644 continue;
3645 }
3646 match db::set_namespace_standard(
3647 &lock.0,
3648 &entry.namespace,
3649 &entry.standard_id,
3650 entry.parent_namespace.as_deref(),
3651 ) {
3652 Ok(()) => namespace_meta_applied += 1,
3653 Err(e) => {
3654 tracing::warn!(
3655 "sync_push: set_namespace_standard failed for {}: {e}",
3656 entry.namespace
3657 );
3658 skipped += 1;
3659 }
3660 }
3661 }
3662
3663 let mut namespace_meta_cleared = 0usize;
3668 for ns in &body.namespace_meta_clears {
3669 if validate::validate_namespace(ns).is_err() {
3670 skipped += 1;
3671 continue;
3672 }
3673 if body.dry_run {
3674 noop += 1;
3675 continue;
3676 }
3677 match db::clear_namespace_standard(&lock.0, ns) {
3678 Ok(true) => namespace_meta_cleared += 1,
3679 Ok(false) => noop += 1,
3680 Err(e) => {
3681 tracing::warn!("sync_push: clear_namespace_standard failed for {ns}: {e}");
3682 skipped += 1;
3683 }
3684 }
3685 }
3686
3687 if !body.dry_run
3690 && let Some(at) = latest_seen.as_deref()
3691 && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, &body.sender_agent_id, at)
3692 {
3693 tracing::warn!("sync_push: sync_state_observe failed: {e}");
3694 }
3695
3696 let mut hnsw_updates: Vec<(String, Vec<f32>)> = Vec::new();
3704 if !body.dry_run
3705 && !embedding_refresh.is_empty()
3706 && let Some(emb) = app.embedder.as_ref().as_ref()
3707 {
3708 for (id, text) in &embedding_refresh {
3709 match emb.embed(text) {
3710 Ok(vec) => {
3711 if let Err(e) = db::set_embedding(&lock.0, id, &vec) {
3712 tracing::warn!("sync_push: set_embedding failed for {id}: {e}");
3713 continue;
3714 }
3715 hnsw_updates.push((id.clone(), vec));
3716 }
3717 Err(e) => {
3718 tracing::warn!("sync_push: embed failed for {id}: {e}");
3719 }
3720 }
3721 }
3722 }
3723
3724 let receiver_clock = db::sync_state_load(&lock.0, &local_agent_id)
3728 .unwrap_or_else(|_| crate::models::VectorClock::default());
3729
3730 drop(lock);
3733 if !hnsw_updates.is_empty() {
3734 let mut idx_lock = app.vector_index.lock().await;
3735 if let Some(idx) = idx_lock.as_mut() {
3736 for (id, vec) in hnsw_updates {
3737 idx.remove(&id);
3738 idx.insert(id, vec);
3739 }
3740 }
3741 }
3742
3743 (
3744 StatusCode::OK,
3745 Json(json!({
3746 "applied": applied,
3747 "deleted": deleted,
3748 "archived": archived,
3749 "restored": restored,
3750 "links_applied": links_applied,
3751 "pendings_applied": pendings_applied,
3752 "pending_decisions_applied": pending_decisions_applied,
3753 "namespace_meta_applied": namespace_meta_applied,
3754 "namespace_meta_cleared": namespace_meta_cleared,
3755 "noop": noop,
3756 "skipped": skipped,
3757 "dry_run": body.dry_run,
3758 "receiver_agent_id": local_agent_id,
3759 "receiver_clock": receiver_clock,
3760 })),
3761 )
3762 .into_response()
3763}
3764
3765pub async fn sync_since(
3766 State(state): State<Db>,
3767 headers: HeaderMap,
3768 Query(q): Query<SyncSinceQuery>,
3769) -> impl IntoResponse {
3770 if let Some(ref s) = q.since
3774 && !s.is_empty()
3775 && chrono::DateTime::parse_from_rfc3339(s).is_err()
3776 {
3777 return (
3778 StatusCode::BAD_REQUEST,
3779 Json(json!({
3780 "error": "invalid `since` parameter — expected RFC 3339 timestamp"
3781 })),
3782 )
3783 .into_response();
3784 }
3785 let limit = q.limit.unwrap_or(500).min(10_000);
3786 let lock = state.lock().await;
3787 let mems = match db::memories_updated_since(&lock.0, q.since.as_deref(), limit) {
3788 Ok(v) => v,
3789 Err(e) => {
3790 tracing::error!("sync_since: {e}");
3791 return (
3792 StatusCode::INTERNAL_SERVER_ERROR,
3793 Json(json!({"error": "internal server error"})),
3794 )
3795 .into_response();
3796 }
3797 };
3798
3799 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
3803 if let (Some(peer), Ok(local_agent_id)) = (
3804 q.peer.as_deref(),
3805 crate::identity::resolve_http_agent_id(None, header_agent_id),
3806 ) && validate::validate_agent_id(peer).is_ok()
3807 && let Some(last) = mems.last()
3808 && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, peer, &last.updated_at)
3809 {
3810 tracing::debug!("sync_since: sync_state_observe failed: {e}");
3811 }
3812
3813 let earliest_updated_at = mems.first().map(|m| m.updated_at.clone());
3825 let latest_updated_at = mems.last().map(|m| m.updated_at.clone());
3826
3827 (
3828 StatusCode::OK,
3829 Json(json!({
3830 "count": mems.len(),
3831 "limit": limit,
3832 "updated_since": q.since,
3833 "earliest_updated_at": earliest_updated_at,
3834 "latest_updated_at": latest_updated_at,
3835 "memories": mems,
3836 })),
3837 )
3838 .into_response()
3839}
3840
3841async fn fanout_or_503(app: &AppState, mem: &Memory) -> Option<axum::response::Response> {
3850 let fed = app.federation.as_ref().as_ref()?;
3851 match crate::federation::broadcast_store_quorum(fed, mem).await {
3852 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
3853 Ok(_) => None,
3854 Err(err) => {
3855 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3856 Some(
3857 (
3858 StatusCode::SERVICE_UNAVAILABLE,
3859 [("Retry-After", "2")],
3860 Json(serde_json::to_value(&payload).unwrap_or_default()),
3861 )
3862 .into_response(),
3863 )
3864 }
3865 },
3866 Err(e) => {
3867 tracing::warn!("fanout error (local committed): {e:?}");
3868 None
3869 }
3870 }
3871}
3872
3873fn resolve_caller_agent_id(
3890 body: Option<&str>,
3891 headers: &HeaderMap,
3892 query: Option<&str>,
3893) -> Result<String, String> {
3894 if let Some(id) = body
3899 && !id.is_empty()
3900 {
3901 validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
3902 return Ok(id.to_string());
3903 }
3904 if let Some(id) = query
3905 && !id.is_empty()
3906 {
3907 validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
3908 return Ok(id.to_string());
3909 }
3910 let header_val = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
3911 crate::identity::resolve_http_agent_id(None, header_val)
3912 .map_err(|e| format!("invalid agent_id: {e}"))
3913}
3914
3915pub async fn get_capabilities(State(app): State<AppState>) -> impl IntoResponse {
3918 let embedder_loaded = app.embedder.as_ref().is_some();
3936 let lock = app.db.lock().await;
3937 let conn = &lock.0;
3938 let result = crate::mcp::handle_capabilities_with_conn(
3939 app.tier_config.as_ref(),
3940 None,
3941 embedder_loaded,
3942 Some(conn),
3943 );
3944 drop(lock);
3945 match result {
3946 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
3947 Err(e) => {
3948 tracing::error!("capabilities: {e}");
3949 (
3950 StatusCode::INTERNAL_SERVER_ERROR,
3951 Json(json!({"error": "internal server error"})),
3952 )
3953 .into_response()
3954 }
3955 }
3956}
3957
3958#[derive(Deserialize)]
3961pub struct NotifyBody {
3962 pub target_agent_id: String,
3963 pub title: String,
3964 #[serde(default)]
3966 pub payload: Option<String>,
3967 #[serde(default)]
3968 pub content: Option<String>,
3969 #[serde(default)]
3970 pub priority: Option<i64>,
3971 #[serde(default)]
3972 pub tier: Option<String>,
3973 #[serde(default)]
3975 pub agent_id: Option<String>,
3976}
3977
3978pub async fn notify(
3979 State(app): State<AppState>,
3980 headers: HeaderMap,
3981 Json(body): Json<NotifyBody>,
3982) -> impl IntoResponse {
3983 let Some(payload) = body.payload.or(body.content) else {
3984 return (
3985 StatusCode::BAD_REQUEST,
3986 Json(json!({"error": "payload or content is required"})),
3987 )
3988 .into_response();
3989 };
3990 let sender = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
3991 Ok(id) => id,
3992 Err(e) => {
3993 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
3994 }
3995 };
3996
3997 let mut params = json!({
3998 "target_agent_id": body.target_agent_id,
3999 "title": body.title,
4000 "payload": payload,
4001 });
4002 if let Some(p) = body.priority {
4003 params["priority"] = json!(p);
4004 }
4005 if let Some(t) = body.tier {
4006 params["tier"] = json!(t);
4007 }
4008
4009 let lock = app.db.lock().await;
4010 let resolved_ttl = lock.2.clone();
4011 let mcp_client = sender.clone();
4015 let result = crate::mcp::handle_notify(&lock.0, ¶ms, &resolved_ttl, Some(&mcp_client));
4016
4017 let fanout_mem = match &result {
4025 Ok(v) => v
4026 .get("id")
4027 .and_then(|x| x.as_str())
4028 .and_then(|id| db::get(&lock.0, id).ok().flatten()),
4029 Err(_) => None,
4030 };
4031 drop(lock);
4032
4033 match result {
4034 Ok(v) => {
4035 if let Some(mem) = fanout_mem
4036 && let Some(resp) = fanout_or_503(&app, &mem).await
4037 {
4038 return resp;
4039 }
4040 (StatusCode::CREATED, Json(v)).into_response()
4041 }
4042 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4043 }
4044}
4045
4046#[derive(Deserialize)]
4047pub struct InboxQuery {
4048 #[serde(default)]
4049 pub agent_id: Option<String>,
4050 #[serde(default)]
4051 pub unread_only: Option<bool>,
4052 #[serde(default)]
4053 pub limit: Option<u64>,
4054}
4055
4056pub async fn get_inbox(
4057 State(app): State<AppState>,
4058 headers: HeaderMap,
4059 Query(q): Query<InboxQuery>,
4060) -> impl IntoResponse {
4061 let owner = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4062 Ok(id) => id,
4063 Err(e) => {
4064 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4065 }
4066 };
4067
4068 let mut params = json!({"agent_id": owner});
4069 if let Some(u) = q.unread_only {
4070 params["unread_only"] = json!(u);
4071 }
4072 if let Some(l) = q.limit {
4073 params["limit"] = json!(l);
4074 }
4075 let lock = app.db.lock().await;
4076 let result = crate::mcp::handle_inbox(&lock.0, ¶ms, None);
4080 drop(lock);
4081 match result {
4082 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4083 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4084 }
4085}
4086
4087#[derive(Deserialize)]
4099pub struct SubscribeBody {
4100 #[serde(default)]
4103 pub url: Option<String>,
4104 #[serde(default)]
4105 pub events: Option<String>,
4106 #[serde(default)]
4107 pub secret: Option<String>,
4108 #[serde(default)]
4109 pub namespace_filter: Option<String>,
4110 #[serde(default)]
4111 pub agent_filter: Option<String>,
4112 #[serde(default)]
4114 pub namespace: Option<String>,
4115 #[serde(default)]
4117 pub agent_id: Option<String>,
4118}
4119
4120pub async fn subscribe(
4121 State(app): State<AppState>,
4122 headers: HeaderMap,
4123 Json(body): Json<SubscribeBody>,
4124) -> impl IntoResponse {
4125 let caller = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
4126 Ok(id) => id,
4127 Err(e) => {
4128 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4129 }
4130 };
4131
4132 let (url, namespace_filter, agent_filter) = if let Some(u) = body.url {
4134 (u, body.namespace_filter, body.agent_filter)
4135 } else {
4136 let Some(ns) = body.namespace.clone() else {
4137 return (
4138 StatusCode::BAD_REQUEST,
4139 Json(json!({"error": "url or namespace is required"})),
4140 )
4141 .into_response();
4142 };
4143 let synthetic = format!("http://localhost/_ns/{caller}/{ns}");
4147 (
4148 synthetic,
4149 Some(ns),
4150 body.agent_filter.or_else(|| Some(caller.clone())),
4151 )
4152 };
4153
4154 let events = body.events.unwrap_or_else(|| "*".to_string());
4155
4156 let lock = app.db.lock().await;
4161 let already = db::list_agents(&lock.0)
4162 .ok()
4163 .is_some_and(|a| a.iter().any(|x| x.agent_id == caller));
4164 if !already {
4165 let _ = db::register_agent(&lock.0, &caller, "ai:generic", &[]);
4166 }
4167 let sub_result: Result<serde_json::Value, String> = (|| {
4174 crate::subscriptions::validate_url(&url).map_err(|e| e.to_string())?;
4175 let id = crate::subscriptions::insert(
4176 &lock.0,
4177 &crate::subscriptions::NewSubscription {
4178 url: &url,
4179 events: &events,
4180 secret: body.secret.as_deref(),
4181 namespace_filter: namespace_filter.as_deref(),
4182 agent_filter: agent_filter.as_deref(),
4183 created_by: Some(&caller),
4184 },
4185 )
4186 .map_err(|e| e.to_string())?;
4187 Ok(json!({
4188 "id": id,
4189 "url": url,
4190 "events": events,
4191 "namespace_filter": namespace_filter,
4192 "agent_filter": agent_filter,
4193 "created_by": caller,
4194 }))
4195 })();
4196 let registered_mem = if already {
4200 None
4201 } else {
4202 db::list(
4203 &lock.0,
4204 Some("_agents"),
4205 None,
4206 1000,
4207 0,
4208 None,
4209 None,
4210 None,
4211 None,
4212 None,
4213 )
4214 .ok()
4215 .and_then(|rows| {
4216 rows.into_iter()
4217 .find(|m| m.title == format!("agent:{caller}"))
4218 })
4219 };
4220 drop(lock);
4221
4222 if let Some(ref mem) = registered_mem
4223 && let Some(resp) = fanout_or_503(&app, mem).await
4224 {
4225 return resp;
4226 }
4227
4228 match sub_result {
4229 Ok(mut v) => {
4230 if let Some(obj) = v.as_object_mut() {
4234 if let Some(ref ns) = namespace_filter {
4235 obj.insert("namespace".into(), json!(ns));
4236 }
4237 obj.insert("agent_id".into(), json!(caller));
4238 }
4239 (StatusCode::CREATED, Json(v)).into_response()
4240 }
4241 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4242 }
4243}
4244
4245#[derive(Deserialize)]
4246pub struct UnsubscribeQuery {
4247 #[serde(default)]
4248 pub id: Option<String>,
4249 #[serde(default)]
4251 pub agent_id: Option<String>,
4252 #[serde(default)]
4253 pub namespace: Option<String>,
4254}
4255
4256pub async fn unsubscribe(
4257 State(app): State<AppState>,
4258 headers: HeaderMap,
4259 Query(q): Query<UnsubscribeQuery>,
4260) -> impl IntoResponse {
4261 if let Some(id) = q.id.clone() {
4264 let mut params = json!({"id": id});
4265 let _ = params.as_object_mut();
4267 let lock = app.db.lock().await;
4268 let result = crate::mcp::handle_unsubscribe(&lock.0, ¶ms);
4269 drop(lock);
4270 return match result {
4271 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4272 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4273 };
4274 }
4275
4276 let caller = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4277 Ok(id) => id,
4278 Err(e) => {
4279 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4280 }
4281 };
4282 let Some(ns) = q.namespace else {
4283 return (
4284 StatusCode::BAD_REQUEST,
4285 Json(json!({"error": "id or (agent_id, namespace) required"})),
4286 )
4287 .into_response();
4288 };
4289
4290 let lock = app.db.lock().await;
4291 let subs = crate::subscriptions::list(&lock.0).unwrap_or_default();
4292 let target = subs.into_iter().find(|s| {
4293 s.namespace_filter.as_deref() == Some(ns.as_str())
4294 && (s.agent_filter.as_deref() == Some(caller.as_str())
4295 || s.created_by.as_deref() == Some(caller.as_str()))
4296 });
4297 let outcome = match target {
4298 Some(s) => crate::subscriptions::delete(&lock.0, &s.id).map(|r| (s.id, r)),
4299 None => Ok((String::new(), false)),
4300 };
4301 drop(lock);
4302 match outcome {
4303 Ok((id, removed)) => {
4304 (StatusCode::OK, Json(json!({"id": id, "removed": removed}))).into_response()
4305 }
4306 Err(e) => {
4307 tracing::error!("unsubscribe: {e}");
4308 (
4309 StatusCode::INTERNAL_SERVER_ERROR,
4310 Json(json!({"error": "internal server error"})),
4311 )
4312 .into_response()
4313 }
4314 }
4315}
4316
4317#[derive(Deserialize)]
4318pub struct ListSubscriptionsQuery {
4319 #[serde(default)]
4320 pub agent_id: Option<String>,
4321}
4322
4323pub async fn list_subscriptions(
4324 State(state): State<Db>,
4325 Query(q): Query<ListSubscriptionsQuery>,
4326) -> impl IntoResponse {
4327 let lock = state.lock().await;
4328 let subs = match crate::subscriptions::list(&lock.0) {
4329 Ok(s) => s,
4330 Err(e) => {
4331 tracing::error!("list_subscriptions: {e}");
4332 return (
4333 StatusCode::INTERNAL_SERVER_ERROR,
4334 Json(json!({"error": "internal server error"})),
4335 )
4336 .into_response();
4337 }
4338 };
4339 drop(lock);
4340 let filtered: Vec<_> = match q.agent_id.as_deref() {
4342 Some(aid) => subs
4343 .into_iter()
4344 .filter(|s| {
4345 s.agent_filter.as_deref() == Some(aid) || s.created_by.as_deref() == Some(aid)
4346 })
4347 .collect(),
4348 None => subs,
4349 };
4350 let rows: Vec<serde_json::Value> = filtered
4353 .iter()
4354 .map(|s| {
4355 json!({
4356 "id": s.id,
4357 "url": s.url,
4358 "events": s.events,
4359 "namespace": s.namespace_filter,
4360 "namespace_filter": s.namespace_filter,
4361 "agent_filter": s.agent_filter,
4362 "agent_id": s.agent_filter.clone().or(s.created_by.clone()),
4363 "created_by": s.created_by,
4364 "created_at": s.created_at,
4365 "dispatch_count": s.dispatch_count,
4366 "failure_count": s.failure_count,
4367 })
4368 })
4369 .collect();
4370 let count = rows.len();
4371 (
4372 StatusCode::OK,
4373 Json(json!({"count": count, "subscriptions": rows})),
4374 )
4375 .into_response()
4376}
4377
4378#[derive(Deserialize)]
4386pub struct NamespaceStandardBody {
4387 #[serde(default)]
4389 pub id: Option<String>,
4390 #[serde(default)]
4392 pub parent: Option<String>,
4393 #[serde(default)]
4395 pub governance: Option<serde_json::Value>,
4396 #[serde(default)]
4399 pub namespace: Option<String>,
4400 #[serde(default)]
4402 pub standard: Option<Box<NamespaceStandardBody>>,
4403}
4404
4405fn flatten_standard_body(body: NamespaceStandardBody) -> NamespaceStandardBody {
4406 if let Some(inner) = body.standard {
4410 let mut merged = *inner;
4411 if merged.namespace.is_none() {
4412 merged.namespace = body.namespace;
4413 }
4414 if merged.id.is_none() {
4415 merged.id = body.id;
4416 }
4417 if merged.parent.is_none() {
4418 merged.parent = body.parent;
4419 }
4420 if merged.governance.is_none() {
4421 merged.governance = body.governance;
4422 }
4423 merged
4424 } else {
4425 body
4426 }
4427}
4428
4429fn namespace_standard_params(ns: &str, body: &NamespaceStandardBody) -> serde_json::Value {
4430 let mut params = json!({"namespace": ns});
4431 if let Some(ref id) = body.id {
4432 params["id"] = json!(id);
4433 }
4434 if let Some(ref p) = body.parent {
4435 params["parent"] = json!(p);
4436 }
4437 if let Some(ref g) = body.governance {
4438 params["governance"] = g.clone();
4439 }
4440 params
4441}
4442
4443async fn set_namespace_standard_inner(
4444 app: &AppState,
4445 ns: &str,
4446 body: NamespaceStandardBody,
4447) -> axum::response::Response {
4448 let body = flatten_standard_body(body);
4449 let lock = app.db.lock().await;
4453 let resolved_id = if let Some(id) = body.id.clone() {
4454 id
4455 } else {
4456 let existing = db::list(
4459 &lock.0,
4460 Some(ns),
4461 None,
4462 1,
4463 0,
4464 None,
4465 None,
4466 None,
4467 Some("_namespace_standard"),
4468 None,
4469 )
4470 .ok()
4471 .and_then(|v| v.into_iter().next());
4472 if let Some(m) = existing {
4473 m.id
4474 } else {
4475 let now = Utc::now().to_rfc3339();
4476 let placeholder = Memory {
4477 id: Uuid::new_v4().to_string(),
4478 tier: Tier::Long,
4479 namespace: ns.to_string(),
4480 title: format!("_standard:{ns}"),
4481 content: format!("namespace standard for {ns}"),
4482 tags: vec!["_namespace_standard".to_string()],
4483 priority: 5,
4484 confidence: 1.0,
4485 source: "api".into(),
4486 access_count: 0,
4487 created_at: now.clone(),
4488 updated_at: now,
4489 last_accessed_at: None,
4490 expires_at: None,
4491 metadata: serde_json::json!({"agent_id": "system"}),
4492 };
4493 match db::insert(&lock.0, &placeholder) {
4494 Ok(id) => id,
4495 Err(e) => {
4496 tracing::error!("namespace_standard: placeholder insert failed: {e}");
4497 return (
4498 StatusCode::INTERNAL_SERVER_ERROR,
4499 Json(json!({"error": "internal server error"})),
4500 )
4501 .into_response();
4502 }
4503 }
4504 }
4505 };
4506 let mut effective = body;
4507 effective.id = Some(resolved_id.clone());
4508 let params = namespace_standard_params(ns, &effective);
4509 let result = crate::mcp::handle_namespace_set_standard(&lock.0, ¶ms);
4510 let standard_mem = db::get(&lock.0, &resolved_id).ok().flatten();
4513 let meta_entry = db::get_namespace_meta_entry(&lock.0, ns).ok().flatten();
4518 drop(lock);
4519
4520 match result {
4521 Ok(v) => {
4522 if let Some(ref mem) = standard_mem
4523 && let Some(resp) = fanout_or_503(app, mem).await
4524 {
4525 return resp;
4526 }
4527 if let (Some(entry), Some(fed)) = (meta_entry.as_ref(), app.federation.as_ref()) {
4528 match crate::federation::broadcast_namespace_meta_quorum(fed, entry).await {
4529 Ok(tracker) => {
4530 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4531 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4532 return (
4533 StatusCode::SERVICE_UNAVAILABLE,
4534 [("Retry-After", "2")],
4535 Json(serde_json::to_value(&payload).unwrap_or_default()),
4536 )
4537 .into_response();
4538 }
4539 }
4540 Err(err) => {
4541 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4542 return (
4543 StatusCode::SERVICE_UNAVAILABLE,
4544 [("Retry-After", "2")],
4545 Json(serde_json::to_value(&payload).unwrap_or_default()),
4546 )
4547 .into_response();
4548 }
4549 }
4550 }
4551 (StatusCode::CREATED, Json(v)).into_response()
4552 }
4553 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4554 }
4555}
4556
4557pub async fn set_namespace_standard(
4558 State(app): State<AppState>,
4559 Path(ns): Path<String>,
4560 Json(body): Json<NamespaceStandardBody>,
4561) -> impl IntoResponse {
4562 set_namespace_standard_inner(&app, &ns, body).await
4563}
4564
4565#[derive(Deserialize)]
4566pub struct NamespaceStandardQuery {
4567 #[serde(default)]
4568 pub namespace: Option<String>,
4569 #[serde(default)]
4570 pub inherit: Option<bool>,
4571}
4572
4573pub async fn get_namespace_standard(
4574 State(state): State<Db>,
4575 Path(ns): Path<String>,
4576 Query(q): Query<NamespaceStandardQuery>,
4577) -> impl IntoResponse {
4578 let mut params = json!({"namespace": ns});
4579 if let Some(inh) = q.inherit {
4580 params["inherit"] = json!(inh);
4581 }
4582 let lock = state.lock().await;
4583 let result = crate::mcp::handle_namespace_get_standard(&lock.0, ¶ms);
4584 drop(lock);
4585 match result {
4586 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4587 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4588 }
4589}
4590
4591pub async fn clear_namespace_standard(
4592 State(app): State<AppState>,
4593 Path(ns): Path<String>,
4594) -> impl IntoResponse {
4595 clear_namespace_standard_inner(&app, &ns).await
4596}
4597
4598pub async fn set_namespace_standard_qs(
4600 State(app): State<AppState>,
4601 Json(body): Json<NamespaceStandardBody>,
4602) -> impl IntoResponse {
4603 let Some(ns) = body
4604 .namespace
4605 .clone()
4606 .or_else(|| body.standard.as_ref().and_then(|s| s.namespace.clone()))
4607 else {
4608 return (
4609 StatusCode::BAD_REQUEST,
4610 Json(json!({"error": "namespace is required"})),
4611 )
4612 .into_response();
4613 };
4614 set_namespace_standard_inner(&app, &ns, body).await
4615}
4616
4617pub async fn get_namespace_standard_qs(
4618 State(state): State<Db>,
4619 Query(q): Query<NamespaceStandardQuery>,
4620) -> impl IntoResponse {
4621 let Some(ns) = q.namespace.clone() else {
4625 return list_namespaces(State(state)).await.into_response();
4626 };
4627 let mut params = json!({"namespace": ns});
4628 if let Some(inh) = q.inherit {
4629 params["inherit"] = json!(inh);
4630 }
4631 let lock = state.lock().await;
4632 let result = crate::mcp::handle_namespace_get_standard(&lock.0, ¶ms);
4633 drop(lock);
4634 match result {
4635 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4636 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4637 }
4638}
4639
4640pub async fn clear_namespace_standard_qs(
4641 State(app): State<AppState>,
4642 Query(q): Query<NamespaceStandardQuery>,
4643) -> impl IntoResponse {
4644 let Some(ns) = q.namespace else {
4645 return (
4646 StatusCode::BAD_REQUEST,
4647 Json(json!({"error": "namespace is required"})),
4648 )
4649 .into_response();
4650 };
4651 clear_namespace_standard_inner(&app, &ns).await
4652}
4653
4654async fn clear_namespace_standard_inner(app: &AppState, ns: &str) -> axum::response::Response {
4661 let params = json!({"namespace": ns});
4662 let lock = app.db.lock().await;
4663 let result = crate::mcp::handle_namespace_clear_standard(&lock.0, ¶ms);
4664 drop(lock);
4665 match result {
4666 Ok(v) => {
4667 if let Some(fed) = app.federation.as_ref() {
4668 let namespaces = vec![ns.to_string()];
4669 match crate::federation::broadcast_namespace_meta_clear_quorum(fed, &namespaces)
4670 .await
4671 {
4672 Ok(tracker) => {
4673 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4674 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4675 return (
4676 StatusCode::SERVICE_UNAVAILABLE,
4677 [("Retry-After", "2")],
4678 Json(serde_json::to_value(&payload).unwrap_or_default()),
4679 )
4680 .into_response();
4681 }
4682 }
4683 Err(err) => {
4684 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4685 return (
4686 StatusCode::SERVICE_UNAVAILABLE,
4687 [("Retry-After", "2")],
4688 Json(serde_json::to_value(&payload).unwrap_or_default()),
4689 )
4690 .into_response();
4691 }
4692 }
4693 }
4694 (StatusCode::OK, Json(v)).into_response()
4695 }
4696 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4697 }
4698}
4699
4700#[derive(Deserialize)]
4703pub struct SessionStartBody {
4704 #[serde(default)]
4705 pub namespace: Option<String>,
4706 #[serde(default)]
4707 pub limit: Option<u64>,
4708 #[serde(default)]
4709 pub agent_id: Option<String>,
4710}
4711
4712pub async fn session_start(
4713 State(state): State<Db>,
4714 headers: HeaderMap,
4715 Json(body): Json<SessionStartBody>,
4716) -> impl IntoResponse {
4717 if let Some(ref id) = body.agent_id
4719 && let Err(e) = validate::validate_agent_id(id)
4720 {
4721 return (
4722 StatusCode::BAD_REQUEST,
4723 Json(json!({"error": format!("invalid agent_id: {e}")})),
4724 )
4725 .into_response();
4726 }
4727 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4728 let _ = header_agent_id; let mut params = json!({});
4730 if let Some(ref n) = body.namespace {
4731 params["namespace"] = json!(n);
4732 }
4733 if let Some(l) = body.limit {
4734 params["limit"] = json!(l);
4735 }
4736 let lock = state.lock().await;
4737 let result = crate::mcp::handle_session_start(&lock.0, ¶ms, None);
4738 drop(lock);
4739 match result {
4740 Ok(mut v) => {
4741 if let Some(obj) = v.as_object_mut() {
4745 obj.entry("session_id")
4746 .or_insert_with(|| json!(Uuid::new_v4().to_string()));
4747 if let Some(ref a) = body.agent_id {
4748 obj.insert("agent_id".into(), json!(a));
4749 }
4750 }
4751 (StatusCode::OK, Json(v)).into_response()
4752 }
4753 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4754 }
4755}
4756
4757#[cfg(test)]
4758mod tests {
4759 use super::*;
4760
4761 fn test_state() -> Db {
4762 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4763 let path = std::path::PathBuf::from(":memory:");
4764 Arc::new(Mutex::new((conn, path, ResolvedTtl::default(), true)))
4765 }
4766
4767 #[tokio::test]
4768 async fn health_returns_ok() {
4769 let state = test_state();
4770 let lock = state.lock().await;
4771 let ok = db::health_check(&lock.0).unwrap_or(false);
4772 assert!(ok);
4773 }
4774
4775 #[tokio::test]
4776 async fn store_and_retrieve_via_state() {
4777 let state = test_state();
4778 let lock = state.lock().await;
4779 let now = Utc::now();
4780 let mem = Memory {
4781 id: Uuid::new_v4().to_string(),
4782 tier: Tier::Long,
4783 namespace: "test".into(),
4784 title: "Handler test".into(),
4785 content: "Testing handlers.".into(),
4786 tags: vec!["test".into()],
4787 priority: 7,
4788 confidence: 1.0,
4789 source: "test".into(),
4790 access_count: 0,
4791 created_at: now.to_rfc3339(),
4792 updated_at: now.to_rfc3339(),
4793 last_accessed_at: None,
4794 expires_at: None,
4795 metadata: serde_json::json!({}),
4796 };
4797 let id = db::insert(&lock.0, &mem).unwrap();
4798 let got = db::get(&lock.0, &id).unwrap().unwrap();
4799 assert_eq!(got.title, "Handler test");
4800 }
4801
4802 #[tokio::test]
4803 async fn recall_via_state() {
4804 let state = test_state();
4805 let lock = state.lock().await;
4806 let now = Utc::now();
4807 let mem = Memory {
4808 id: Uuid::new_v4().to_string(),
4809 tier: Tier::Long,
4810 namespace: "test".into(),
4811 title: "Recall handler test".into(),
4812 content: "Content for recall.".into(),
4813 tags: vec![],
4814 priority: 8,
4815 confidence: 1.0,
4816 source: "test".into(),
4817 access_count: 0,
4818 created_at: now.to_rfc3339(),
4819 updated_at: now.to_rfc3339(),
4820 last_accessed_at: None,
4821 expires_at: None,
4822 metadata: serde_json::json!({}),
4823 };
4824 db::insert(&lock.0, &mem).unwrap();
4825 let (results, _tokens) = db::recall(
4826 &lock.0,
4827 "recall handler",
4828 Some("test"),
4829 10,
4830 None,
4831 None,
4832 None,
4833 crate::models::SHORT_TTL_EXTEND_SECS,
4834 crate::models::MID_TTL_EXTEND_SECS,
4835 None,
4836 None,
4837 )
4838 .unwrap();
4839 assert!(!results.is_empty());
4840 assert!(results[0].1 > 0.0); }
4842
4843 #[tokio::test]
4844 async fn stats_via_state() {
4845 let state = test_state();
4846 let lock = state.lock().await;
4847 let path = std::path::Path::new(":memory:");
4848 let s = db::stats(&lock.0, path).unwrap();
4849 assert_eq!(s.total, 0);
4850 }
4851
4852 #[tokio::test]
4853 async fn bulk_size_limit() {
4854 assert_eq!(MAX_BULK_SIZE, 1000);
4855 }
4856
4857 #[tokio::test]
4858 async fn list_empty_namespace() {
4859 let state = test_state();
4860 let lock = state.lock().await;
4861 let results = db::list(
4862 &lock.0,
4863 Some("nonexistent"),
4864 None,
4865 10,
4866 0,
4867 None,
4868 None,
4869 None,
4870 None,
4871 None,
4872 )
4873 .unwrap();
4874 assert!(results.is_empty());
4875 }
4876
4877 #[tokio::test]
4878 async fn create_and_update_with_metadata() {
4879 let state = test_state();
4880 let lock = state.lock().await;
4881 let now = Utc::now();
4882
4883 let mem = Memory {
4885 id: Uuid::new_v4().to_string(),
4886 tier: Tier::Long,
4887 namespace: "test".into(),
4888 title: "HTTP metadata test".into(),
4889 content: "Testing metadata through handler layer.".into(),
4890 tags: vec![],
4891 priority: 5,
4892 confidence: 1.0,
4893 source: "api".into(),
4894 access_count: 0,
4895 created_at: now.to_rfc3339(),
4896 updated_at: now.to_rfc3339(),
4897 last_accessed_at: None,
4898 expires_at: None,
4899 metadata: serde_json::json!({"http_test": true, "version": 1}),
4900 };
4901 let id = db::insert(&lock.0, &mem).unwrap();
4902
4903 let got = db::get(&lock.0, &id).unwrap().unwrap();
4905 assert_eq!(got.metadata["http_test"], true);
4906 assert_eq!(got.metadata["version"], 1);
4907
4908 let new_meta =
4910 serde_json::json!({"http_test": true, "version": 2, "updated_by": "handler"});
4911 let (found, _) = db::update(
4912 &lock.0,
4913 &id,
4914 None,
4915 None,
4916 None,
4917 None,
4918 None,
4919 None,
4920 None,
4921 None,
4922 Some(&new_meta),
4923 )
4924 .unwrap();
4925 assert!(found);
4926
4927 let got = db::get(&lock.0, &id).unwrap().unwrap();
4929 assert_eq!(got.metadata["version"], 2);
4930 assert_eq!(got.metadata["updated_by"], "handler");
4931 }
4932
4933 use axum::{Router, body::Body, routing::get as axum_get, routing::post as axum_post};
4936 use tower::ServiceExt as _;
4937
4938 fn test_app_state(db: Db) -> AppState {
4939 AppState {
4940 db,
4941 embedder: Arc::new(None),
4942 vector_index: Arc::new(Mutex::new(None)),
4943 federation: Arc::new(None),
4944 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
4945 scoring: Arc::new(crate::config::ResolvedScoring::default()),
4946 }
4947 }
4948
4949 #[tokio::test]
4950 async fn http_create_memory_uses_appstate_and_persists() {
4951 let state = test_state();
4955 let app = Router::new()
4956 .route("/api/v1/memories", axum_post(create_memory))
4957 .with_state(test_app_state(state.clone()));
4958
4959 let body = serde_json::json!({
4960 "tier": "long",
4961 "namespace": "http-embed-test",
4962 "title": "Semantic-ready via HTTP",
4963 "content": "HTTP-authored memories must now participate in semantic recall.",
4964 "tags": ["issue-219"],
4965 "priority": 7,
4966 "confidence": 1.0,
4967 "source": "api",
4968 "metadata": {}
4969 });
4970 let resp = app
4971 .oneshot(
4972 axum::http::Request::builder()
4973 .uri("/api/v1/memories")
4974 .method("POST")
4975 .header("content-type", "application/json")
4976 .header("x-agent-id", "alice")
4977 .body(Body::from(serde_json::to_vec(&body).unwrap()))
4978 .unwrap(),
4979 )
4980 .await
4981 .unwrap();
4982 assert_eq!(resp.status(), StatusCode::CREATED);
4983
4984 let lock = state.lock().await;
4986 let rows = db::list(
4987 &lock.0,
4988 Some("http-embed-test"),
4989 None,
4990 10,
4991 0,
4992 None,
4993 None,
4994 None,
4995 None,
4996 None,
4997 )
4998 .unwrap();
4999 assert!(!rows.is_empty(), "HTTP-authored memory must be persisted");
5000 assert_eq!(rows[0].title, "Semantic-ready via HTTP");
5001 }
5002
5003 #[tokio::test]
5004 async fn http_update_memory_uses_appstate() {
5005 let state = test_state();
5008 let now = Utc::now();
5009 let id = {
5010 let lock = state.lock().await;
5011 let mem = Memory {
5012 id: Uuid::new_v4().to_string(),
5013 tier: Tier::Long,
5014 namespace: "http-embed-test".into(),
5015 title: "Before update".into(),
5016 content: "Original content.".into(),
5017 tags: vec![],
5018 priority: 5,
5019 confidence: 1.0,
5020 source: "test".into(),
5021 access_count: 0,
5022 created_at: now.to_rfc3339(),
5023 updated_at: now.to_rfc3339(),
5024 last_accessed_at: None,
5025 expires_at: None,
5026 metadata: serde_json::json!({}),
5027 };
5028 db::insert(&lock.0, &mem).unwrap()
5029 };
5030
5031 let app = Router::new()
5032 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
5033 .with_state(test_app_state(state.clone()));
5034
5035 let patch = serde_json::json!({"content": "Updated content for semantic refresh."});
5036 let resp = app
5037 .oneshot(
5038 axum::http::Request::builder()
5039 .uri(format!("/api/v1/memories/{id}"))
5040 .method("PUT")
5041 .header("content-type", "application/json")
5042 .body(Body::from(serde_json::to_vec(&patch).unwrap()))
5043 .unwrap(),
5044 )
5045 .await
5046 .unwrap();
5047 assert_eq!(resp.status(), StatusCode::OK);
5048 }
5049
5050 #[tokio::test]
5053 async fn http_sync_push_applies_and_advances_clock() {
5054 let state = test_state();
5058 let app = Router::new()
5059 .route("/api/v1/sync/push", axum_post(sync_push))
5060 .with_state(test_app_state(state.clone()));
5061
5062 let now = Utc::now().to_rfc3339();
5063 let body = serde_json::json!({
5064 "sender_agent_id": "peer-alice",
5065 "sender_clock": {"entries": {}},
5066 "memories": [{
5067 "id": Uuid::new_v4().to_string(),
5068 "tier": "long",
5069 "namespace": "sync-smoke",
5070 "title": "From peer",
5071 "content": "Pushed via HTTP sync endpoint.",
5072 "tags": [],
5073 "priority": 5,
5074 "confidence": 1.0,
5075 "source": "api",
5076 "access_count": 0,
5077 "created_at": now,
5078 "updated_at": now,
5079 "last_accessed_at": null,
5080 "expires_at": null,
5081 "metadata": {"agent_id": "peer-alice"}
5082 }],
5083 "dry_run": false
5084 });
5085 let resp = app
5086 .oneshot(
5087 axum::http::Request::builder()
5088 .uri("/api/v1/sync/push")
5089 .method("POST")
5090 .header("content-type", "application/json")
5091 .header("x-agent-id", "local-receiver")
5092 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5093 .unwrap(),
5094 )
5095 .await
5096 .unwrap();
5097 assert_eq!(resp.status(), StatusCode::OK);
5098
5099 let lock = state.lock().await;
5101 let rows = db::list(
5102 &lock.0,
5103 Some("sync-smoke"),
5104 None,
5105 10,
5106 0,
5107 None,
5108 None,
5109 None,
5110 None,
5111 None,
5112 )
5113 .unwrap();
5114 assert_eq!(rows.len(), 1);
5115 let clock = db::sync_state_load(&lock.0, "local-receiver").unwrap();
5117 assert!(
5118 clock.latest_from("peer-alice").is_some(),
5119 "push must record sender in sync_state; got: {:?}",
5120 clock.entries
5121 );
5122 }
5123
5124 #[tokio::test]
5125 async fn http_sync_push_applies_archives() {
5126 let state = test_state();
5131 let id = {
5134 let lock = state.lock().await;
5135 let now = Utc::now().to_rfc3339();
5136 let mem = Memory {
5137 id: Uuid::new_v4().to_string(),
5138 tier: Tier::Long,
5139 namespace: "s29".into(),
5140 title: "Archive M1".into(),
5141 content: "body".into(),
5142 tags: vec![],
5143 priority: 5,
5144 confidence: 1.0,
5145 source: "api".into(),
5146 access_count: 0,
5147 created_at: now.clone(),
5148 updated_at: now,
5149 last_accessed_at: None,
5150 expires_at: None,
5151 metadata: serde_json::json!({}),
5152 };
5153 db::insert(&lock.0, &mem).unwrap()
5154 };
5155
5156 let app = Router::new()
5157 .route("/api/v1/sync/push", axum_post(sync_push))
5158 .with_state(test_app_state(state.clone()));
5159
5160 let body = serde_json::json!({
5161 "sender_agent_id": "peer-a",
5162 "sender_clock": {"entries": {}},
5163 "memories": [],
5164 "archives": [id, "missing-on-peer"],
5165 "dry_run": false
5166 });
5167 let resp = app
5168 .oneshot(
5169 axum::http::Request::builder()
5170 .uri("/api/v1/sync/push")
5171 .method("POST")
5172 .header("content-type", "application/json")
5173 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5174 .unwrap(),
5175 )
5176 .await
5177 .unwrap();
5178 assert_eq!(resp.status(), StatusCode::OK);
5179 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5180 .await
5181 .unwrap();
5182 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5183 assert_eq!(v["archived"], 1, "live row must be archived");
5184 assert_eq!(v["noop"], 1, "missing id must no-op");
5185
5186 let lock = state.lock().await;
5189 assert!(db::get(&lock.0, &id).unwrap().is_none());
5190 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5191 assert_eq!(archived.len(), 1);
5192 assert_eq!(archived[0]["id"], id);
5193 assert_eq!(archived[0]["archive_reason"], "sync_push");
5194 }
5195
5196 #[tokio::test]
5197 async fn http_archive_by_ids_happy_path() {
5198 let state = test_state();
5202 let live_id = {
5203 let lock = state.lock().await;
5204 let now = Utc::now().to_rfc3339();
5205 let mem = Memory {
5206 id: Uuid::new_v4().to_string(),
5207 tier: Tier::Long,
5208 namespace: "s29".into(),
5209 title: "Live for archive".into(),
5210 content: "will be archived".into(),
5211 tags: vec![],
5212 priority: 5,
5213 confidence: 1.0,
5214 source: "api".into(),
5215 access_count: 0,
5216 created_at: now.clone(),
5217 updated_at: now,
5218 last_accessed_at: None,
5219 expires_at: None,
5220 metadata: serde_json::json!({}),
5221 };
5222 db::insert(&lock.0, &mem).unwrap()
5223 };
5224
5225 let app = Router::new()
5226 .route("/api/v1/archive", axum_post(archive_by_ids))
5227 .with_state(test_app_state(state.clone()));
5228
5229 let body = serde_json::json!({
5230 "ids": [live_id, "does-not-exist"],
5231 "reason": "scenario_s29"
5232 });
5233 let resp = app
5234 .oneshot(
5235 axum::http::Request::builder()
5236 .uri("/api/v1/archive")
5237 .method("POST")
5238 .header("content-type", "application/json")
5239 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5240 .unwrap(),
5241 )
5242 .await
5243 .unwrap();
5244 assert_eq!(resp.status(), StatusCode::OK);
5245 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5246 .await
5247 .unwrap();
5248 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5249 assert_eq!(v["count"], 1);
5250 assert_eq!(v["archived"].as_array().unwrap().len(), 1);
5251 assert_eq!(v["missing"].as_array().unwrap().len(), 1);
5252 assert_eq!(v["reason"], "scenario_s29");
5253
5254 let lock = state.lock().await;
5256 assert!(db::get(&lock.0, &live_id).unwrap().is_none());
5257 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5258 assert_eq!(archived.len(), 1);
5259 assert_eq!(archived[0]["id"], live_id);
5260 assert_eq!(archived[0]["archive_reason"], "scenario_s29");
5261 }
5262
5263 #[tokio::test]
5264 async fn http_archive_by_ids_default_reason() {
5265 let state = test_state();
5268 let live_id = {
5269 let lock = state.lock().await;
5270 let now = Utc::now().to_rfc3339();
5271 let mem = Memory {
5272 id: Uuid::new_v4().to_string(),
5273 tier: Tier::Long,
5274 namespace: "s29-default".into(),
5275 title: "Default reason".into(),
5276 content: "c".into(),
5277 tags: vec![],
5278 priority: 5,
5279 confidence: 1.0,
5280 source: "api".into(),
5281 access_count: 0,
5282 created_at: now.clone(),
5283 updated_at: now,
5284 last_accessed_at: None,
5285 expires_at: None,
5286 metadata: serde_json::json!({}),
5287 };
5288 db::insert(&lock.0, &mem).unwrap()
5289 };
5290
5291 let app = Router::new()
5292 .route("/api/v1/archive", axum_post(archive_by_ids))
5293 .with_state(test_app_state(state.clone()));
5294 let body = serde_json::json!({"ids": [live_id]});
5295 let resp = app
5296 .oneshot(
5297 axum::http::Request::builder()
5298 .uri("/api/v1/archive")
5299 .method("POST")
5300 .header("content-type", "application/json")
5301 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5302 .unwrap(),
5303 )
5304 .await
5305 .unwrap();
5306 assert_eq!(resp.status(), StatusCode::OK);
5307 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5308 .await
5309 .unwrap();
5310 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5311 assert_eq!(v["reason"], "archive");
5312 let lock = state.lock().await;
5313 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5314 assert_eq!(archived[0]["archive_reason"], "archive");
5315 }
5316
5317 #[tokio::test]
5318 async fn http_bulk_create_uses_appstate_and_persists() {
5319 let state = test_state();
5323 let app = Router::new()
5324 .route("/api/v1/memories/bulk", axum_post(bulk_create))
5325 .with_state(test_app_state(state.clone()));
5326
5327 let bodies: Vec<serde_json::Value> = (0..5)
5328 .map(|i| {
5329 serde_json::json!({
5330 "tier": "long",
5331 "namespace": "bulk-appstate",
5332 "title": format!("bulk-{i}"),
5333 "content": format!("body-{i}"),
5334 "tags": [],
5335 "priority": 5,
5336 "confidence": 1.0,
5337 "source": "api",
5338 "metadata": {}
5339 })
5340 })
5341 .collect();
5342 let resp = app
5343 .oneshot(
5344 axum::http::Request::builder()
5345 .uri("/api/v1/memories/bulk")
5346 .method("POST")
5347 .header("content-type", "application/json")
5348 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5349 .unwrap(),
5350 )
5351 .await
5352 .unwrap();
5353 assert_eq!(resp.status(), StatusCode::OK);
5354 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5355 .await
5356 .unwrap();
5357 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5358 assert_eq!(v["created"], 5);
5359 assert!(v["errors"].as_array().unwrap().is_empty());
5360
5361 let lock = state.lock().await;
5364 let rows = db::list(
5365 &lock.0,
5366 Some("bulk-appstate"),
5367 None,
5368 100,
5369 0,
5370 None,
5371 None,
5372 None,
5373 None,
5374 None,
5375 )
5376 .unwrap();
5377 assert_eq!(rows.len(), 5, "bulk rows must persist via AppState");
5378 }
5379
5380 #[tokio::test]
5381 async fn http_bulk_create_fans_out_with_federation() {
5382 use std::sync::atomic::{AtomicUsize, Ordering};
5387 use tokio::net::TcpListener;
5388
5389 let state = test_state();
5390
5391 let count = Arc::new(AtomicUsize::new(0));
5393 let count_for_peer = count.clone();
5394 #[derive(Clone)]
5395 struct MockState {
5396 count: Arc<AtomicUsize>,
5397 }
5398 async fn mock_sync_push(
5399 axum::extract::State(s): axum::extract::State<MockState>,
5400 Json(_body): Json<serde_json::Value>,
5401 ) -> (StatusCode, Json<serde_json::Value>) {
5402 s.count.fetch_add(1, Ordering::Relaxed);
5403 (
5404 StatusCode::OK,
5405 Json(json!({"applied":1,"noop":0,"skipped":0})),
5406 )
5407 }
5408 let peer_app = Router::new()
5409 .route("/api/v1/sync/push", axum_post(mock_sync_push))
5410 .with_state(MockState {
5411 count: count_for_peer,
5412 });
5413 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5414 let addr = listener.local_addr().unwrap();
5415 tokio::spawn(async move {
5416 axum::serve(listener, peer_app).await.ok();
5417 });
5418
5419 let peer_url = format!("http://{addr}");
5421 let fed = crate::federation::FederationConfig::build(
5422 2, &[peer_url],
5424 std::time::Duration::from_secs(2),
5425 None,
5426 None,
5427 None,
5428 "ai:bulk-test".to_string(),
5429 )
5430 .unwrap()
5431 .expect("federation must be built");
5432
5433 let app_state = AppState {
5434 db: state.clone(),
5435 embedder: Arc::new(None),
5436 vector_index: Arc::new(Mutex::new(None)),
5437 federation: Arc::new(Some(fed)),
5438 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
5439 scoring: Arc::new(crate::config::ResolvedScoring::default()),
5440 };
5441 let router = Router::new()
5442 .route("/api/v1/memories/bulk", axum_post(bulk_create))
5443 .with_state(app_state);
5444
5445 let n = 4;
5447 let bodies: Vec<serde_json::Value> = (0..n)
5448 .map(|i| {
5449 serde_json::json!({
5450 "tier": "long",
5451 "namespace": "bulk-fanout",
5452 "title": format!("bulk-fanout-{i}"),
5453 "content": "c",
5454 "tags": [],
5455 "priority": 5,
5456 "confidence": 1.0,
5457 "source": "api",
5458 "metadata": {}
5459 })
5460 })
5461 .collect();
5462 let resp = router
5463 .oneshot(
5464 axum::http::Request::builder()
5465 .uri("/api/v1/memories/bulk")
5466 .method("POST")
5467 .header("content-type", "application/json")
5468 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5469 .unwrap(),
5470 )
5471 .await
5472 .unwrap();
5473 assert_eq!(resp.status(), StatusCode::OK);
5474 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5475 .await
5476 .unwrap();
5477 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5478 assert_eq!(v["created"], n);
5479
5480 let expected = n + 1;
5486 for _ in 0..20 {
5487 if count.load(Ordering::Relaxed) >= expected {
5488 break;
5489 }
5490 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5491 }
5492 assert_eq!(
5493 count.load(Ordering::Relaxed),
5494 expected,
5495 "mock peer must receive one sync_push POST per bulk row plus one terminal catchup batch"
5496 );
5497 }
5498
5499 #[tokio::test]
5500 async fn http_sync_push_rejects_oversized_batch_redteam_242() {
5501 let state = test_state();
5505 let app = Router::new()
5506 .route("/api/v1/sync/push", axum_post(sync_push))
5507 .with_state(test_app_state(state));
5508 let now = Utc::now().to_rfc3339();
5509 let mems: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
5511 .map(|i| {
5512 serde_json::json!({
5513 "id": Uuid::new_v4().to_string(),
5514 "tier": "long",
5515 "namespace": "oversize",
5516 "title": format!("m{i}"),
5517 "content": "x",
5518 "tags": [],
5519 "priority": 5,
5520 "confidence": 1.0,
5521 "source": "api",
5522 "access_count": 0,
5523 "created_at": now,
5524 "updated_at": now,
5525 "last_accessed_at": null,
5526 "expires_at": null,
5527 "metadata": {}
5528 })
5529 })
5530 .collect();
5531 let body = serde_json::json!({
5532 "sender_agent_id": "peer-flood",
5533 "sender_clock": {"entries": {}},
5534 "memories": mems,
5535 "dry_run": false,
5536 });
5537 let resp = app
5538 .oneshot(
5539 axum::http::Request::builder()
5540 .uri("/api/v1/sync/push")
5541 .method("POST")
5542 .header("content-type", "application/json")
5543 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5544 .unwrap(),
5545 )
5546 .await
5547 .unwrap();
5548 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5549 }
5550
5551 #[tokio::test]
5552 async fn http_sync_push_dry_run_applies_nothing() {
5553 let state = test_state();
5555 let app = Router::new()
5556 .route("/api/v1/sync/push", axum_post(sync_push))
5557 .with_state(test_app_state(state.clone()));
5558
5559 let now = Utc::now().to_rfc3339();
5560 let body = serde_json::json!({
5561 "sender_agent_id": "peer-bob",
5562 "sender_clock": {"entries": {}},
5563 "memories": [{
5564 "id": Uuid::new_v4().to_string(),
5565 "tier": "long",
5566 "namespace": "sync-dryrun",
5567 "title": "Must not land",
5568 "content": "Preview only.",
5569 "tags": [],
5570 "priority": 5,
5571 "confidence": 1.0,
5572 "source": "api",
5573 "access_count": 0,
5574 "created_at": now,
5575 "updated_at": now,
5576 "last_accessed_at": null,
5577 "expires_at": null,
5578 "metadata": {}
5579 }],
5580 "dry_run": true
5581 });
5582 let resp = app
5583 .oneshot(
5584 axum::http::Request::builder()
5585 .uri("/api/v1/sync/push")
5586 .method("POST")
5587 .header("content-type", "application/json")
5588 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5589 .unwrap(),
5590 )
5591 .await
5592 .unwrap();
5593 assert_eq!(resp.status(), StatusCode::OK);
5594
5595 let lock = state.lock().await;
5596 let rows = db::list(
5597 &lock.0,
5598 Some("sync-dryrun"),
5599 None,
5600 10,
5601 0,
5602 None,
5603 None,
5604 None,
5605 None,
5606 None,
5607 )
5608 .unwrap();
5609 assert!(rows.is_empty(), "dry_run must not write rows");
5610 }
5611
5612 #[tokio::test]
5613 async fn http_contradictions_surfaces_same_topic_candidates_and_synth_link() {
5614 let state = test_state();
5618 let now = Utc::now().to_rfc3339();
5619
5620 {
5624 let lock = state.lock().await;
5625 let topic = "sky-color-test";
5626 for (title, agent, content) in [
5627 ("sky-color-test-alice", "ai:alice", "sky-color-test is blue"),
5628 ("sky-color-test-bob", "ai:bob", "sky-color-test is red"),
5629 ] {
5630 let mem = Memory {
5631 id: Uuid::new_v4().to_string(),
5632 tier: Tier::Mid,
5633 namespace: "contradictions-test".into(),
5634 title: title.into(),
5635 content: content.into(),
5636 tags: vec![],
5637 priority: 5,
5638 confidence: 1.0,
5639 source: "api".into(),
5640 access_count: 0,
5641 created_at: now.clone(),
5642 updated_at: now.clone(),
5643 last_accessed_at: None,
5644 expires_at: None,
5645 metadata: serde_json::json!({
5646 "agent_id": agent,
5647 "topic": topic,
5648 }),
5649 };
5650 db::insert(&lock.0, &mem).unwrap();
5651 }
5652 }
5653
5654 let app = Router::new()
5655 .route("/api/v1/contradictions", axum_get(detect_contradictions))
5656 .with_state(state);
5657
5658 let resp = app
5659 .oneshot(
5660 axum::http::Request::builder()
5661 .uri(
5662 "/api/v1/contradictions?topic=sky-color-test&namespace=contradictions-test",
5663 )
5664 .body(Body::empty())
5665 .unwrap(),
5666 )
5667 .await
5668 .unwrap();
5669 assert_eq!(resp.status(), StatusCode::OK);
5670 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5671 .await
5672 .unwrap();
5673 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5674
5675 let memories = v["memories"].as_array().unwrap();
5676 assert_eq!(memories.len(), 2, "both candidates should be returned");
5677
5678 let links = v["links"].as_array().unwrap();
5679 let synth_contradict = links.iter().find(|l| {
5680 l["relation"].as_str() == Some("contradicts")
5681 && l["synthesized"].as_bool() == Some(true)
5682 });
5683 assert!(
5684 synth_contradict.is_some(),
5685 "expected a synthesized contradicts link between alice and bob"
5686 );
5687 }
5688
5689 #[tokio::test]
5690 async fn http_contradictions_requires_topic_or_namespace() {
5691 let state = test_state();
5694 let app = Router::new()
5695 .route("/api/v1/contradictions", axum_get(detect_contradictions))
5696 .with_state(state);
5697 let resp = app
5698 .oneshot(
5699 axum::http::Request::builder()
5700 .uri("/api/v1/contradictions")
5701 .body(Body::empty())
5702 .unwrap(),
5703 )
5704 .await
5705 .unwrap();
5706 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5707 }
5708
5709 #[tokio::test]
5710 async fn http_sync_push_applies_deletions() {
5711 let state = test_state();
5715 let now = Utc::now().to_rfc3339();
5716
5717 let seeded_id = {
5718 let lock = state.lock().await;
5719 let mem = Memory {
5720 id: Uuid::new_v4().to_string(),
5721 tier: Tier::Mid,
5722 namespace: "delete-fanout".into(),
5723 title: "to-be-deleted".into(),
5724 content: "body".into(),
5725 tags: vec![],
5726 priority: 5,
5727 confidence: 1.0,
5728 source: "api".into(),
5729 access_count: 0,
5730 created_at: now.clone(),
5731 updated_at: now.clone(),
5732 last_accessed_at: None,
5733 expires_at: None,
5734 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
5735 };
5736 db::insert(&lock.0, &mem).unwrap()
5737 };
5738
5739 let app = Router::new()
5740 .route("/api/v1/sync/push", axum_post(sync_push))
5741 .with_state(test_app_state(state.clone()));
5742
5743 let body = serde_json::json!({
5744 "sender_agent_id": "peer-alice",
5745 "sender_clock": {"entries": {}},
5746 "memories": [],
5747 "deletions": [seeded_id.clone()],
5748 "dry_run": false
5749 });
5750 let resp = app
5751 .oneshot(
5752 axum::http::Request::builder()
5753 .uri("/api/v1/sync/push")
5754 .method("POST")
5755 .header("content-type", "application/json")
5756 .header("x-agent-id", "local-receiver")
5757 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5758 .unwrap(),
5759 )
5760 .await
5761 .unwrap();
5762 assert_eq!(resp.status(), StatusCode::OK);
5763 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5764 .await
5765 .unwrap();
5766 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5767 assert_eq!(v["deleted"], 1);
5768
5769 let lock = state.lock().await;
5770 let gone = db::get(&lock.0, &seeded_id).unwrap();
5771 assert!(
5772 gone.is_none(),
5773 "row should have been tombstoned by sync_push"
5774 );
5775 }
5776
5777 #[tokio::test]
5778 async fn http_sync_push_applies_incoming_links() {
5779 let state = test_state();
5784 let now = Utc::now().to_rfc3339();
5785
5786 let (m1, m2) = {
5788 let lock = state.lock().await;
5789 let m1 = Memory {
5790 id: Uuid::new_v4().to_string(),
5791 tier: Tier::Mid,
5792 namespace: "link-fanout".into(),
5793 title: "source".into(),
5794 content: "a".into(),
5795 tags: vec![],
5796 priority: 5,
5797 confidence: 1.0,
5798 source: "api".into(),
5799 access_count: 0,
5800 created_at: now.clone(),
5801 updated_at: now.clone(),
5802 last_accessed_at: None,
5803 expires_at: None,
5804 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
5805 };
5806 let m1_id = db::insert(&lock.0, &m1).unwrap();
5807 let m2 = Memory {
5808 id: Uuid::new_v4().to_string(),
5809 tier: Tier::Mid,
5810 namespace: "link-fanout".into(),
5811 title: "target".into(),
5812 content: "b".into(),
5813 tags: vec![],
5814 priority: 5,
5815 confidence: 1.0,
5816 source: "api".into(),
5817 access_count: 0,
5818 created_at: now.clone(),
5819 updated_at: now.clone(),
5820 last_accessed_at: None,
5821 expires_at: None,
5822 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
5823 };
5824 let m2_id = db::insert(&lock.0, &m2).unwrap();
5825 (m1_id, m2_id)
5826 };
5827
5828 let app = Router::new()
5829 .route("/api/v1/sync/push", axum_post(sync_push))
5830 .with_state(test_app_state(state.clone()));
5831
5832 let body = serde_json::json!({
5833 "sender_agent_id": "peer-alice",
5834 "sender_clock": {"entries": {}},
5835 "memories": [],
5836 "links": [{
5837 "source_id": m1,
5838 "target_id": m2,
5839 "relation": "related_to",
5840 "created_at": now,
5841 }],
5842 "dry_run": false
5843 });
5844 let resp = app
5845 .oneshot(
5846 axum::http::Request::builder()
5847 .uri("/api/v1/sync/push")
5848 .method("POST")
5849 .header("content-type", "application/json")
5850 .header("x-agent-id", "local-receiver")
5851 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5852 .unwrap(),
5853 )
5854 .await
5855 .unwrap();
5856 assert_eq!(resp.status(), StatusCode::OK);
5857 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5858 .await
5859 .unwrap();
5860 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5861 assert_eq!(v["links_applied"], 1);
5862
5863 let lock = state.lock().await;
5864 let links = db::get_links(&lock.0, &m1).unwrap();
5865 assert_eq!(links.len(), 1);
5866 assert_eq!(links[0].target_id, m2);
5867 assert_eq!(links[0].relation, "related_to");
5868 }
5869
5870 #[tokio::test]
5871 async fn http_sync_since_streams_new_memories_only() {
5872 let state = test_state();
5875 let old_ts = "2020-01-01T00:00:00+00:00";
5877 let new_ts = Utc::now().to_rfc3339();
5878 {
5879 let lock = state.lock().await;
5880 for (title, ts) in [("old-mem", old_ts), ("new-mem", new_ts.as_str())] {
5881 let mem = Memory {
5882 id: Uuid::new_v4().to_string(),
5883 tier: Tier::Long,
5884 namespace: "since-test".into(),
5885 title: title.into(),
5886 content: "body".into(),
5887 tags: vec![],
5888 priority: 5,
5889 confidence: 1.0,
5890 source: "api".into(),
5891 access_count: 0,
5892 created_at: ts.to_string(),
5893 updated_at: ts.to_string(),
5894 last_accessed_at: None,
5895 expires_at: None,
5896 metadata: serde_json::json!({}),
5897 };
5898 db::insert(&lock.0, &mem).unwrap();
5899 }
5900 }
5901
5902 let app = Router::new()
5903 .route("/api/v1/sync/since", axum_get(sync_since))
5904 .with_state(state);
5905
5906 let resp = app
5907 .oneshot(
5908 axum::http::Request::builder()
5909 .uri("/api/v1/sync/since?since=2020-06-01T00:00:00%2B00:00")
5910 .body(Body::empty())
5911 .unwrap(),
5912 )
5913 .await
5914 .unwrap();
5915 assert_eq!(resp.status(), StatusCode::OK);
5916 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5917 .await
5918 .unwrap();
5919 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5920 let titles: Vec<String> = v["memories"]
5921 .as_array()
5922 .unwrap()
5923 .iter()
5924 .filter_map(|m| m["title"].as_str().map(str::to_string))
5925 .collect();
5926 assert_eq!(titles, vec!["new-mem".to_string()]);
5927 }
5928
5929 #[tokio::test]
5930 async fn http_sync_since_includes_s39_diagnostic_fields() {
5931 let state = test_state();
5936 let mid_ts = "2024-06-01T00:00:00+00:00";
5938 let newer_ts = "2025-06-01T00:00:00+00:00";
5939 let newest_ts = "2026-01-01T00:00:00+00:00";
5940 {
5941 let lock = state.lock().await;
5942 for (title, ts) in [("mid", mid_ts), ("newer", newer_ts), ("newest", newest_ts)] {
5943 let mem = Memory {
5944 id: Uuid::new_v4().to_string(),
5945 tier: Tier::Long,
5946 namespace: "s39-diag".into(),
5947 title: title.into(),
5948 content: "c".into(),
5949 tags: vec![],
5950 priority: 5,
5951 confidence: 1.0,
5952 source: "api".into(),
5953 access_count: 0,
5954 created_at: ts.to_string(),
5955 updated_at: ts.to_string(),
5956 last_accessed_at: None,
5957 expires_at: None,
5958 metadata: serde_json::json!({}),
5959 };
5960 db::insert(&lock.0, &mem).unwrap();
5961 }
5962 }
5963
5964 let app = Router::new()
5965 .route("/api/v1/sync/since", axum_get(sync_since))
5966 .with_state(state.clone());
5967
5968 let since = "2024-01-01T00:00:00%2B00:00";
5970 let resp = app
5971 .oneshot(
5972 axum::http::Request::builder()
5973 .uri(format!("/api/v1/sync/since?since={since}"))
5974 .body(Body::empty())
5975 .unwrap(),
5976 )
5977 .await
5978 .unwrap();
5979 assert_eq!(resp.status(), StatusCode::OK);
5980 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5981 .await
5982 .unwrap();
5983 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5984 assert_eq!(v["count"], 3);
5985 assert_eq!(v["updated_since"], "2024-01-01T00:00:00+00:00");
5987 assert_eq!(v["earliest_updated_at"], mid_ts);
5988 assert_eq!(v["latest_updated_at"], newest_ts);
5989
5990 let empty_app = Router::new()
5993 .route("/api/v1/sync/since", axum_get(sync_since))
5994 .with_state(state);
5995 let resp = empty_app
5996 .oneshot(
5997 axum::http::Request::builder()
5998 .uri("/api/v1/sync/since?since=2099-01-01T00:00:00%2B00:00")
5999 .body(Body::empty())
6000 .unwrap(),
6001 )
6002 .await
6003 .unwrap();
6004 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6005 .await
6006 .unwrap();
6007 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6008 assert_eq!(v["count"], 0);
6009 assert!(v["earliest_updated_at"].is_null());
6010 assert!(v["latest_updated_at"].is_null());
6011 assert_eq!(v["updated_since"], "2099-01-01T00:00:00+00:00");
6012 }
6013
6014 #[tokio::test]
6015 async fn sync_since_rejects_garbage_timestamp_with_400() {
6016 let state = test_state();
6019 let app = Router::new()
6020 .route("/api/v1/sync/since", axum_get(sync_since))
6021 .with_state(state);
6022
6023 let resp = app
6024 .oneshot(
6025 axum::http::Request::builder()
6026 .uri("/api/v1/sync/since?since=not-a-date")
6027 .body(Body::empty())
6028 .unwrap(),
6029 )
6030 .await
6031 .unwrap();
6032 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6033 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6034 .await
6035 .unwrap();
6036 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6037 assert!(v["error"].as_str().unwrap().contains("RFC 3339"));
6038 }
6039
6040 #[tokio::test]
6041 async fn sync_state_observe_is_monotonic() {
6042 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6044 let older = "2020-01-01T00:00:00+00:00";
6045 let newer = "2026-04-17T00:00:00+00:00";
6046
6047 db::sync_state_observe(&conn, "local", "peer-a", newer).unwrap();
6048 db::sync_state_observe(&conn, "local", "peer-a", older).unwrap();
6050 let clock = db::sync_state_load(&conn, "local").unwrap();
6051 assert_eq!(clock.latest_from("peer-a"), Some(newer));
6052 }
6053
6054 async fn dummy_handler() -> impl IntoResponse {
6057 (StatusCode::OK, "ok")
6058 }
6059
6060 fn auth_app(api_key: Option<&str>) -> Router {
6061 let auth_state = ApiKeyState {
6062 key: api_key.map(String::from),
6063 };
6064 Router::new()
6065 .route("/api/v1/health", axum_get(dummy_handler))
6066 .route("/api/v1/memories", axum_get(dummy_handler))
6067 .layer(axum::middleware::from_fn_with_state(
6068 auth_state,
6069 api_key_auth,
6070 ))
6071 }
6072
6073 #[tokio::test]
6074 async fn api_key_no_key_configured_allows_all() {
6075 let app = auth_app(None);
6076 let resp = app
6077 .oneshot(
6078 axum::http::Request::builder()
6079 .uri("/api/v1/memories")
6080 .body(Body::empty())
6081 .unwrap(),
6082 )
6083 .await
6084 .unwrap();
6085 assert_eq!(resp.status(), StatusCode::OK);
6086 }
6087
6088 #[tokio::test]
6089 async fn api_key_valid_header_allows() {
6090 let app = auth_app(Some("secret123"));
6091 let resp = app
6092 .oneshot(
6093 axum::http::Request::builder()
6094 .uri("/api/v1/memories")
6095 .header("x-api-key", "secret123")
6096 .body(Body::empty())
6097 .unwrap(),
6098 )
6099 .await
6100 .unwrap();
6101 assert_eq!(resp.status(), StatusCode::OK);
6102 }
6103
6104 #[tokio::test]
6105 async fn api_key_invalid_header_rejected() {
6106 let app = auth_app(Some("secret123"));
6107 let resp = app
6108 .oneshot(
6109 axum::http::Request::builder()
6110 .uri("/api/v1/memories")
6111 .header("x-api-key", "wrong")
6112 .body(Body::empty())
6113 .unwrap(),
6114 )
6115 .await
6116 .unwrap();
6117 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6118 }
6119
6120 #[tokio::test]
6121 async fn api_key_missing_header_rejected() {
6122 let app = auth_app(Some("secret123"));
6123 let resp = app
6124 .oneshot(
6125 axum::http::Request::builder()
6126 .uri("/api/v1/memories")
6127 .body(Body::empty())
6128 .unwrap(),
6129 )
6130 .await
6131 .unwrap();
6132 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6133 }
6134
6135 #[tokio::test]
6136 async fn api_key_valid_query_param_allows() {
6137 let app = auth_app(Some("secret123"));
6138 let resp = app
6139 .oneshot(
6140 axum::http::Request::builder()
6141 .uri("/api/v1/memories?api_key=secret123")
6142 .body(Body::empty())
6143 .unwrap(),
6144 )
6145 .await
6146 .unwrap();
6147 assert_eq!(resp.status(), StatusCode::OK);
6148 }
6149
6150 #[tokio::test]
6151 async fn api_key_health_exempt() {
6152 let app = auth_app(Some("secret123"));
6153 let resp = app
6154 .oneshot(
6155 axum::http::Request::builder()
6156 .uri("/api/v1/health")
6157 .body(Body::empty())
6158 .unwrap(),
6159 )
6160 .await
6161 .unwrap();
6162 assert_eq!(resp.status(), StatusCode::OK);
6163 }
6164 #[tokio::test]
6172 async fn create_memory_rejects_invalid_json() {
6173 let state = test_state();
6174 let app = Router::new()
6175 .route("/api/v1/memories", axum_post(create_memory))
6176 .with_state(test_app_state(state));
6177
6178 let resp = app
6179 .oneshot(
6180 axum::http::Request::builder()
6181 .uri("/api/v1/memories")
6182 .method("POST")
6183 .header("content-type", "application/json")
6184 .body(Body::from(b"not valid json".to_vec()))
6185 .unwrap(),
6186 )
6187 .await
6188 .unwrap();
6189 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6190 }
6191
6192 #[tokio::test]
6193 async fn create_memory_rejects_missing_required_fields() {
6194 let state = test_state();
6195 let app = Router::new()
6196 .route("/api/v1/memories", axum_post(create_memory))
6197 .with_state(test_app_state(state));
6198
6199 let body = serde_json::json!({
6201 "tier": "long",
6202 "namespace": "test",
6203 "content": "body text",
6204 "tags": [],
6205 "priority": 5,
6206 "confidence": 1.0,
6207 "source": "api",
6208 "metadata": {}
6209 });
6210 let resp = app
6211 .clone()
6212 .oneshot(
6213 axum::http::Request::builder()
6214 .uri("/api/v1/memories")
6215 .method("POST")
6216 .header("content-type", "application/json")
6217 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6218 .unwrap(),
6219 )
6220 .await
6221 .unwrap();
6222 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6223 }
6224
6225 #[tokio::test]
6226 async fn create_memory_rejects_empty_title() {
6227 let state = test_state();
6228 let app = Router::new()
6229 .route("/api/v1/memories", axum_post(create_memory))
6230 .with_state(test_app_state(state));
6231
6232 let body = serde_json::json!({
6233 "tier": "long",
6234 "namespace": "test",
6235 "title": "",
6236 "content": "body text",
6237 "tags": [],
6238 "priority": 5,
6239 "confidence": 1.0,
6240 "source": "api",
6241 "metadata": {}
6242 });
6243 let resp = app
6244 .oneshot(
6245 axum::http::Request::builder()
6246 .uri("/api/v1/memories")
6247 .method("POST")
6248 .header("content-type", "application/json")
6249 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6250 .unwrap(),
6251 )
6252 .await
6253 .unwrap();
6254 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6255 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6256 .await
6257 .unwrap();
6258 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6259 assert!(v["error"].as_str().unwrap().contains("title"));
6260 }
6261
6262 #[tokio::test]
6263 async fn create_memory_rejects_oversized_content() {
6264 let state = test_state();
6265 let app = Router::new()
6266 .route("/api/v1/memories", axum_post(create_memory))
6267 .with_state(test_app_state(state));
6268
6269 let oversized = "x".repeat(65537);
6271 let body = serde_json::json!({
6272 "tier": "long",
6273 "namespace": "test",
6274 "title": "Test",
6275 "content": oversized,
6276 "tags": [],
6277 "priority": 5,
6278 "confidence": 1.0,
6279 "source": "api",
6280 "metadata": {}
6281 });
6282 let resp = app
6283 .oneshot(
6284 axum::http::Request::builder()
6285 .uri("/api/v1/memories")
6286 .method("POST")
6287 .header("content-type", "application/json")
6288 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6289 .unwrap(),
6290 )
6291 .await
6292 .unwrap();
6293 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6294 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6295 .await
6296 .unwrap();
6297 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6298 assert!(v["error"].as_str().unwrap().contains("exceeds max size"));
6299 }
6300
6301 #[tokio::test]
6302 async fn create_memory_rejects_invalid_tier() {
6303 let state = test_state();
6304 let app = Router::new()
6305 .route("/api/v1/memories", axum_post(create_memory))
6306 .with_state(test_app_state(state));
6307
6308 let body_str = r#"{"tier":"invalid_tier","namespace":"test","title":"Test","content":"body","tags":[],"priority":5,"confidence":1.0,"source":"api","metadata":{}}"#;
6310 let resp = app
6311 .oneshot(
6312 axum::http::Request::builder()
6313 .uri("/api/v1/memories")
6314 .method("POST")
6315 .header("content-type", "application/json")
6316 .body(Body::from(body_str.as_bytes().to_vec()))
6317 .unwrap(),
6318 )
6319 .await
6320 .unwrap();
6321 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6322 }
6323
6324 #[tokio::test]
6325 async fn create_memory_rejects_invalid_priority() {
6326 let state = test_state();
6327 let app = Router::new()
6328 .route("/api/v1/memories", axum_post(create_memory))
6329 .with_state(test_app_state(state));
6330
6331 let body = serde_json::json!({
6332 "tier": "long",
6333 "namespace": "test",
6334 "title": "Test",
6335 "content": "body",
6336 "tags": [],
6337 "priority": 0, "confidence": 1.0,
6339 "source": "api",
6340 "metadata": {}
6341 });
6342 let resp = app
6343 .oneshot(
6344 axum::http::Request::builder()
6345 .uri("/api/v1/memories")
6346 .method("POST")
6347 .header("content-type", "application/json")
6348 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6349 .unwrap(),
6350 )
6351 .await
6352 .unwrap();
6353 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6354 }
6355
6356 #[tokio::test]
6357 async fn create_memory_rejects_invalid_confidence() {
6358 let state = test_state();
6359 let app = Router::new()
6360 .route("/api/v1/memories", axum_post(create_memory))
6361 .with_state(test_app_state(state));
6362
6363 let body = serde_json::json!({
6364 "tier": "long",
6365 "namespace": "test",
6366 "title": "Test",
6367 "content": "body",
6368 "tags": [],
6369 "priority": 5,
6370 "confidence": 1.5, "source": "api",
6372 "metadata": {}
6373 });
6374 let resp = app
6375 .oneshot(
6376 axum::http::Request::builder()
6377 .uri("/api/v1/memories")
6378 .method("POST")
6379 .header("content-type", "application/json")
6380 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6381 .unwrap(),
6382 )
6383 .await
6384 .unwrap();
6385 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6386 }
6387
6388 #[tokio::test]
6389 async fn create_memory_rejects_invalid_source() {
6390 let state = test_state();
6391 let app = Router::new()
6392 .route("/api/v1/memories", axum_post(create_memory))
6393 .with_state(test_app_state(state));
6394
6395 let body = serde_json::json!({
6396 "tier": "long",
6397 "namespace": "test",
6398 "title": "Test",
6399 "content": "body",
6400 "tags": [],
6401 "priority": 5,
6402 "confidence": 1.0,
6403 "source": "invalid_source",
6404 "metadata": {}
6405 });
6406 let resp = app
6407 .oneshot(
6408 axum::http::Request::builder()
6409 .uri("/api/v1/memories")
6410 .method("POST")
6411 .header("content-type", "application/json")
6412 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6413 .unwrap(),
6414 )
6415 .await
6416 .unwrap();
6417 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6418 }
6419
6420 #[tokio::test]
6423 async fn update_memory_rejects_invalid_id() {
6424 let state = test_state();
6425 let app = Router::new()
6426 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6427 .with_state(test_app_state(state));
6428
6429 let body = serde_json::json!({"content": "new content"});
6430 let resp = app
6434 .oneshot(
6435 axum::http::Request::builder()
6436 .uri("/api/v1/memories/@@@@@@@@@@@@") .method("PUT")
6438 .header("content-type", "application/json")
6439 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6440 .unwrap(),
6441 )
6442 .await
6443 .unwrap();
6444 assert!(resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::NOT_FOUND);
6446 }
6447
6448 #[tokio::test]
6449 async fn update_memory_rejects_oversized_content() {
6450 let state = test_state();
6451 let now = Utc::now();
6452 let id = {
6453 let lock = state.lock().await;
6454 let mem = Memory {
6455 id: Uuid::new_v4().to_string(),
6456 tier: Tier::Long,
6457 namespace: "test".into(),
6458 title: "To Update".into(),
6459 content: "Original".into(),
6460 tags: vec![],
6461 priority: 5,
6462 confidence: 1.0,
6463 source: "test".into(),
6464 access_count: 0,
6465 created_at: now.to_rfc3339(),
6466 updated_at: now.to_rfc3339(),
6467 last_accessed_at: None,
6468 expires_at: None,
6469 metadata: serde_json::json!({}),
6470 };
6471 db::insert(&lock.0, &mem).unwrap()
6472 };
6473
6474 let app = Router::new()
6475 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6476 .with_state(test_app_state(state));
6477
6478 let oversized = "x".repeat(65537);
6479 let body = serde_json::json!({"content": oversized});
6480 let resp = app
6481 .oneshot(
6482 axum::http::Request::builder()
6483 .uri(format!("/api/v1/memories/{id}"))
6484 .method("PUT")
6485 .header("content-type", "application/json")
6486 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6487 .unwrap(),
6488 )
6489 .await
6490 .unwrap();
6491 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6492 }
6493
6494 #[tokio::test]
6495 async fn update_memory_rejects_invalid_confidence() {
6496 let state = test_state();
6497 let now = Utc::now();
6498 let id = {
6499 let lock = state.lock().await;
6500 let mem = Memory {
6501 id: Uuid::new_v4().to_string(),
6502 tier: Tier::Long,
6503 namespace: "test".into(),
6504 title: "To Update".into(),
6505 content: "Original".into(),
6506 tags: vec![],
6507 priority: 5,
6508 confidence: 1.0,
6509 source: "test".into(),
6510 access_count: 0,
6511 created_at: now.to_rfc3339(),
6512 updated_at: now.to_rfc3339(),
6513 last_accessed_at: None,
6514 expires_at: None,
6515 metadata: serde_json::json!({}),
6516 };
6517 db::insert(&lock.0, &mem).unwrap()
6518 };
6519
6520 let app = Router::new()
6521 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6522 .with_state(test_app_state(state));
6523
6524 let body = serde_json::json!({"confidence": -0.5});
6525 let resp = app
6526 .oneshot(
6527 axum::http::Request::builder()
6528 .uri(format!("/api/v1/memories/{id}"))
6529 .method("PUT")
6530 .header("content-type", "application/json")
6531 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6532 .unwrap(),
6533 )
6534 .await
6535 .unwrap();
6536 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6537 }
6538
6539 #[tokio::test]
6542 async fn link_rejects_self_link() {
6543 let state = test_state();
6544 let app = Router::new()
6545 .route("/api/v1/links", axum_post(create_link))
6546 .with_state(test_app_state(state));
6547
6548 let same_id = Uuid::new_v4().to_string();
6549 let body = serde_json::json!({
6550 "source_id": same_id,
6551 "target_id": same_id,
6552 "relation": "related_to"
6553 });
6554 let resp = app
6555 .oneshot(
6556 axum::http::Request::builder()
6557 .uri("/api/v1/links")
6558 .method("POST")
6559 .header("content-type", "application/json")
6560 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6561 .unwrap(),
6562 )
6563 .await
6564 .unwrap();
6565 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6566 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6567 .await
6568 .unwrap();
6569 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6570 assert!(
6571 v["error"]
6572 .as_str()
6573 .unwrap()
6574 .contains("cannot link a memory to itself")
6575 );
6576 }
6577
6578 #[tokio::test]
6579 async fn link_rejects_unknown_relation() {
6580 let state = test_state();
6581 let app = Router::new()
6582 .route("/api/v1/links", axum_post(create_link))
6583 .with_state(test_app_state(state));
6584
6585 let body = serde_json::json!({
6586 "source_id": Uuid::new_v4().to_string(),
6587 "target_id": Uuid::new_v4().to_string(),
6588 "relation": "invalid_relation"
6589 });
6590 let resp = app
6591 .oneshot(
6592 axum::http::Request::builder()
6593 .uri("/api/v1/links")
6594 .method("POST")
6595 .header("content-type", "application/json")
6596 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6597 .unwrap(),
6598 )
6599 .await
6600 .unwrap();
6601 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6602 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6603 .await
6604 .unwrap();
6605 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6606 assert!(v["error"].as_str().unwrap().contains("relation"));
6607 }
6608
6609 #[tokio::test]
6612 async fn recall_post_rejects_empty_context() {
6613 let state = test_state();
6614 let app = Router::new()
6615 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6616 .with_state(test_app_state(state));
6617
6618 let body = serde_json::json!({
6619 "context": "",
6620 "limit": 10
6621 });
6622 let resp = app
6623 .oneshot(
6624 axum::http::Request::builder()
6625 .uri("/api/v1/memories/recall")
6626 .method("POST")
6627 .header("content-type", "application/json")
6628 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6629 .unwrap(),
6630 )
6631 .await
6632 .unwrap();
6633 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6634 }
6635
6636 #[tokio::test]
6637 async fn recall_post_rejects_zero_budget_tokens() {
6638 let state = test_state();
6639 let app = Router::new()
6640 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6641 .with_state(test_app_state(state));
6642
6643 let body = serde_json::json!({
6644 "context": "search term",
6645 "limit": 10,
6646 "budget_tokens": 0
6647 });
6648 let resp = app
6649 .oneshot(
6650 axum::http::Request::builder()
6651 .uri("/api/v1/memories/recall")
6652 .method("POST")
6653 .header("content-type", "application/json")
6654 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6655 .unwrap(),
6656 )
6657 .await
6658 .unwrap();
6659 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6660 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6661 .await
6662 .unwrap();
6663 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6664 assert!(v["error"].as_str().unwrap().contains("budget_tokens"));
6665 }
6666
6667 #[tokio::test]
6668 async fn recall_get_rejects_empty_context() {
6669 let state = test_state();
6670 let app = Router::new()
6671 .route(
6672 "/api/v1/memories/recall",
6673 axum::routing::get(recall_memories_get),
6674 )
6675 .with_state(test_app_state(state));
6676
6677 let resp = app
6678 .oneshot(
6679 axum::http::Request::builder()
6680 .uri("/api/v1/memories/recall?context=")
6681 .method("GET")
6682 .body(Body::empty())
6683 .unwrap(),
6684 )
6685 .await
6686 .unwrap();
6687 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6688 }
6689
6690 #[tokio::test]
6693 async fn register_agent_rejects_invalid_agent_id() {
6694 let state = test_state();
6695 let app = Router::new()
6696 .route("/api/v1/agents", axum_post(register_agent))
6697 .with_state(test_app_state(state));
6698
6699 let body = serde_json::json!({
6700 "agent_id": "x".repeat(129), "agent_type": "human",
6702 "capabilities": []
6703 });
6704 let resp = app
6705 .oneshot(
6706 axum::http::Request::builder()
6707 .uri("/api/v1/agents")
6708 .method("POST")
6709 .header("content-type", "application/json")
6710 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6711 .unwrap(),
6712 )
6713 .await
6714 .unwrap();
6715 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6716 }
6717
6718 #[tokio::test]
6719 async fn register_agent_rejects_invalid_agent_type() {
6720 let state = test_state();
6721 let app = Router::new()
6722 .route("/api/v1/agents", axum_post(register_agent))
6723 .with_state(test_app_state(state));
6724
6725 let body = serde_json::json!({
6726 "agent_id": "test-agent",
6727 "agent_type": "invalid_type",
6728 "capabilities": []
6729 });
6730 let resp = app
6731 .oneshot(
6732 axum::http::Request::builder()
6733 .uri("/api/v1/agents")
6734 .method("POST")
6735 .header("content-type", "application/json")
6736 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6737 .unwrap(),
6738 )
6739 .await
6740 .unwrap();
6741 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6742 }
6743
6744 #[tokio::test]
6747 async fn subscribe_rejects_private_ip() {
6748 let state = test_state();
6749 let app = Router::new()
6750 .route("/api/v1/subscriptions", axum_post(subscribe))
6751 .with_state(test_app_state(state));
6752
6753 let body = serde_json::json!({
6755 "url": "http://10.0.0.1/webhook",
6756 "events": "*"
6757 });
6758 let resp = app
6759 .oneshot(
6760 axum::http::Request::builder()
6761 .uri("/api/v1/subscriptions")
6762 .method("POST")
6763 .header("content-type", "application/json")
6764 .header("x-agent-id", "alice")
6765 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6766 .unwrap(),
6767 )
6768 .await
6769 .unwrap();
6770 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6771 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6773 .await
6774 .unwrap();
6775 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6776 let error_msg = v["error"].as_str().unwrap();
6777 assert!(
6778 error_msg.contains("private")
6779 || error_msg.contains("link-local")
6780 || error_msg.contains("https")
6781 || error_msg.contains("non-loopback")
6782 );
6783 }
6784
6785 #[tokio::test]
6786 async fn subscribe_rejects_file_url() {
6787 let state = test_state();
6788 let app = Router::new()
6789 .route("/api/v1/subscriptions", axum_post(subscribe))
6790 .with_state(test_app_state(state));
6791
6792 let body = serde_json::json!({
6793 "url": "file:///etc/passwd",
6794 "events": "*"
6795 });
6796 let resp = app
6797 .oneshot(
6798 axum::http::Request::builder()
6799 .uri("/api/v1/subscriptions")
6800 .method("POST")
6801 .header("content-type", "application/json")
6802 .header("x-agent-id", "alice")
6803 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6804 .unwrap(),
6805 )
6806 .await
6807 .unwrap();
6808 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6809 }
6810
6811 #[tokio::test]
6812 async fn subscribe_accepts_localhost_loopback() {
6813 let state = test_state();
6815 let app = Router::new()
6816 .route("/api/v1/subscriptions", axum_post(subscribe))
6817 .with_state(test_app_state(state));
6818
6819 let body = serde_json::json!({
6820 "url": "http://localhost/webhook",
6821 "events": "*"
6822 });
6823 let resp = app
6824 .oneshot(
6825 axum::http::Request::builder()
6826 .uri("/api/v1/subscriptions")
6827 .method("POST")
6828 .header("content-type", "application/json")
6829 .header("x-agent-id", "alice")
6830 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6831 .unwrap(),
6832 )
6833 .await
6834 .unwrap();
6835 assert!(resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK);
6838 }
6839
6840 #[tokio::test]
6843 async fn notify_rejects_missing_payload() {
6844 let state = test_state();
6845 let app = Router::new()
6846 .route("/api/v1/notify", axum_post(notify))
6847 .with_state(test_app_state(state));
6848
6849 let body = serde_json::json!({
6850 "target_agent_id": "bob",
6851 "title": "A message"
6852 });
6853 let resp = app
6854 .oneshot(
6855 axum::http::Request::builder()
6856 .uri("/api/v1/notify")
6857 .method("POST")
6858 .header("content-type", "application/json")
6859 .header("x-agent-id", "alice")
6860 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6861 .unwrap(),
6862 )
6863 .await
6864 .unwrap();
6865 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6866 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6867 .await
6868 .unwrap();
6869 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6870 assert!(
6871 v["error"].as_str().unwrap().contains("payload")
6872 || v["error"].as_str().unwrap().contains("content")
6873 );
6874 }
6875
6876 #[tokio::test]
6884 async fn create_memory_handles_missing_content_type() {
6885 let state = test_state();
6886 let app = Router::new()
6887 .route("/api/v1/memories", axum_post(create_memory))
6888 .with_state(test_app_state(state));
6889
6890 let body = serde_json::json!({
6891 "tier": "long",
6892 "namespace": "test",
6893 "title": "Test",
6894 "content": "body",
6895 "tags": [],
6896 "priority": 5,
6897 "confidence": 1.0,
6898 "source": "api",
6899 "metadata": {}
6900 });
6901 let resp = app
6903 .oneshot(
6904 axum::http::Request::builder()
6905 .uri("/api/v1/memories")
6906 .method("POST")
6907 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6908 .unwrap(),
6909 )
6910 .await
6911 .unwrap();
6912 assert!(resp.status() != StatusCode::CREATED);
6914 }
6915
6916 #[tokio::test]
6919 async fn list_memories_handles_limit_zero() {
6920 let state = test_state();
6921 let app = Router::new()
6922 .route("/api/v1/memories", axum::routing::get(list_memories))
6923 .with_state(test_app_state(state));
6924
6925 let resp = app
6926 .oneshot(
6927 axum::http::Request::builder()
6928 .uri("/api/v1/memories?limit=0")
6929 .method("GET")
6930 .body(Body::empty())
6931 .unwrap(),
6932 )
6933 .await
6934 .unwrap();
6935 assert_eq!(resp.status(), StatusCode::OK);
6937 }
6938
6939 #[tokio::test]
6940 async fn list_memories_clamps_oversized_limit() {
6941 let state = test_state();
6942 let app = Router::new()
6943 .route("/api/v1/memories", axum::routing::get(list_memories))
6944 .with_state(test_app_state(state));
6945
6946 let resp = app
6947 .oneshot(
6948 axum::http::Request::builder()
6949 .uri("/api/v1/memories?limit=10000") .method("GET")
6951 .body(Body::empty())
6952 .unwrap(),
6953 )
6954 .await
6955 .unwrap();
6956 assert_eq!(resp.status(), StatusCode::OK);
6958 }
6959
6960 #[tokio::test]
6961 async fn search_memories_handles_negative_limit() {
6962 let state = test_state();
6963 let app = Router::new()
6964 .route(
6965 "/api/v1/memories/search",
6966 axum::routing::get(search_memories),
6967 )
6968 .with_state(test_app_state(state));
6969
6970 let resp = app
6971 .oneshot(
6972 axum::http::Request::builder()
6973 .uri("/api/v1/memories/search?query=test&limit=-1")
6974 .method("GET")
6975 .body(Body::empty())
6976 .unwrap(),
6977 )
6978 .await
6979 .unwrap();
6980 assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST);
6982 }
6983
6984 #[tokio::test]
6987 async fn api_key_missing_when_required_rejects() {
6988 let app = auth_app(Some("secret123"));
6989 let resp = app
6990 .oneshot(
6991 axum::http::Request::builder()
6992 .uri("/api/v1/memories")
6993 .method("GET")
6994 .body(Body::empty())
6996 .unwrap(),
6997 )
6998 .await
6999 .unwrap();
7000 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7001 }
7002
7003 #[tokio::test]
7004 async fn api_key_wrong_value_rejects() {
7005 let app = auth_app(Some("secret123"));
7006 let resp = app
7007 .oneshot(
7008 axum::http::Request::builder()
7009 .uri("/api/v1/memories")
7010 .method("GET")
7011 .header("x-api-key", "wrong_secret")
7012 .body(Body::empty())
7013 .unwrap(),
7014 )
7015 .await
7016 .unwrap();
7017 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7018 }
7019
7020 async fn insert_test_memory(state: &Db, namespace: &str, title: &str) -> String {
7029 let lock = state.lock().await;
7030 let now = Utc::now().to_rfc3339();
7031 let mem = Memory {
7032 id: Uuid::new_v4().to_string(),
7033 tier: Tier::Long,
7034 namespace: namespace.into(),
7035 title: title.into(),
7036 content: format!("content for {title}"),
7037 tags: vec![],
7038 priority: 5,
7039 confidence: 1.0,
7040 source: "test".into(),
7041 access_count: 0,
7042 created_at: now.clone(),
7043 updated_at: now,
7044 last_accessed_at: None,
7045 expires_at: None,
7046 metadata: serde_json::json!({}),
7047 };
7048 db::insert(&lock.0, &mem).unwrap()
7049 }
7050
7051 #[tokio::test]
7054 async fn http_list_archive_rejects_limit_zero() {
7055 let state = test_state();
7056 let app = Router::new()
7057 .route("/api/v1/archive", axum::routing::get(list_archive))
7058 .with_state(state);
7059 let resp = app
7060 .oneshot(
7061 axum::http::Request::builder()
7062 .uri("/api/v1/archive?limit=0")
7063 .body(Body::empty())
7064 .unwrap(),
7065 )
7066 .await
7067 .unwrap();
7068 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7069 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7070 .await
7071 .unwrap();
7072 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7073 assert!(v["error"].as_str().unwrap().contains("limit"));
7074 }
7075
7076 #[tokio::test]
7077 async fn http_list_archive_clamps_oversized_limit() {
7078 let state = test_state();
7079 let app = Router::new()
7080 .route("/api/v1/archive", axum::routing::get(list_archive))
7081 .with_state(state);
7082 let resp = app
7083 .oneshot(
7084 axum::http::Request::builder()
7085 .uri("/api/v1/archive?limit=99999")
7086 .body(Body::empty())
7087 .unwrap(),
7088 )
7089 .await
7090 .unwrap();
7091 assert_eq!(resp.status(), StatusCode::OK);
7092 }
7093
7094 #[tokio::test]
7095 async fn http_list_archive_filters_by_namespace() {
7096 let state = test_state();
7097 let id = insert_test_memory(&state, "arch-ns-a", "to-archive").await;
7099 {
7100 let lock = state.lock().await;
7101 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7102 }
7103 let app = Router::new()
7104 .route("/api/v1/archive", axum::routing::get(list_archive))
7105 .with_state(state);
7106 let resp = app
7107 .oneshot(
7108 axum::http::Request::builder()
7109 .uri("/api/v1/archive?namespace=arch-ns-a&limit=10")
7110 .body(Body::empty())
7111 .unwrap(),
7112 )
7113 .await
7114 .unwrap();
7115 assert_eq!(resp.status(), StatusCode::OK);
7116 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7117 .await
7118 .unwrap();
7119 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7120 assert_eq!(v["count"], 1);
7121 }
7122
7123 #[tokio::test]
7124 async fn http_restore_archive_404_for_unknown_id() {
7125 let state = test_state();
7126 let app = Router::new()
7127 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7128 .with_state(test_app_state(state));
7129 let resp = app
7130 .oneshot(
7131 axum::http::Request::builder()
7132 .uri("/api/v1/archive/00000000-0000-0000-0000-000000000000/restore")
7133 .method("POST")
7134 .body(Body::empty())
7135 .unwrap(),
7136 )
7137 .await
7138 .unwrap();
7139 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7140 }
7141
7142 #[tokio::test]
7143 async fn http_restore_archive_rejects_empty_id() {
7144 let state = test_state();
7149 let app = Router::new()
7150 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7151 .with_state(test_app_state(state));
7152 let resp = app
7153 .oneshot(
7154 axum::http::Request::builder()
7155 .uri("/api/v1/archive/%01/restore")
7156 .method("POST")
7157 .body(Body::empty())
7158 .unwrap(),
7159 )
7160 .await
7161 .unwrap();
7162 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7163 }
7164
7165 #[tokio::test]
7166 async fn http_restore_archive_double_restore_returns_404() {
7167 let state = test_state();
7170 let id = insert_test_memory(&state, "restore-twice", "row").await;
7171 {
7172 let lock = state.lock().await;
7173 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7174 }
7175 let app = Router::new()
7176 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7177 .with_state(test_app_state(state.clone()));
7178
7179 let resp = app
7181 .clone()
7182 .oneshot(
7183 axum::http::Request::builder()
7184 .uri(format!("/api/v1/archive/{id}/restore"))
7185 .method("POST")
7186 .body(Body::empty())
7187 .unwrap(),
7188 )
7189 .await
7190 .unwrap();
7191 assert_eq!(resp.status(), StatusCode::OK);
7192
7193 let resp = app
7195 .oneshot(
7196 axum::http::Request::builder()
7197 .uri(format!("/api/v1/archive/{id}/restore"))
7198 .method("POST")
7199 .body(Body::empty())
7200 .unwrap(),
7201 )
7202 .await
7203 .unwrap();
7204 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7205 }
7206
7207 #[tokio::test]
7208 async fn http_purge_archive_zero_days_purges_all() {
7209 let state = test_state();
7212 let id = insert_test_memory(&state, "purge-zero", "x").await;
7213 {
7214 let lock = state.lock().await;
7215 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7216 }
7217 let app = Router::new()
7218 .route("/api/v1/archive/purge", axum_post(purge_archive))
7219 .with_state(state.clone());
7220 let resp = app
7221 .oneshot(
7222 axum::http::Request::builder()
7223 .uri("/api/v1/archive/purge?older_than_days=0")
7224 .method("POST")
7225 .body(Body::empty())
7226 .unwrap(),
7227 )
7228 .await
7229 .unwrap();
7230 assert_eq!(resp.status(), StatusCode::OK);
7231 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7232 .await
7233 .unwrap();
7234 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7235 assert!(v["purged"].as_u64().is_some());
7239 }
7240
7241 #[tokio::test]
7242 async fn http_purge_archive_negative_days_returns_500() {
7243 let state = test_state();
7245 let app = Router::new()
7246 .route("/api/v1/archive/purge", axum_post(purge_archive))
7247 .with_state(state);
7248 let resp = app
7249 .oneshot(
7250 axum::http::Request::builder()
7251 .uri("/api/v1/archive/purge?older_than_days=-1")
7252 .method("POST")
7253 .body(Body::empty())
7254 .unwrap(),
7255 )
7256 .await
7257 .unwrap();
7258 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
7259 }
7260
7261 #[tokio::test]
7262 async fn http_purge_archive_no_days_purges_unconditional() {
7263 let state = test_state();
7265 let id = insert_test_memory(&state, "purge-all", "x").await;
7266 {
7267 let lock = state.lock().await;
7268 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7269 }
7270 let app = Router::new()
7271 .route("/api/v1/archive/purge", axum_post(purge_archive))
7272 .with_state(state.clone());
7273 let resp = app
7274 .oneshot(
7275 axum::http::Request::builder()
7276 .uri("/api/v1/archive/purge")
7277 .method("POST")
7278 .body(Body::empty())
7279 .unwrap(),
7280 )
7281 .await
7282 .unwrap();
7283 assert_eq!(resp.status(), StatusCode::OK);
7284 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7285 .await
7286 .unwrap();
7287 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7288 assert_eq!(v["purged"], 1);
7289 }
7290
7291 #[tokio::test]
7292 async fn http_archive_stats_reports_per_namespace_counts() {
7293 let state = test_state();
7294 let id_a = insert_test_memory(&state, "stats-a", "a").await;
7295 let id_b = insert_test_memory(&state, "stats-b", "b").await;
7296 {
7297 let lock = state.lock().await;
7298 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
7299 db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
7300 }
7301 let app = Router::new()
7302 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
7303 .with_state(state);
7304 let resp = app
7305 .oneshot(
7306 axum::http::Request::builder()
7307 .uri("/api/v1/archive/stats")
7308 .body(Body::empty())
7309 .unwrap(),
7310 )
7311 .await
7312 .unwrap();
7313 assert_eq!(resp.status(), StatusCode::OK);
7314 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7315 .await
7316 .unwrap();
7317 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7318 assert_eq!(v["archived_total"], 2);
7319 assert_eq!(v["by_namespace"].as_array().unwrap().len(), 2);
7320 }
7321
7322 #[tokio::test]
7323 async fn http_archive_by_ids_rejects_oversized_batch() {
7324 let state = test_state();
7326 let app = Router::new()
7327 .route("/api/v1/archive", axum_post(archive_by_ids))
7328 .with_state(test_app_state(state));
7329 let big_ids: Vec<String> = (0..=MAX_BULK_SIZE)
7330 .map(|_| Uuid::new_v4().to_string())
7331 .collect();
7332 let body = serde_json::json!({"ids": big_ids});
7333 let resp = app
7334 .oneshot(
7335 axum::http::Request::builder()
7336 .uri("/api/v1/archive")
7337 .method("POST")
7338 .header("content-type", "application/json")
7339 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7340 .unwrap(),
7341 )
7342 .await
7343 .unwrap();
7344 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7345 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7346 .await
7347 .unwrap();
7348 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7349 assert!(v["error"].as_str().unwrap().contains("archive limited"));
7350 }
7351
7352 #[tokio::test]
7353 async fn http_archive_by_ids_rejects_invalid_id_in_batch() {
7354 let state = test_state();
7355 let app = Router::new()
7356 .route("/api/v1/archive", axum_post(archive_by_ids))
7357 .with_state(test_app_state(state));
7358 let body = serde_json::json!({"ids": [" "]});
7360 let resp = app
7361 .oneshot(
7362 axum::http::Request::builder()
7363 .uri("/api/v1/archive")
7364 .method("POST")
7365 .header("content-type", "application/json")
7366 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7367 .unwrap(),
7368 )
7369 .await
7370 .unwrap();
7371 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7372 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7373 .await
7374 .unwrap();
7375 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7376 assert!(v["error"].as_str().unwrap().contains("invalid id"));
7377 }
7378
7379 #[tokio::test]
7380 async fn http_archive_by_ids_all_missing() {
7381 let state = test_state();
7385 let app = Router::new()
7386 .route("/api/v1/archive", axum_post(archive_by_ids))
7387 .with_state(test_app_state(state));
7388 let ids: Vec<String> = (0..3).map(|_| Uuid::new_v4().to_string()).collect();
7389 let body = serde_json::json!({"ids": ids});
7390 let resp = app
7391 .oneshot(
7392 axum::http::Request::builder()
7393 .uri("/api/v1/archive")
7394 .method("POST")
7395 .header("content-type", "application/json")
7396 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7397 .unwrap(),
7398 )
7399 .await
7400 .unwrap();
7401 assert_eq!(resp.status(), StatusCode::OK);
7402 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7403 .await
7404 .unwrap();
7405 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7406 assert_eq!(v["count"], 0);
7407 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
7408 assert_eq!(v["missing"].as_array().unwrap().len(), 3);
7409 }
7410
7411 #[tokio::test]
7414 async fn http_bulk_create_oversized_batch_rejected() {
7415 let state = test_state();
7416 let app = Router::new()
7417 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7418 .with_state(test_app_state(state));
7419 let bodies: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
7420 .map(|i| {
7421 serde_json::json!({
7422 "tier": "long",
7423 "namespace": "bulk-overflow",
7424 "title": format!("t-{i}"),
7425 "content": "c",
7426 "tags": [],
7427 "priority": 5,
7428 "confidence": 1.0,
7429 "source": "api",
7430 "metadata": {}
7431 })
7432 })
7433 .collect();
7434 let resp = app
7435 .oneshot(
7436 axum::http::Request::builder()
7437 .uri("/api/v1/memories/bulk")
7438 .method("POST")
7439 .header("content-type", "application/json")
7440 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7441 .unwrap(),
7442 )
7443 .await
7444 .unwrap();
7445 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7446 }
7447
7448 #[tokio::test]
7449 async fn http_bulk_create_partial_success_collects_errors() {
7450 let state = test_state();
7454 let app = Router::new()
7455 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7456 .with_state(test_app_state(state.clone()));
7457 let bodies = serde_json::json!([
7458 {
7459 "tier": "long",
7460 "namespace": "bulk-mixed",
7461 "title": "good row",
7462 "content": "ok",
7463 "tags": [],
7464 "priority": 5,
7465 "confidence": 1.0,
7466 "source": "api",
7467 "metadata": {}
7468 },
7469 {
7470 "tier": "long",
7471 "namespace": "bulk-mixed",
7472 "title": "",
7473 "content": "bad: empty title",
7474 "tags": [],
7475 "priority": 5,
7476 "confidence": 1.0,
7477 "source": "api",
7478 "metadata": {}
7479 }
7480 ]);
7481 let resp = app
7482 .oneshot(
7483 axum::http::Request::builder()
7484 .uri("/api/v1/memories/bulk")
7485 .method("POST")
7486 .header("content-type", "application/json")
7487 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7488 .unwrap(),
7489 )
7490 .await
7491 .unwrap();
7492 assert_eq!(resp.status(), StatusCode::OK);
7493 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7494 .await
7495 .unwrap();
7496 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7497 assert_eq!(v["created"], 1);
7498 assert_eq!(v["errors"].as_array().unwrap().len(), 1);
7499
7500 let lock = state.lock().await;
7502 let rows = db::list(
7503 &lock.0,
7504 Some("bulk-mixed"),
7505 None,
7506 10,
7507 0,
7508 None,
7509 None,
7510 None,
7511 None,
7512 None,
7513 )
7514 .unwrap();
7515 assert_eq!(rows.len(), 1);
7516 assert_eq!(rows[0].title, "good row");
7517 }
7518
7519 #[tokio::test]
7520 async fn http_bulk_create_empty_body_succeeds_with_zero_created() {
7521 let state = test_state();
7522 let app = Router::new()
7523 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7524 .with_state(test_app_state(state));
7525 let bodies: Vec<serde_json::Value> = vec![];
7526 let resp = app
7527 .oneshot(
7528 axum::http::Request::builder()
7529 .uri("/api/v1/memories/bulk")
7530 .method("POST")
7531 .header("content-type", "application/json")
7532 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7533 .unwrap(),
7534 )
7535 .await
7536 .unwrap();
7537 assert_eq!(resp.status(), StatusCode::OK);
7538 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7539 .await
7540 .unwrap();
7541 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7542 assert_eq!(v["created"], 0);
7543 assert!(v["errors"].as_array().unwrap().is_empty());
7544 }
7545
7546 #[tokio::test]
7549 async fn http_list_pending_empty_returns_zero_count() {
7550 let state = test_state();
7551 let app = Router::new()
7552 .route("/api/v1/pending", axum::routing::get(list_pending))
7553 .with_state(state);
7554 let resp = app
7555 .oneshot(
7556 axum::http::Request::builder()
7557 .uri("/api/v1/pending")
7558 .body(Body::empty())
7559 .unwrap(),
7560 )
7561 .await
7562 .unwrap();
7563 assert_eq!(resp.status(), StatusCode::OK);
7564 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7565 .await
7566 .unwrap();
7567 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7568 assert_eq!(v["count"], 0);
7569 }
7570
7571 #[tokio::test]
7572 async fn http_list_pending_with_status_filter() {
7573 let state = test_state();
7574 let app = Router::new()
7575 .route("/api/v1/pending", axum::routing::get(list_pending))
7576 .with_state(state);
7577 let resp = app
7579 .oneshot(
7580 axum::http::Request::builder()
7581 .uri("/api/v1/pending?status=approved&limit=5")
7582 .body(Body::empty())
7583 .unwrap(),
7584 )
7585 .await
7586 .unwrap();
7587 assert_eq!(resp.status(), StatusCode::OK);
7588 }
7589
7590 #[tokio::test]
7591 async fn http_approve_pending_unknown_id_returns_403_or_500() {
7592 let state = test_state();
7597 let app = Router::new()
7598 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7599 .with_state(test_app_state(state));
7600 let unknown = Uuid::new_v4().to_string();
7601 let resp = app
7602 .oneshot(
7603 axum::http::Request::builder()
7604 .uri(format!("/api/v1/pending/{unknown}/approve"))
7605 .method("POST")
7606 .header("x-agent-id", "alice")
7607 .body(Body::empty())
7608 .unwrap(),
7609 )
7610 .await
7611 .unwrap();
7612 assert!(
7613 resp.status() == StatusCode::FORBIDDEN
7614 || resp.status() == StatusCode::INTERNAL_SERVER_ERROR
7615 || resp.status() == StatusCode::ACCEPTED,
7616 "unexpected status {}",
7617 resp.status()
7618 );
7619 }
7620
7621 #[tokio::test]
7622 async fn http_approve_pending_rejects_invalid_agent_id() {
7623 let state = test_state();
7626 let app = Router::new()
7627 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7628 .with_state(test_app_state(state));
7629 let id = Uuid::new_v4().to_string();
7630 let resp = app
7631 .oneshot(
7632 axum::http::Request::builder()
7633 .uri(format!("/api/v1/pending/{id}/approve"))
7634 .method("POST")
7635 .header("x-agent-id", "bad agent")
7636 .body(Body::empty())
7637 .unwrap(),
7638 )
7639 .await
7640 .unwrap();
7641 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7642 }
7643
7644 #[tokio::test]
7645 async fn http_reject_pending_unknown_id_returns_404() {
7646 let state = test_state();
7647 let app = Router::new()
7648 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7649 .with_state(test_app_state(state));
7650 let unknown = Uuid::new_v4().to_string();
7651 let resp = app
7652 .oneshot(
7653 axum::http::Request::builder()
7654 .uri(format!("/api/v1/pending/{unknown}/reject"))
7655 .method("POST")
7656 .header("x-agent-id", "alice")
7657 .body(Body::empty())
7658 .unwrap(),
7659 )
7660 .await
7661 .unwrap();
7662 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7663 }
7664
7665 #[tokio::test]
7666 async fn http_reject_pending_rejects_invalid_agent_id() {
7667 let state = test_state();
7668 let app = Router::new()
7669 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7670 .with_state(test_app_state(state));
7671 let id = Uuid::new_v4().to_string();
7672 let resp = app
7673 .oneshot(
7674 axum::http::Request::builder()
7675 .uri(format!("/api/v1/pending/{id}/reject"))
7676 .method("POST")
7677 .header("x-agent-id", "bad agent")
7678 .body(Body::empty())
7679 .unwrap(),
7680 )
7681 .await
7682 .unwrap();
7683 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7684 }
7685
7686 #[tokio::test]
7689 async fn http_search_rejects_blank_query() {
7690 let state = test_state();
7691 let app = Router::new()
7692 .route(
7693 "/api/v1/memories/search",
7694 axum::routing::get(search_memories),
7695 )
7696 .with_state(state);
7697 let resp = app
7698 .oneshot(
7699 axum::http::Request::builder()
7700 .uri("/api/v1/memories/search?q=%20%20%20") .body(Body::empty())
7702 .unwrap(),
7703 )
7704 .await
7705 .unwrap();
7706 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7707 }
7708
7709 #[tokio::test]
7710 async fn http_search_long_query_succeeds() {
7711 let state = test_state();
7714 let app = Router::new()
7715 .route(
7716 "/api/v1/memories/search",
7717 axum::routing::get(search_memories),
7718 )
7719 .with_state(state);
7720 let q = "a".repeat(2_000);
7721 let resp = app
7722 .oneshot(
7723 axum::http::Request::builder()
7724 .uri(format!("/api/v1/memories/search?q={q}"))
7725 .body(Body::empty())
7726 .unwrap(),
7727 )
7728 .await
7729 .unwrap();
7730 assert!(
7731 resp.status() == StatusCode::OK
7732 || resp.status() == StatusCode::BAD_REQUEST
7733 || resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
7734 "unexpected status {}",
7735 resp.status()
7736 );
7737 }
7738
7739 #[tokio::test]
7740 async fn http_search_normal_query_returns_results_array() {
7741 let state = test_state();
7744 let app = Router::new()
7745 .route(
7746 "/api/v1/memories/search",
7747 axum::routing::get(search_memories),
7748 )
7749 .with_state(state);
7750 let resp = app
7751 .oneshot(
7752 axum::http::Request::builder()
7753 .uri("/api/v1/memories/search?q=hello")
7754 .body(Body::empty())
7755 .unwrap(),
7756 )
7757 .await
7758 .unwrap();
7759 assert_eq!(resp.status(), StatusCode::OK);
7760 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7761 .await
7762 .unwrap();
7763 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7764 assert!(v["results"].is_array());
7765 assert_eq!(v["query"], "hello");
7766 }
7767
7768 #[tokio::test]
7769 async fn http_search_invalid_agent_id_filter_rejected() {
7770 let state = test_state();
7771 let app = Router::new()
7772 .route(
7773 "/api/v1/memories/search",
7774 axum::routing::get(search_memories),
7775 )
7776 .with_state(state);
7777 let resp = app
7779 .oneshot(
7780 axum::http::Request::builder()
7781 .uri("/api/v1/memories/search?q=test&agent_id=bad%20agent")
7782 .body(Body::empty())
7783 .unwrap(),
7784 )
7785 .await
7786 .unwrap();
7787 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7788 }
7789
7790 #[tokio::test]
7793 async fn http_recall_get_rejects_blank_context() {
7794 let state = test_state();
7795 let app = Router::new()
7796 .route(
7797 "/api/v1/memories/recall",
7798 axum::routing::get(recall_memories_get),
7799 )
7800 .with_state(test_app_state(state));
7801 let resp = app
7802 .oneshot(
7803 axum::http::Request::builder()
7804 .uri("/api/v1/memories/recall?context=%20")
7805 .body(Body::empty())
7806 .unwrap(),
7807 )
7808 .await
7809 .unwrap();
7810 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7811 }
7812
7813 #[tokio::test]
7814 async fn http_recall_get_rejects_zero_budget_tokens() {
7815 let state = test_state();
7816 let app = Router::new()
7817 .route(
7818 "/api/v1/memories/recall",
7819 axum::routing::get(recall_memories_get),
7820 )
7821 .with_state(test_app_state(state));
7822 let resp = app
7823 .oneshot(
7824 axum::http::Request::builder()
7825 .uri("/api/v1/memories/recall?context=hi&budget_tokens=0")
7826 .body(Body::empty())
7827 .unwrap(),
7828 )
7829 .await
7830 .unwrap();
7831 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7832 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7833 .await
7834 .unwrap();
7835 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7836 assert!(v["error"].as_str().unwrap().contains("budget_tokens"));
7837 }
7838
7839 #[tokio::test]
7840 async fn http_recall_post_rejects_blank_context() {
7841 let state = test_state();
7842 let app = Router::new()
7843 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
7844 .with_state(test_app_state(state));
7845 let body = serde_json::json!({"context": " "});
7846 let resp = app
7847 .oneshot(
7848 axum::http::Request::builder()
7849 .uri("/api/v1/memories/recall")
7850 .method("POST")
7851 .header("content-type", "application/json")
7852 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7853 .unwrap(),
7854 )
7855 .await
7856 .unwrap();
7857 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7858 }
7859
7860 #[tokio::test]
7861 async fn http_recall_post_keyword_mode_returns_mode_field() {
7862 let state = test_state();
7865 let _id = insert_test_memory(&state, "recall-mode", "the title").await;
7866 let app = Router::new()
7867 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
7868 .with_state(test_app_state(state));
7869 let body = serde_json::json!({"context": "title", "namespace": "recall-mode"});
7870 let resp = app
7871 .oneshot(
7872 axum::http::Request::builder()
7873 .uri("/api/v1/memories/recall")
7874 .method("POST")
7875 .header("content-type", "application/json")
7876 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7877 .unwrap(),
7878 )
7879 .await
7880 .unwrap();
7881 assert_eq!(resp.status(), StatusCode::OK);
7882 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7883 .await
7884 .unwrap();
7885 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7886 assert_eq!(v["mode"], "keyword");
7887 }
7888
7889 #[tokio::test]
7892 async fn http_sync_since_empty_db_returns_zero_count() {
7893 let state = test_state();
7894 let app = Router::new()
7895 .route("/api/v1/sync/since", axum::routing::get(sync_since))
7896 .with_state(state);
7897 let resp = app
7898 .oneshot(
7899 axum::http::Request::builder()
7900 .uri("/api/v1/sync/since?since=2000-01-01T00:00:00Z&limit=10")
7901 .body(Body::empty())
7902 .unwrap(),
7903 )
7904 .await
7905 .unwrap();
7906 assert_eq!(resp.status(), StatusCode::OK);
7907 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7908 .await
7909 .unwrap();
7910 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7911 assert_eq!(v["count"], 0);
7912 assert!(v["earliest_updated_at"].is_null());
7913 assert!(v["latest_updated_at"].is_null());
7914 }
7915
7916 #[tokio::test]
7917 async fn http_sync_since_clamps_oversized_limit() {
7918 let state = test_state();
7919 let app = Router::new()
7920 .route("/api/v1/sync/since", axum::routing::get(sync_since))
7921 .with_state(state);
7922 let resp = app
7923 .oneshot(
7924 axum::http::Request::builder()
7925 .uri("/api/v1/sync/since?limit=999999")
7926 .body(Body::empty())
7927 .unwrap(),
7928 )
7929 .await
7930 .unwrap();
7931 assert_eq!(resp.status(), StatusCode::OK);
7932 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7933 .await
7934 .unwrap();
7935 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7936 assert!(v["limit"].as_u64().unwrap() <= 10_000);
7938 }
7939
7940 #[tokio::test]
7941 async fn http_sync_since_empty_since_string_treated_as_full_snapshot() {
7942 let state = test_state();
7945 let _id = insert_test_memory(&state, "sync-empty", "row").await;
7946 let app = Router::new()
7947 .route("/api/v1/sync/since", axum::routing::get(sync_since))
7948 .with_state(state);
7949 let resp = app
7950 .oneshot(
7951 axum::http::Request::builder()
7952 .uri("/api/v1/sync/since?since=")
7953 .body(Body::empty())
7954 .unwrap(),
7955 )
7956 .await
7957 .unwrap();
7958 assert_eq!(resp.status(), StatusCode::OK);
7959 }
7960
7961 #[tokio::test]
7962 async fn http_sync_since_records_peer_via_observe() {
7963 let state = test_state();
7966 let _id = insert_test_memory(&state, "sync-peer", "row").await;
7967 let app = Router::new()
7968 .route("/api/v1/sync/since", axum::routing::get(sync_since))
7969 .with_state(state.clone());
7970 let resp = app
7971 .oneshot(
7972 axum::http::Request::builder()
7973 .uri("/api/v1/sync/since?peer=peer-x")
7974 .header("x-agent-id", "alice")
7975 .body(Body::empty())
7976 .unwrap(),
7977 )
7978 .await
7979 .unwrap();
7980 assert_eq!(resp.status(), StatusCode::OK);
7981 }
7982
7983 #[tokio::test]
7986 async fn http_capabilities_returns_features() {
7987 let state = test_state();
7988 let app = Router::new()
7989 .route("/api/v1/capabilities", axum::routing::get(get_capabilities))
7990 .with_state(test_app_state(state));
7991 let resp = app
7992 .oneshot(
7993 axum::http::Request::builder()
7994 .uri("/api/v1/capabilities")
7995 .body(Body::empty())
7996 .unwrap(),
7997 )
7998 .await
7999 .unwrap();
8000 assert_eq!(resp.status(), StatusCode::OK);
8001 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8002 .await
8003 .unwrap();
8004 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8005 assert_eq!(v["features"]["embedder_loaded"], false);
8008 }
8009
8010 #[tokio::test]
8011 async fn http_session_start_rejects_invalid_agent_id() {
8012 let state = test_state();
8013 let app = Router::new()
8014 .route("/api/v1/session/start", axum_post(session_start))
8015 .with_state(state);
8016 let body = serde_json::json!({"agent_id": "bad agent id with spaces"});
8017 let resp = app
8018 .oneshot(
8019 axum::http::Request::builder()
8020 .uri("/api/v1/session/start")
8021 .method("POST")
8022 .header("content-type", "application/json")
8023 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8024 .unwrap(),
8025 )
8026 .await
8027 .unwrap();
8028 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8029 }
8030
8031 #[tokio::test]
8032 async fn http_session_start_stamps_session_id() {
8033 let state = test_state();
8034 let app = Router::new()
8035 .route("/api/v1/session/start", axum_post(session_start))
8036 .with_state(state);
8037 let body = serde_json::json!({});
8038 let resp = app
8039 .oneshot(
8040 axum::http::Request::builder()
8041 .uri("/api/v1/session/start")
8042 .method("POST")
8043 .header("content-type", "application/json")
8044 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8045 .unwrap(),
8046 )
8047 .await
8048 .unwrap();
8049 assert_eq!(resp.status(), StatusCode::OK);
8050 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8051 .await
8052 .unwrap();
8053 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8054 assert!(v["session_id"].as_str().is_some());
8055 }
8056
8057 #[tokio::test]
8058 async fn http_get_taxonomy_rejects_invalid_prefix() {
8059 let state = test_state();
8062 let app = Router::new()
8063 .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8064 .with_state(state);
8065 let resp = app
8066 .oneshot(
8067 axum::http::Request::builder()
8068 .uri("/api/v1/taxonomy?prefix=bad%20prefix")
8069 .body(Body::empty())
8070 .unwrap(),
8071 )
8072 .await
8073 .unwrap();
8074 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8075 }
8076
8077 #[tokio::test]
8078 async fn http_get_taxonomy_clamps_depth_and_limit() {
8079 let state = test_state();
8080 let app = Router::new()
8081 .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8082 .with_state(state);
8083 let resp = app
8084 .oneshot(
8085 axum::http::Request::builder()
8086 .uri("/api/v1/taxonomy?depth=1000&limit=999999")
8087 .body(Body::empty())
8088 .unwrap(),
8089 )
8090 .await
8091 .unwrap();
8092 assert_eq!(resp.status(), StatusCode::OK);
8093 }
8094
8095 #[tokio::test]
8098 async fn http_list_subscriptions_empty_returns_zero() {
8099 let state = test_state();
8100 let app = Router::new()
8101 .route(
8102 "/api/v1/subscriptions",
8103 axum::routing::get(list_subscriptions),
8104 )
8105 .with_state(state);
8106 let resp = app
8107 .oneshot(
8108 axum::http::Request::builder()
8109 .uri("/api/v1/subscriptions")
8110 .body(Body::empty())
8111 .unwrap(),
8112 )
8113 .await
8114 .unwrap();
8115 assert_eq!(resp.status(), StatusCode::OK);
8116 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8117 .await
8118 .unwrap();
8119 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8120 assert_eq!(v["count"], 0);
8121 assert!(v["subscriptions"].as_array().unwrap().is_empty());
8122 }
8123
8124 #[tokio::test]
8125 async fn http_list_subscriptions_filters_by_agent_id() {
8126 let state = test_state();
8129 let app = Router::new()
8130 .route(
8131 "/api/v1/subscriptions",
8132 axum::routing::get(list_subscriptions),
8133 )
8134 .with_state(state);
8135 let resp = app
8136 .oneshot(
8137 axum::http::Request::builder()
8138 .uri("/api/v1/subscriptions?agent_id=alice")
8139 .body(Body::empty())
8140 .unwrap(),
8141 )
8142 .await
8143 .unwrap();
8144 assert_eq!(resp.status(), StatusCode::OK);
8145 }
8146
8147 #[tokio::test]
8150 async fn http_get_inbox_with_x_agent_id_header() {
8151 let state = test_state();
8152 let app = Router::new()
8153 .route("/api/v1/inbox", axum::routing::get(get_inbox))
8154 .with_state(test_app_state(state));
8155 let resp = app
8156 .oneshot(
8157 axum::http::Request::builder()
8158 .uri("/api/v1/inbox?unread_only=true&limit=20")
8159 .header("x-agent-id", "alice")
8160 .body(Body::empty())
8161 .unwrap(),
8162 )
8163 .await
8164 .unwrap();
8165 assert_eq!(resp.status(), StatusCode::OK);
8166 }
8167
8168 #[tokio::test]
8181 async fn http_check_duplicate_rejects_invalid_title() {
8182 let state = test_state();
8183 let app = Router::new()
8184 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8185 .with_state(test_app_state(state));
8186 let body = serde_json::json!({"title": "", "content": "non-empty"});
8188 let resp = app
8189 .oneshot(
8190 axum::http::Request::builder()
8191 .uri("/api/v1/check_duplicate")
8192 .method("POST")
8193 .header("content-type", "application/json")
8194 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8195 .unwrap(),
8196 )
8197 .await
8198 .unwrap();
8199 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8200 }
8201
8202 #[tokio::test]
8203 async fn http_check_duplicate_rejects_invalid_content() {
8204 let state = test_state();
8205 let app = Router::new()
8206 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8207 .with_state(test_app_state(state));
8208 let body = serde_json::json!({"title": "ok", "content": ""});
8210 let resp = app
8211 .oneshot(
8212 axum::http::Request::builder()
8213 .uri("/api/v1/check_duplicate")
8214 .method("POST")
8215 .header("content-type", "application/json")
8216 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8217 .unwrap(),
8218 )
8219 .await
8220 .unwrap();
8221 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8222 }
8223
8224 #[tokio::test]
8225 async fn http_check_duplicate_rejects_invalid_namespace() {
8226 let state = test_state();
8227 let app = Router::new()
8228 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8229 .with_state(test_app_state(state));
8230 let body = serde_json::json!({
8232 "title": "ok",
8233 "content": "ok content",
8234 "namespace": "BAD NAMESPACE WITH SPACES",
8235 });
8236 let resp = app
8237 .oneshot(
8238 axum::http::Request::builder()
8239 .uri("/api/v1/check_duplicate")
8240 .method("POST")
8241 .header("content-type", "application/json")
8242 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8243 .unwrap(),
8244 )
8245 .await
8246 .unwrap();
8247 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8248 }
8249
8250 #[tokio::test]
8251 async fn http_check_duplicate_503_when_no_embedder() {
8252 let state = test_state();
8254 let app = Router::new()
8255 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8256 .with_state(test_app_state(state));
8257 let body = serde_json::json!({"title": "anchor", "content": "some long enough content"});
8258 let resp = app
8259 .oneshot(
8260 axum::http::Request::builder()
8261 .uri("/api/v1/check_duplicate")
8262 .method("POST")
8263 .header("content-type", "application/json")
8264 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8265 .unwrap(),
8266 )
8267 .await
8268 .unwrap();
8269 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8270 }
8271
8272 #[tokio::test]
8275 async fn http_entity_register_creates_then_idempotent_returns_200() {
8276 let state = test_state();
8277 let app = Router::new()
8278 .route("/api/v1/entities", axum_post(entity_register))
8279 .with_state(state.clone());
8280 let body = serde_json::json!({
8282 "canonical_name": "Acme Corp",
8283 "namespace": "kg-test",
8284 "aliases": ["acme", "Acme"],
8285 "metadata": {"region": "us"},
8286 });
8287 let resp = app
8288 .clone()
8289 .oneshot(
8290 axum::http::Request::builder()
8291 .uri("/api/v1/entities")
8292 .method("POST")
8293 .header("content-type", "application/json")
8294 .header("x-agent-id", "alice")
8295 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8296 .unwrap(),
8297 )
8298 .await
8299 .unwrap();
8300 assert_eq!(resp.status(), StatusCode::CREATED);
8301
8302 let resp2 = app
8304 .oneshot(
8305 axum::http::Request::builder()
8306 .uri("/api/v1/entities")
8307 .method("POST")
8308 .header("content-type", "application/json")
8309 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8310 .unwrap(),
8311 )
8312 .await
8313 .unwrap();
8314 assert_eq!(resp2.status(), StatusCode::OK);
8315 }
8316
8317 #[tokio::test]
8318 async fn http_entity_register_rejects_invalid_canonical_name() {
8319 let state = test_state();
8320 let app = Router::new()
8321 .route("/api/v1/entities", axum_post(entity_register))
8322 .with_state(state);
8323 let body = serde_json::json!({
8324 "canonical_name": "",
8325 "namespace": "kg-test",
8326 });
8327 let resp = app
8328 .oneshot(
8329 axum::http::Request::builder()
8330 .uri("/api/v1/entities")
8331 .method("POST")
8332 .header("content-type", "application/json")
8333 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8334 .unwrap(),
8335 )
8336 .await
8337 .unwrap();
8338 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8339 }
8340
8341 #[tokio::test]
8342 async fn http_entity_register_rejects_invalid_namespace() {
8343 let state = test_state();
8344 let app = Router::new()
8345 .route("/api/v1/entities", axum_post(entity_register))
8346 .with_state(state);
8347 let body = serde_json::json!({
8348 "canonical_name": "Acme",
8349 "namespace": "BAD NS!",
8350 });
8351 let resp = app
8352 .oneshot(
8353 axum::http::Request::builder()
8354 .uri("/api/v1/entities")
8355 .method("POST")
8356 .header("content-type", "application/json")
8357 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8358 .unwrap(),
8359 )
8360 .await
8361 .unwrap();
8362 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8363 }
8364
8365 #[tokio::test]
8366 async fn http_entity_register_rejects_invalid_agent_id_header() {
8367 let state = test_state();
8368 let app = Router::new()
8369 .route("/api/v1/entities", axum_post(entity_register))
8370 .with_state(state);
8371 let body = serde_json::json!({
8372 "canonical_name": "Acme",
8373 "namespace": "kg-test",
8374 });
8375 let resp = app
8376 .oneshot(
8377 axum::http::Request::builder()
8378 .uri("/api/v1/entities")
8379 .method("POST")
8380 .header("content-type", "application/json")
8381 .header("x-agent-id", "BAD AGENT!")
8382 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8383 .unwrap(),
8384 )
8385 .await
8386 .unwrap();
8387 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8388 }
8389
8390 #[tokio::test]
8391 async fn http_entity_register_collision_with_non_entity_returns_409() {
8392 let state = test_state();
8395 let now = Utc::now().to_rfc3339();
8396 {
8397 let lock = state.lock().await;
8398 let mem = Memory {
8399 id: Uuid::new_v4().to_string(),
8400 tier: Tier::Long,
8401 namespace: "collide-ns".into(),
8402 title: "Acme Squat".into(),
8403 content: "this is a regular memory".into(),
8404 tags: vec![],
8405 priority: 5,
8406 confidence: 1.0,
8407 source: "test".into(),
8408 access_count: 0,
8409 created_at: now.clone(),
8410 updated_at: now,
8411 last_accessed_at: None,
8412 expires_at: None,
8413 metadata: serde_json::json!({}),
8414 };
8415 db::insert(&lock.0, &mem).unwrap();
8416 }
8417 let app = Router::new()
8418 .route("/api/v1/entities", axum_post(entity_register))
8419 .with_state(state);
8420 let body = serde_json::json!({
8421 "canonical_name": "Acme Squat",
8422 "namespace": "collide-ns",
8423 });
8424 let resp = app
8425 .oneshot(
8426 axum::http::Request::builder()
8427 .uri("/api/v1/entities")
8428 .method("POST")
8429 .header("content-type", "application/json")
8430 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8431 .unwrap(),
8432 )
8433 .await
8434 .unwrap();
8435 assert_eq!(resp.status(), StatusCode::CONFLICT);
8436 }
8437
8438 #[tokio::test]
8439 async fn http_entity_get_by_alias_blank_alias_rejected() {
8440 let state = test_state();
8441 let app = Router::new()
8442 .route(
8443 "/api/v1/entities/by_alias",
8444 axum::routing::get(entity_get_by_alias),
8445 )
8446 .with_state(state);
8447 let resp = app
8448 .oneshot(
8449 axum::http::Request::builder()
8450 .uri("/api/v1/entities/by_alias?alias=%20%20")
8451 .body(Body::empty())
8452 .unwrap(),
8453 )
8454 .await
8455 .unwrap();
8456 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8457 }
8458
8459 #[tokio::test]
8460 async fn http_entity_get_by_alias_invalid_namespace_rejected() {
8461 let state = test_state();
8462 let app = Router::new()
8463 .route(
8464 "/api/v1/entities/by_alias",
8465 axum::routing::get(entity_get_by_alias),
8466 )
8467 .with_state(state);
8468 let resp = app
8469 .oneshot(
8470 axum::http::Request::builder()
8471 .uri("/api/v1/entities/by_alias?alias=acme&namespace=BAD%20NS!")
8472 .body(Body::empty())
8473 .unwrap(),
8474 )
8475 .await
8476 .unwrap();
8477 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8478 }
8479
8480 #[tokio::test]
8481 async fn http_entity_get_by_alias_returns_found_false_when_unknown() {
8482 let state = test_state();
8483 let app = Router::new()
8484 .route(
8485 "/api/v1/entities/by_alias",
8486 axum::routing::get(entity_get_by_alias),
8487 )
8488 .with_state(state);
8489 let resp = app
8490 .oneshot(
8491 axum::http::Request::builder()
8492 .uri("/api/v1/entities/by_alias?alias=nonexistent")
8493 .body(Body::empty())
8494 .unwrap(),
8495 )
8496 .await
8497 .unwrap();
8498 assert_eq!(resp.status(), StatusCode::OK);
8499 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8500 .await
8501 .unwrap();
8502 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8503 assert_eq!(v["found"], serde_json::json!(false));
8504 }
8505
8506 #[tokio::test]
8507 async fn http_entity_get_by_alias_returns_found_true_after_register() {
8508 let state = test_state();
8510 {
8511 let lock = state.lock().await;
8512 db::entity_register(
8513 &lock.0,
8514 "Acme Corp",
8515 "kg-lookup",
8516 &["acme".to_string(), "ACME".to_string()],
8517 &serde_json::json!({}),
8518 Some("alice"),
8519 )
8520 .unwrap();
8521 }
8522 let app = Router::new()
8523 .route(
8524 "/api/v1/entities/by_alias",
8525 axum::routing::get(entity_get_by_alias),
8526 )
8527 .with_state(state);
8528 let resp = app
8529 .oneshot(
8530 axum::http::Request::builder()
8531 .uri("/api/v1/entities/by_alias?alias=acme&namespace=kg-lookup")
8532 .body(Body::empty())
8533 .unwrap(),
8534 )
8535 .await
8536 .unwrap();
8537 assert_eq!(resp.status(), StatusCode::OK);
8538 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8539 .await
8540 .unwrap();
8541 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8542 assert_eq!(v["found"], serde_json::json!(true));
8543 assert_eq!(v["canonical_name"], serde_json::json!("Acme Corp"));
8544 }
8545
8546 #[tokio::test]
8549 async fn http_kg_timeline_rejects_invalid_source_id() {
8550 let state = test_state();
8551 let app = Router::new()
8552 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8553 .with_state(state);
8554 let resp = app
8556 .oneshot(
8557 axum::http::Request::builder()
8558 .uri("/api/v1/kg/timeline?source_id=")
8559 .body(Body::empty())
8560 .unwrap(),
8561 )
8562 .await
8563 .unwrap();
8564 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8565 }
8566
8567 #[tokio::test]
8568 async fn http_kg_timeline_rejects_invalid_since() {
8569 let state = test_state();
8570 let app = Router::new()
8571 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8572 .with_state(state);
8573 let id = Uuid::new_v4().to_string();
8574 let uri = format!("/api/v1/kg/timeline?source_id={id}&since=NOT-A-TIMESTAMP");
8575 let resp = app
8576 .oneshot(
8577 axum::http::Request::builder()
8578 .uri(&uri)
8579 .body(Body::empty())
8580 .unwrap(),
8581 )
8582 .await
8583 .unwrap();
8584 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8585 }
8586
8587 #[tokio::test]
8588 async fn http_kg_timeline_rejects_invalid_until() {
8589 let state = test_state();
8590 let app = Router::new()
8591 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8592 .with_state(state);
8593 let id = Uuid::new_v4().to_string();
8594 let uri = format!("/api/v1/kg/timeline?source_id={id}&until=garbage");
8595 let resp = app
8596 .oneshot(
8597 axum::http::Request::builder()
8598 .uri(&uri)
8599 .body(Body::empty())
8600 .unwrap(),
8601 )
8602 .await
8603 .unwrap();
8604 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8605 }
8606
8607 #[tokio::test]
8608 async fn http_kg_timeline_returns_empty_for_unlinked_source() {
8609 let state = test_state();
8611 let id = {
8612 let lock = state.lock().await;
8613 let now = Utc::now().to_rfc3339();
8614 let mem = Memory {
8615 id: Uuid::new_v4().to_string(),
8616 tier: Tier::Long,
8617 namespace: "kg-tl".into(),
8618 title: "anchor".into(),
8619 content: "anchor body".into(),
8620 tags: vec![],
8621 priority: 5,
8622 confidence: 1.0,
8623 source: "test".into(),
8624 access_count: 0,
8625 created_at: now.clone(),
8626 updated_at: now,
8627 last_accessed_at: None,
8628 expires_at: None,
8629 metadata: serde_json::json!({}),
8630 };
8631 db::insert(&lock.0, &mem).unwrap()
8632 };
8633 let app = Router::new()
8634 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8635 .with_state(state);
8636 let uri = format!("/api/v1/kg/timeline?source_id={id}");
8637 let resp = app
8638 .oneshot(
8639 axum::http::Request::builder()
8640 .uri(&uri)
8641 .body(Body::empty())
8642 .unwrap(),
8643 )
8644 .await
8645 .unwrap();
8646 assert_eq!(resp.status(), StatusCode::OK);
8647 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8648 .await
8649 .unwrap();
8650 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8651 assert_eq!(v["count"], serde_json::json!(0));
8652 assert!(v["events"].is_array());
8653 }
8654
8655 #[tokio::test]
8658 async fn http_kg_invalidate_rejects_invalid_link() {
8659 let state = test_state();
8660 let app = Router::new()
8661 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8662 .with_state(state);
8663 let body = serde_json::json!({
8665 "source_id": "11111111-1111-4111-8111-111111111111",
8666 "target_id": "11111111-1111-4111-8111-111111111111",
8667 "relation": "related_to",
8668 });
8669 let resp = app
8670 .oneshot(
8671 axum::http::Request::builder()
8672 .uri("/api/v1/kg/invalidate")
8673 .method("POST")
8674 .header("content-type", "application/json")
8675 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8676 .unwrap(),
8677 )
8678 .await
8679 .unwrap();
8680 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8681 }
8682
8683 #[tokio::test]
8684 async fn http_kg_invalidate_rejects_invalid_valid_until() {
8685 let state = test_state();
8686 let app = Router::new()
8687 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8688 .with_state(state);
8689 let body = serde_json::json!({
8690 "source_id": "11111111-1111-4111-8111-111111111111",
8691 "target_id": "22222222-2222-4222-8222-222222222222",
8692 "relation": "related_to",
8693 "valid_until": "garbage",
8694 });
8695 let resp = app
8696 .oneshot(
8697 axum::http::Request::builder()
8698 .uri("/api/v1/kg/invalidate")
8699 .method("POST")
8700 .header("content-type", "application/json")
8701 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8702 .unwrap(),
8703 )
8704 .await
8705 .unwrap();
8706 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8709 }
8710
8711 #[tokio::test]
8712 async fn http_kg_invalidate_404_when_link_missing() {
8713 let state = test_state();
8714 let app = Router::new()
8715 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8716 .with_state(state);
8717 let body = serde_json::json!({
8718 "source_id": "11111111-1111-4111-8111-111111111111",
8719 "target_id": "22222222-2222-4222-8222-222222222222",
8720 "relation": "related_to",
8721 });
8722 let resp = app
8723 .oneshot(
8724 axum::http::Request::builder()
8725 .uri("/api/v1/kg/invalidate")
8726 .method("POST")
8727 .header("content-type", "application/json")
8728 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8729 .unwrap(),
8730 )
8731 .await
8732 .unwrap();
8733 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8734 }
8735
8736 #[tokio::test]
8737 async fn http_kg_invalidate_marks_link_as_invalidated() {
8738 let state = test_state();
8740 let (a_id, b_id) = {
8741 let lock = state.lock().await;
8742 let now = Utc::now().to_rfc3339();
8743 let mk = |title: &str| Memory {
8744 id: Uuid::new_v4().to_string(),
8745 tier: Tier::Long,
8746 namespace: "kg-inv".into(),
8747 title: title.into(),
8748 content: format!("{title} body"),
8749 tags: vec![],
8750 priority: 5,
8751 confidence: 1.0,
8752 source: "test".into(),
8753 access_count: 0,
8754 created_at: now.clone(),
8755 updated_at: now.clone(),
8756 last_accessed_at: None,
8757 expires_at: None,
8758 metadata: serde_json::json!({}),
8759 };
8760 let a = db::insert(&lock.0, &mk("source-a")).unwrap();
8761 let b = db::insert(&lock.0, &mk("target-b")).unwrap();
8762 db::create_link(&lock.0, &a, &b, "related_to").unwrap();
8763 (a, b)
8764 };
8765 let app = Router::new()
8766 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8767 .with_state(state);
8768 let body = serde_json::json!({
8769 "source_id": a_id,
8770 "target_id": b_id,
8771 "relation": "related_to",
8772 });
8773 let resp = app
8774 .oneshot(
8775 axum::http::Request::builder()
8776 .uri("/api/v1/kg/invalidate")
8777 .method("POST")
8778 .header("content-type", "application/json")
8779 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8780 .unwrap(),
8781 )
8782 .await
8783 .unwrap();
8784 assert_eq!(resp.status(), StatusCode::OK);
8785 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8786 .await
8787 .unwrap();
8788 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8789 assert_eq!(v["found"], serde_json::json!(true));
8790 }
8791
8792 #[tokio::test]
8795 async fn http_kg_query_rejects_invalid_source_id() {
8796 let state = test_state();
8797 let app = Router::new()
8798 .route("/api/v1/kg/query", axum_post(kg_query))
8799 .with_state(state);
8800 let body = serde_json::json!({"source_id": ""});
8802 let resp = app
8803 .oneshot(
8804 axum::http::Request::builder()
8805 .uri("/api/v1/kg/query")
8806 .method("POST")
8807 .header("content-type", "application/json")
8808 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8809 .unwrap(),
8810 )
8811 .await
8812 .unwrap();
8813 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8814 }
8815
8816 #[tokio::test]
8817 async fn http_kg_query_rejects_invalid_valid_at() {
8818 let state = test_state();
8819 let app = Router::new()
8820 .route("/api/v1/kg/query", axum_post(kg_query))
8821 .with_state(state);
8822 let body = serde_json::json!({
8823 "source_id": "11111111-1111-4111-8111-111111111111",
8824 "valid_at": "not-a-timestamp",
8825 });
8826 let resp = app
8827 .oneshot(
8828 axum::http::Request::builder()
8829 .uri("/api/v1/kg/query")
8830 .method("POST")
8831 .header("content-type", "application/json")
8832 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8833 .unwrap(),
8834 )
8835 .await
8836 .unwrap();
8837 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8838 }
8839
8840 #[tokio::test]
8841 async fn http_kg_query_rejects_invalid_allowed_agent() {
8842 let state = test_state();
8843 let app = Router::new()
8844 .route("/api/v1/kg/query", axum_post(kg_query))
8845 .with_state(state);
8846 let body = serde_json::json!({
8847 "source_id": "11111111-1111-4111-8111-111111111111",
8848 "allowed_agents": ["BAD AGENT!"],
8849 });
8850 let resp = app
8851 .oneshot(
8852 axum::http::Request::builder()
8853 .uri("/api/v1/kg/query")
8854 .method("POST")
8855 .header("content-type", "application/json")
8856 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8857 .unwrap(),
8858 )
8859 .await
8860 .unwrap();
8861 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8862 }
8863
8864 #[tokio::test]
8865 async fn http_kg_query_returns_422_for_oversized_max_depth() {
8866 let state = test_state();
8869 let app = Router::new()
8870 .route("/api/v1/kg/query", axum_post(kg_query))
8871 .with_state(state);
8872 let body = serde_json::json!({
8873 "source_id": "11111111-1111-4111-8111-111111111111",
8874 "max_depth": 999_usize,
8875 });
8876 let resp = app
8877 .oneshot(
8878 axum::http::Request::builder()
8879 .uri("/api/v1/kg/query")
8880 .method("POST")
8881 .header("content-type", "application/json")
8882 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8883 .unwrap(),
8884 )
8885 .await
8886 .unwrap();
8887 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
8888 }
8889
8890 #[tokio::test]
8891 async fn http_kg_query_returns_422_for_zero_max_depth() {
8892 let state = test_state();
8895 let app = Router::new()
8896 .route("/api/v1/kg/query", axum_post(kg_query))
8897 .with_state(state);
8898 let body = serde_json::json!({
8899 "source_id": "11111111-1111-4111-8111-111111111111",
8900 "max_depth": 0_usize,
8901 });
8902 let resp = app
8903 .oneshot(
8904 axum::http::Request::builder()
8905 .uri("/api/v1/kg/query")
8906 .method("POST")
8907 .header("content-type", "application/json")
8908 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8909 .unwrap(),
8910 )
8911 .await
8912 .unwrap();
8913 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
8914 }
8915
8916 #[tokio::test]
8917 async fn http_kg_query_returns_empty_for_unlinked_source() {
8918 let state = test_state();
8920 let id = {
8921 let lock = state.lock().await;
8922 let now = Utc::now().to_rfc3339();
8923 let mem = Memory {
8924 id: Uuid::new_v4().to_string(),
8925 tier: Tier::Long,
8926 namespace: "kg-q".into(),
8927 title: "anchor".into(),
8928 content: "anchor body".into(),
8929 tags: vec![],
8930 priority: 5,
8931 confidence: 1.0,
8932 source: "test".into(),
8933 access_count: 0,
8934 created_at: now.clone(),
8935 updated_at: now,
8936 last_accessed_at: None,
8937 expires_at: None,
8938 metadata: serde_json::json!({}),
8939 };
8940 db::insert(&lock.0, &mem).unwrap()
8941 };
8942 let app = Router::new()
8943 .route("/api/v1/kg/query", axum_post(kg_query))
8944 .with_state(state);
8945 let body = serde_json::json!({
8946 "source_id": id,
8947 "max_depth": 1_usize,
8948 });
8949 let resp = app
8950 .oneshot(
8951 axum::http::Request::builder()
8952 .uri("/api/v1/kg/query")
8953 .method("POST")
8954 .header("content-type", "application/json")
8955 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8956 .unwrap(),
8957 )
8958 .await
8959 .unwrap();
8960 assert_eq!(resp.status(), StatusCode::OK);
8961 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8962 .await
8963 .unwrap();
8964 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8965 assert_eq!(v["count"], serde_json::json!(0));
8966 assert_eq!(v["max_depth"], serde_json::json!(1));
8967 }
8968
8969 #[tokio::test]
8970 async fn http_kg_query_short_circuits_empty_allowed_agents() {
8971 let state = test_state();
8973 let app = Router::new()
8974 .route("/api/v1/kg/query", axum_post(kg_query))
8975 .with_state(state);
8976 let body = serde_json::json!({
8977 "source_id": "11111111-1111-4111-8111-111111111111",
8978 "allowed_agents": [],
8979 });
8980 let resp = app
8981 .oneshot(
8982 axum::http::Request::builder()
8983 .uri("/api/v1/kg/query")
8984 .method("POST")
8985 .header("content-type", "application/json")
8986 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8987 .unwrap(),
8988 )
8989 .await
8990 .unwrap();
8991 assert_eq!(resp.status(), StatusCode::OK);
8992 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8993 .await
8994 .unwrap();
8995 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8996 assert_eq!(v["count"], serde_json::json!(0));
8997 }
8998
8999 #[tokio::test]
9002 async fn http_delete_link_rejects_self_link() {
9003 let state = test_state();
9005 let app = Router::new()
9006 .route("/api/v1/links", axum::routing::delete(delete_link))
9007 .with_state(test_app_state(state));
9008 let body = serde_json::json!({
9009 "source_id": "11111111-1111-4111-8111-111111111111",
9010 "target_id": "11111111-1111-4111-8111-111111111111",
9011 "relation": "related_to",
9012 });
9013 let resp = app
9014 .oneshot(
9015 axum::http::Request::builder()
9016 .uri("/api/v1/links")
9017 .method("DELETE")
9018 .header("content-type", "application/json")
9019 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9020 .unwrap(),
9021 )
9022 .await
9023 .unwrap();
9024 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9025 }
9026
9027 #[tokio::test]
9028 async fn http_delete_link_returns_deleted_false_when_missing() {
9029 let state = test_state();
9030 let app = Router::new()
9031 .route("/api/v1/links", axum::routing::delete(delete_link))
9032 .with_state(test_app_state(state));
9033 let body = serde_json::json!({
9034 "source_id": "11111111-1111-4111-8111-111111111111",
9035 "target_id": "22222222-2222-4222-8222-222222222222",
9036 "relation": "related_to",
9037 });
9038 let resp = app
9039 .oneshot(
9040 axum::http::Request::builder()
9041 .uri("/api/v1/links")
9042 .method("DELETE")
9043 .header("content-type", "application/json")
9044 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9045 .unwrap(),
9046 )
9047 .await
9048 .unwrap();
9049 assert_eq!(resp.status(), StatusCode::OK);
9050 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9051 .await
9052 .unwrap();
9053 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9054 assert_eq!(v["deleted"], serde_json::json!(false));
9055 }
9056
9057 #[tokio::test]
9058 async fn http_get_links_for_unknown_id_returns_empty_array() {
9059 let state = test_state();
9063 let app = Router::new()
9064 .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9065 .with_state(state);
9066 let resp = app
9067 .oneshot(
9068 axum::http::Request::builder()
9069 .uri("/api/v1/memories/nonexistent-id/links")
9070 .body(Body::empty())
9071 .unwrap(),
9072 )
9073 .await
9074 .unwrap();
9075 assert_eq!(resp.status(), StatusCode::OK);
9076 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9077 .await
9078 .unwrap();
9079 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9080 assert!(v["links"].is_array());
9081 assert_eq!(v["links"].as_array().unwrap().len(), 0);
9082 }
9083
9084 #[tokio::test]
9085 async fn http_get_links_returns_empty_array_for_unlinked_id() {
9086 let state = test_state();
9087 let id = {
9088 let lock = state.lock().await;
9089 let now = Utc::now().to_rfc3339();
9090 let mem = Memory {
9091 id: Uuid::new_v4().to_string(),
9092 tier: Tier::Long,
9093 namespace: "links-test".into(),
9094 title: "anchor".into(),
9095 content: "no links yet".into(),
9096 tags: vec![],
9097 priority: 5,
9098 confidence: 1.0,
9099 source: "test".into(),
9100 access_count: 0,
9101 created_at: now.clone(),
9102 updated_at: now,
9103 last_accessed_at: None,
9104 expires_at: None,
9105 metadata: serde_json::json!({}),
9106 };
9107 db::insert(&lock.0, &mem).unwrap()
9108 };
9109 let app = Router::new()
9110 .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9111 .with_state(state);
9112 let resp = app
9113 .oneshot(
9114 axum::http::Request::builder()
9115 .uri(format!("/api/v1/memories/{id}/links"))
9116 .body(Body::empty())
9117 .unwrap(),
9118 )
9119 .await
9120 .unwrap();
9121 assert_eq!(resp.status(), StatusCode::OK);
9122 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9123 .await
9124 .unwrap();
9125 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9126 assert!(v["links"].is_array());
9127 assert_eq!(v["links"].as_array().unwrap().len(), 0);
9128 }
9129
9130 #[tokio::test]
9131 async fn http_list_namespaces_returns_empty_for_fresh_db() {
9132 let state = test_state();
9133 let app = Router::new()
9134 .route("/api/v1/namespaces", axum::routing::get(list_namespaces))
9135 .with_state(state);
9136 let resp = app
9137 .oneshot(
9138 axum::http::Request::builder()
9139 .uri("/api/v1/namespaces")
9140 .body(Body::empty())
9141 .unwrap(),
9142 )
9143 .await
9144 .unwrap();
9145 assert_eq!(resp.status(), StatusCode::OK);
9146 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9147 .await
9148 .unwrap();
9149 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9150 assert!(v["namespaces"].is_array());
9151 }
9152
9153 #[tokio::test]
9154 async fn http_forget_memories_with_namespace_filter_returns_count() {
9155 let state = test_state();
9157 {
9158 let lock = state.lock().await;
9159 let now = Utc::now().to_rfc3339();
9160 for i in 0..3 {
9161 let mem = Memory {
9162 id: Uuid::new_v4().to_string(),
9163 tier: Tier::Long,
9164 namespace: "forget-target".into(),
9165 title: format!("row-{i}"),
9166 content: format!("content {i}"),
9167 tags: vec![],
9168 priority: 5,
9169 confidence: 1.0,
9170 source: "test".into(),
9171 access_count: 0,
9172 created_at: now.clone(),
9173 updated_at: now.clone(),
9174 last_accessed_at: None,
9175 expires_at: None,
9176 metadata: serde_json::json!({}),
9177 };
9178 db::insert(&lock.0, &mem).unwrap();
9179 }
9180 }
9181 let app = Router::new()
9182 .route("/api/v1/forget", axum_post(forget_memories))
9183 .with_state(state);
9184 let body = serde_json::json!({"namespace": "forget-target"});
9185 let resp = app
9186 .oneshot(
9187 axum::http::Request::builder()
9188 .uri("/api/v1/forget")
9189 .method("POST")
9190 .header("content-type", "application/json")
9191 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9192 .unwrap(),
9193 )
9194 .await
9195 .unwrap();
9196 assert_eq!(resp.status(), StatusCode::OK);
9197 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9198 .await
9199 .unwrap();
9200 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9201 assert!(v["deleted"].as_u64().is_some());
9203 }
9204
9205 #[tokio::test]
9208 async fn http_archive_stats_empty_db_returns_zero() {
9209 let state = test_state();
9210 let app = Router::new()
9211 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
9212 .with_state(state);
9213 let resp = app
9214 .oneshot(
9215 axum::http::Request::builder()
9216 .uri("/api/v1/archive/stats")
9217 .body(Body::empty())
9218 .unwrap(),
9219 )
9220 .await
9221 .unwrap();
9222 assert_eq!(resp.status(), StatusCode::OK);
9223 }
9224
9225 #[tokio::test]
9226 async fn http_purge_archive_returns_zero_for_empty_archive() {
9227 let state = test_state();
9228 let app = Router::new()
9229 .route("/api/v1/archive/purge", axum_post(purge_archive))
9230 .with_state(state);
9231 let resp = app
9232 .oneshot(
9233 axum::http::Request::builder()
9234 .uri("/api/v1/archive/purge")
9235 .method("POST")
9236 .body(Body::empty())
9237 .unwrap(),
9238 )
9239 .await
9240 .unwrap();
9241 assert_eq!(resp.status(), StatusCode::OK);
9242 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9243 .await
9244 .unwrap();
9245 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9246 assert_eq!(v["purged"], serde_json::json!(0));
9247 }
9248
9249 #[tokio::test]
9252 async fn http_run_gc_returns_zero_for_clean_db() {
9253 let state = test_state();
9254 let app = Router::new()
9255 .route("/api/v1/gc", axum_post(run_gc))
9256 .with_state(state);
9257 let resp = app
9258 .oneshot(
9259 axum::http::Request::builder()
9260 .uri("/api/v1/gc")
9261 .method("POST")
9262 .body(Body::empty())
9263 .unwrap(),
9264 )
9265 .await
9266 .unwrap();
9267 assert_eq!(resp.status(), StatusCode::OK);
9268 }
9269
9270 #[tokio::test]
9271 async fn http_export_memories_empty_returns_zero_count() {
9272 let state = test_state();
9273 let app = Router::new()
9274 .route("/api/v1/export", axum::routing::get(export_memories))
9275 .with_state(state);
9276 let resp = app
9277 .oneshot(
9278 axum::http::Request::builder()
9279 .uri("/api/v1/export")
9280 .body(Body::empty())
9281 .unwrap(),
9282 )
9283 .await
9284 .unwrap();
9285 assert_eq!(resp.status(), StatusCode::OK);
9286 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9287 .await
9288 .unwrap();
9289 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9290 assert_eq!(v["count"], serde_json::json!(0));
9291 }
9292
9293 #[tokio::test]
9294 async fn http_import_memories_oversized_batch_rejected() {
9295 let state = test_state();
9296 let app = Router::new()
9297 .route("/api/v1/import", axum_post(import_memories))
9298 .with_state(state);
9299 let many: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
9302 .map(|i| {
9303 serde_json::json!({
9304 "id": format!("11111111-1111-4111-8111-{:012}", i),
9305 "tier": "long",
9306 "namespace": "imp",
9307 "title": format!("t-{i}"),
9308 "content": "x",
9309 "tags": [],
9310 "priority": 5,
9311 "confidence": 1.0,
9312 "source": "import",
9313 "access_count": 0,
9314 "created_at": "2026-01-01T00:00:00Z",
9315 "updated_at": "2026-01-01T00:00:00Z",
9316 "last_accessed_at": null,
9317 "expires_at": null,
9318 "metadata": {},
9319 })
9320 })
9321 .collect();
9322 let body = serde_json::json!({"memories": many});
9323 let resp = app
9324 .oneshot(
9325 axum::http::Request::builder()
9326 .uri("/api/v1/import")
9327 .method("POST")
9328 .header("content-type", "application/json")
9329 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9330 .unwrap(),
9331 )
9332 .await
9333 .unwrap();
9334 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9335 }
9336
9337 #[tokio::test]
9338 async fn http_import_memories_skips_invalid_rows() {
9339 let state = test_state();
9341 let app = Router::new()
9342 .route("/api/v1/import", axum_post(import_memories))
9343 .with_state(state);
9344 let valid = serde_json::json!({
9345 "id": Uuid::new_v4().to_string(),
9346 "tier": "long",
9347 "namespace": "imp",
9348 "title": "ok-row",
9349 "content": "valid content",
9350 "tags": [],
9351 "priority": 5,
9352 "confidence": 1.0,
9353 "source": "import",
9354 "access_count": 0,
9355 "created_at": "2026-01-01T00:00:00Z",
9356 "updated_at": "2026-01-01T00:00:00Z",
9357 "last_accessed_at": null,
9358 "expires_at": null,
9359 "metadata": {},
9360 });
9361 let invalid = serde_json::json!({
9363 "id": Uuid::new_v4().to_string(),
9364 "tier": "long",
9365 "namespace": "imp",
9366 "title": "",
9367 "content": "x",
9368 "tags": [],
9369 "priority": 5,
9370 "confidence": 1.0,
9371 "source": "import",
9372 "access_count": 0,
9373 "created_at": "2026-01-01T00:00:00Z",
9374 "updated_at": "2026-01-01T00:00:00Z",
9375 "last_accessed_at": null,
9376 "expires_at": null,
9377 "metadata": {},
9378 });
9379 let body = serde_json::json!({"memories": [valid, invalid]});
9380 let resp = app
9381 .oneshot(
9382 axum::http::Request::builder()
9383 .uri("/api/v1/import")
9384 .method("POST")
9385 .header("content-type", "application/json")
9386 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9387 .unwrap(),
9388 )
9389 .await
9390 .unwrap();
9391 assert_eq!(resp.status(), StatusCode::OK);
9392 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9393 .await
9394 .unwrap();
9395 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9396 assert_eq!(v["imported"], serde_json::json!(1));
9398 assert!(v["errors"].as_array().unwrap().len() >= 1);
9399 }
9400
9401 #[tokio::test]
9404 async fn http_get_stats_empty_db() {
9405 let state = test_state();
9406 let app = Router::new()
9407 .route("/api/v1/stats", axum::routing::get(get_stats))
9408 .with_state(state);
9409 let resp = app
9410 .oneshot(
9411 axum::http::Request::builder()
9412 .uri("/api/v1/stats")
9413 .body(Body::empty())
9414 .unwrap(),
9415 )
9416 .await
9417 .unwrap();
9418 assert_eq!(resp.status(), StatusCode::OK);
9419 }
9420
9421 #[tokio::test]
9422 async fn http_sync_push_namespace_meta_clears_garbage_skipped() {
9423 let state = test_state();
9426 let app = Router::new()
9427 .route("/api/v1/sync/push", axum_post(sync_push))
9428 .with_state(test_app_state(state));
9429 let body = serde_json::json!({
9430 "sender_agent_id": "peer-x",
9431 "memories": [],
9432 "namespace_meta_clears": ["BAD NAMESPACE!"],
9433 });
9434 let resp = app
9435 .oneshot(
9436 axum::http::Request::builder()
9437 .uri("/api/v1/sync/push")
9438 .method("POST")
9439 .header("content-type", "application/json")
9440 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9441 .unwrap(),
9442 )
9443 .await
9444 .unwrap();
9445 assert_eq!(resp.status(), StatusCode::OK);
9446 }
9447
9448 #[tokio::test]
9449 async fn http_sync_push_pending_decision_invalid_id_skipped() {
9450 let state = test_state();
9452 let app = Router::new()
9453 .route("/api/v1/sync/push", axum_post(sync_push))
9454 .with_state(test_app_state(state));
9455 let body = serde_json::json!({
9456 "sender_agent_id": "peer-x",
9457 "memories": [],
9458 "pending_decisions": [
9459 {"id": "BAD ID!", "approved": true, "decider": "alice"}
9460 ],
9461 });
9462 let resp = app
9463 .oneshot(
9464 axum::http::Request::builder()
9465 .uri("/api/v1/sync/push")
9466 .method("POST")
9467 .header("content-type", "application/json")
9468 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9469 .unwrap(),
9470 )
9471 .await
9472 .unwrap();
9473 assert_eq!(resp.status(), StatusCode::OK);
9474 }
9475
9476 #[tokio::test]
9477 async fn http_sync_push_namespace_meta_invalid_skipped() {
9478 let state = test_state();
9481 let app = Router::new()
9482 .route("/api/v1/sync/push", axum_post(sync_push))
9483 .with_state(test_app_state(state));
9484 let body = serde_json::json!({
9485 "sender_agent_id": "peer-x",
9486 "memories": [],
9487 "namespace_meta": [
9488 {"namespace": "BAD NS!", "standard_id": "11111111-1111-4111-8111-111111111111", "parent_namespace": null}
9489 ],
9490 });
9491 let resp = app
9492 .oneshot(
9493 axum::http::Request::builder()
9494 .uri("/api/v1/sync/push")
9495 .method("POST")
9496 .header("content-type", "application/json")
9497 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9498 .unwrap(),
9499 )
9500 .await
9501 .unwrap();
9502 assert_eq!(resp.status(), StatusCode::OK);
9503 }
9504
9505 #[tokio::test]
9506 async fn http_sync_push_dry_run_namespace_meta_no_apply() {
9507 let state = test_state();
9509 let app = Router::new()
9510 .route("/api/v1/sync/push", axum_post(sync_push))
9511 .with_state(test_app_state(state.clone()));
9512 let body = serde_json::json!({
9513 "sender_agent_id": "peer-x",
9514 "memories": [],
9515 "dry_run": true,
9516 "namespace_meta_clears": ["preview-ns"],
9517 "pending_decisions": [
9518 {"id": "11111111-1111-4111-8111-111111111111", "approved": true, "decider": "alice"}
9519 ],
9520 });
9521 let resp = app
9522 .oneshot(
9523 axum::http::Request::builder()
9524 .uri("/api/v1/sync/push")
9525 .method("POST")
9526 .header("content-type", "application/json")
9527 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9528 .unwrap(),
9529 )
9530 .await
9531 .unwrap();
9532 assert_eq!(resp.status(), StatusCode::OK);
9533 }
9534
9535 #[tokio::test]
9546 async fn http_list_archive_empty_returns_empty_array() {
9547 let state = test_state();
9549 let app = Router::new()
9550 .route("/api/v1/archive", axum::routing::get(list_archive))
9551 .with_state(state);
9552 let resp = app
9553 .oneshot(
9554 axum::http::Request::builder()
9555 .uri("/api/v1/archive")
9556 .body(Body::empty())
9557 .unwrap(),
9558 )
9559 .await
9560 .unwrap();
9561 assert_eq!(resp.status(), StatusCode::OK);
9562 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9563 .await
9564 .unwrap();
9565 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9566 assert_eq!(v["count"], 0);
9567 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
9568 }
9569
9570 #[tokio::test]
9571 async fn http_list_archive_with_items_returns_them() {
9572 let state = test_state();
9574 let id_a = insert_test_memory(&state, "h8a-list-items", "row-a").await;
9575 let id_b = insert_test_memory(&state, "h8a-list-items", "row-b").await;
9576 {
9577 let lock = state.lock().await;
9578 db::archive_memory(&lock.0, &id_a, Some("test")).unwrap();
9579 db::archive_memory(&lock.0, &id_b, Some("test")).unwrap();
9580 }
9581 let app = Router::new()
9582 .route("/api/v1/archive", axum::routing::get(list_archive))
9583 .with_state(state);
9584 let resp = app
9585 .oneshot(
9586 axum::http::Request::builder()
9587 .uri("/api/v1/archive?limit=10")
9588 .body(Body::empty())
9589 .unwrap(),
9590 )
9591 .await
9592 .unwrap();
9593 assert_eq!(resp.status(), StatusCode::OK);
9594 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9595 .await
9596 .unwrap();
9597 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9598 assert_eq!(v["count"], 2);
9599 }
9600
9601 #[tokio::test]
9602 async fn http_list_archive_pagination_offset_skips() {
9603 let state = test_state();
9606 let id1 = insert_test_memory(&state, "h8a-page", "row-1").await;
9607 let id2 = insert_test_memory(&state, "h8a-page", "row-2").await;
9608 let id3 = insert_test_memory(&state, "h8a-page", "row-3").await;
9609 {
9610 let lock = state.lock().await;
9611 db::archive_memory(&lock.0, &id1, Some("p")).unwrap();
9612 db::archive_memory(&lock.0, &id2, Some("p")).unwrap();
9613 db::archive_memory(&lock.0, &id3, Some("p")).unwrap();
9614 }
9615 let app = Router::new()
9616 .route("/api/v1/archive", axum::routing::get(list_archive))
9617 .with_state(state);
9618 let resp = app
9619 .oneshot(
9620 axum::http::Request::builder()
9621 .uri("/api/v1/archive?limit=1&offset=1")
9622 .body(Body::empty())
9623 .unwrap(),
9624 )
9625 .await
9626 .unwrap();
9627 assert_eq!(resp.status(), StatusCode::OK);
9628 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9629 .await
9630 .unwrap();
9631 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9632 assert_eq!(v["count"], 1);
9633 }
9634
9635 #[tokio::test]
9636 async fn http_list_archive_namespace_filter_excludes_others() {
9637 let state = test_state();
9640 let id_a = insert_test_memory(&state, "h8a-ns-a", "row-a").await;
9641 let id_b = insert_test_memory(&state, "h8a-ns-b", "row-b").await;
9642 {
9643 let lock = state.lock().await;
9644 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9645 db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
9646 }
9647 let app = Router::new()
9648 .route("/api/v1/archive", axum::routing::get(list_archive))
9649 .with_state(state);
9650 let resp = app
9651 .oneshot(
9652 axum::http::Request::builder()
9653 .uri("/api/v1/archive?namespace=h8a-ns-a&limit=10")
9654 .body(Body::empty())
9655 .unwrap(),
9656 )
9657 .await
9658 .unwrap();
9659 assert_eq!(resp.status(), StatusCode::OK);
9660 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9661 .await
9662 .unwrap();
9663 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9664 assert_eq!(v["count"], 1);
9665 let entries = v["archived"].as_array().unwrap();
9666 assert_eq!(entries[0]["namespace"], "h8a-ns-a");
9667 }
9668
9669 #[tokio::test]
9670 async fn http_list_archive_namespace_filter_unknown_returns_empty() {
9671 let state = test_state();
9674 let id_a = insert_test_memory(&state, "h8a-ns-known", "row-a").await;
9675 {
9676 let lock = state.lock().await;
9677 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9678 }
9679 let app = Router::new()
9680 .route("/api/v1/archive", axum::routing::get(list_archive))
9681 .with_state(state);
9682 let resp = app
9683 .oneshot(
9684 axum::http::Request::builder()
9685 .uri("/api/v1/archive?namespace=h8a-no-such-ns")
9686 .body(Body::empty())
9687 .unwrap(),
9688 )
9689 .await
9690 .unwrap();
9691 assert_eq!(resp.status(), StatusCode::OK);
9692 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9693 .await
9694 .unwrap();
9695 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9696 assert_eq!(v["count"], 0);
9697 }
9698
9699 #[tokio::test]
9702 async fn http_archive_by_ids_single_id_success() {
9703 let state = test_state();
9705 let id = insert_test_memory(&state, "h8a-aby-single", "row").await;
9706 let app = Router::new()
9707 .route("/api/v1/archive", axum_post(archive_by_ids))
9708 .with_state(test_app_state(state.clone()));
9709 let body = serde_json::json!({"ids": [id], "reason": "h8a-single"});
9710 let resp = app
9711 .oneshot(
9712 axum::http::Request::builder()
9713 .uri("/api/v1/archive")
9714 .method("POST")
9715 .header("content-type", "application/json")
9716 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9717 .unwrap(),
9718 )
9719 .await
9720 .unwrap();
9721 assert_eq!(resp.status(), StatusCode::OK);
9722 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9723 .await
9724 .unwrap();
9725 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9726 assert_eq!(v["count"], 1);
9727 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9728 assert_eq!(v["reason"], "h8a-single");
9729 }
9730
9731 #[tokio::test]
9732 async fn http_archive_by_ids_bulk_success() {
9733 let state = test_state();
9735 let id1 = insert_test_memory(&state, "h8a-bulk", "row-1").await;
9736 let id2 = insert_test_memory(&state, "h8a-bulk", "row-2").await;
9737 let id3 = insert_test_memory(&state, "h8a-bulk", "row-3").await;
9738 let app = Router::new()
9739 .route("/api/v1/archive", axum_post(archive_by_ids))
9740 .with_state(test_app_state(state.clone()));
9741 let body = serde_json::json!({"ids": [id1, id2, id3]});
9742 let resp = app
9743 .oneshot(
9744 axum::http::Request::builder()
9745 .uri("/api/v1/archive")
9746 .method("POST")
9747 .header("content-type", "application/json")
9748 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9749 .unwrap(),
9750 )
9751 .await
9752 .unwrap();
9753 assert_eq!(resp.status(), StatusCode::OK);
9754 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9755 .await
9756 .unwrap();
9757 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9758 assert_eq!(v["count"], 3);
9759 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9760 }
9761
9762 #[tokio::test]
9763 async fn http_archive_by_ids_empty_array_returns_ok_zero_count() {
9764 let state = test_state();
9767 let app = Router::new()
9768 .route("/api/v1/archive", axum_post(archive_by_ids))
9769 .with_state(test_app_state(state.clone()));
9770 let body = serde_json::json!({"ids": []});
9771 let resp = app
9772 .oneshot(
9773 axum::http::Request::builder()
9774 .uri("/api/v1/archive")
9775 .method("POST")
9776 .header("content-type", "application/json")
9777 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9778 .unwrap(),
9779 )
9780 .await
9781 .unwrap();
9782 assert_eq!(resp.status(), StatusCode::OK);
9783 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9784 .await
9785 .unwrap();
9786 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9787 assert_eq!(v["count"], 0);
9788 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
9789 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9790 }
9791
9792 #[tokio::test]
9793 async fn http_archive_by_ids_missing_ids_field_returns_400() {
9794 let state = test_state();
9797 let app = Router::new()
9798 .route("/api/v1/archive", axum_post(archive_by_ids))
9799 .with_state(test_app_state(state));
9800 let body = serde_json::json!({"reason": "no-ids-field"});
9801 let resp = app
9802 .oneshot(
9803 axum::http::Request::builder()
9804 .uri("/api/v1/archive")
9805 .method("POST")
9806 .header("content-type", "application/json")
9807 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9808 .unwrap(),
9809 )
9810 .await
9811 .unwrap();
9812 assert!(resp.status().is_client_error());
9813 }
9814
9815 #[tokio::test]
9816 async fn http_archive_by_ids_malformed_json_returns_400() {
9817 let state = test_state();
9819 let app = Router::new()
9820 .route("/api/v1/archive", axum_post(archive_by_ids))
9821 .with_state(test_app_state(state));
9822 let resp = app
9823 .oneshot(
9824 axum::http::Request::builder()
9825 .uri("/api/v1/archive")
9826 .method("POST")
9827 .header("content-type", "application/json")
9828 .body(Body::from("not-valid-json{{"))
9829 .unwrap(),
9830 )
9831 .await
9832 .unwrap();
9833 assert!(resp.status().is_client_error());
9834 }
9835
9836 #[tokio::test]
9839 async fn http_purge_archive_older_than_keeps_recent() {
9840 let state = test_state();
9843 let id = insert_test_memory(&state, "h8a-purge-recent", "row").await;
9844 {
9845 let lock = state.lock().await;
9846 db::archive_memory(&lock.0, &id, Some("recent")).unwrap();
9847 }
9848 let app = Router::new()
9849 .route("/api/v1/archive", axum::routing::delete(purge_archive))
9850 .with_state(state.clone());
9851 let resp = app
9852 .oneshot(
9853 axum::http::Request::builder()
9854 .uri("/api/v1/archive?older_than_days=365")
9855 .method("DELETE")
9856 .body(Body::empty())
9857 .unwrap(),
9858 )
9859 .await
9860 .unwrap();
9861 assert_eq!(resp.status(), StatusCode::OK);
9862 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9863 .await
9864 .unwrap();
9865 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9866 assert_eq!(v["purged"], 0);
9867 let lock = state.lock().await;
9869 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
9870 assert_eq!(rows.len(), 1);
9871 }
9872
9873 #[tokio::test]
9874 async fn http_purge_archive_unfiltered_purges_everything() {
9875 let state = test_state();
9877 for i in 0..3 {
9878 let id = insert_test_memory(&state, "h8a-purge-all", &format!("row-{i}")).await;
9879 let lock = state.lock().await;
9880 db::archive_memory(&lock.0, &id, Some("all")).unwrap();
9881 }
9882 let app = Router::new()
9883 .route("/api/v1/archive", axum::routing::delete(purge_archive))
9884 .with_state(state.clone());
9885 let resp = app
9886 .oneshot(
9887 axum::http::Request::builder()
9888 .uri("/api/v1/archive")
9889 .method("DELETE")
9890 .body(Body::empty())
9891 .unwrap(),
9892 )
9893 .await
9894 .unwrap();
9895 assert_eq!(resp.status(), StatusCode::OK);
9896 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9897 .await
9898 .unwrap();
9899 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9900 assert_eq!(v["purged"], 3);
9901 let lock = state.lock().await;
9902 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
9903 assert!(rows.is_empty());
9904 }
9905
9906 #[tokio::test]
9907 async fn http_purge_archive_zero_days_purges_all_archived() {
9908 let state = test_state();
9911 let id = insert_test_memory(&state, "h8a-purge-zero", "row").await;
9912 {
9913 let lock = state.lock().await;
9914 db::archive_memory(&lock.0, &id, Some("zero")).unwrap();
9915 }
9916 let app = Router::new()
9917 .route("/api/v1/archive", axum::routing::delete(purge_archive))
9918 .with_state(state.clone());
9919 let resp = app
9920 .oneshot(
9921 axum::http::Request::builder()
9922 .uri("/api/v1/archive?older_than_days=0")
9923 .method("DELETE")
9924 .body(Body::empty())
9925 .unwrap(),
9926 )
9927 .await
9928 .unwrap();
9929 assert_eq!(resp.status(), StatusCode::OK);
9930 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9931 .await
9932 .unwrap();
9933 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9934 assert!(v["purged"].as_u64().unwrap() >= 1);
9936 }
9937
9938 #[tokio::test]
9939 async fn http_purge_archive_response_shape_has_purged_key() {
9940 let state = test_state();
9943 let app = Router::new()
9944 .route("/api/v1/archive", axum::routing::delete(purge_archive))
9945 .with_state(state);
9946 let resp = app
9947 .oneshot(
9948 axum::http::Request::builder()
9949 .uri("/api/v1/archive")
9950 .method("DELETE")
9951 .body(Body::empty())
9952 .unwrap(),
9953 )
9954 .await
9955 .unwrap();
9956 assert_eq!(resp.status(), StatusCode::OK);
9957 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9958 .await
9959 .unwrap();
9960 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9961 assert!(v.is_object());
9962 assert!(v["purged"].is_number());
9963 }
9964
9965 #[tokio::test]
9968 async fn http_restore_archive_happy_path_and_listed_in_active() {
9969 let state = test_state();
9972 let id = insert_test_memory(&state, "h8a-restore-ok", "row").await;
9973 {
9974 let lock = state.lock().await;
9975 db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
9976 }
9977 let app = Router::new()
9978 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
9979 .with_state(test_app_state(state.clone()));
9980 let resp = app
9981 .oneshot(
9982 axum::http::Request::builder()
9983 .uri(format!("/api/v1/archive/{id}/restore"))
9984 .method("POST")
9985 .body(Body::empty())
9986 .unwrap(),
9987 )
9988 .await
9989 .unwrap();
9990 assert_eq!(resp.status(), StatusCode::OK);
9991 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9992 .await
9993 .unwrap();
9994 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9995 assert_eq!(v["restored"], true);
9996 assert_eq!(v["id"], id);
9997 let lock = state.lock().await;
9999 let got = db::get(&lock.0, &id).unwrap();
10000 assert!(got.is_some());
10001 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
10002 assert!(archived.is_empty());
10003 }
10004
10005 #[tokio::test]
10006 async fn http_restore_archive_then_list_archive_excludes_restored() {
10007 let state = test_state();
10010 let id = insert_test_memory(&state, "h8a-restore-list", "row").await;
10011 {
10012 let lock = state.lock().await;
10013 db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
10014 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10016 assert_eq!(rows.len(), 1);
10017 }
10018 let restore_app = Router::new()
10019 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10020 .with_state(test_app_state(state.clone()));
10021 let resp = restore_app
10022 .oneshot(
10023 axum::http::Request::builder()
10024 .uri(format!("/api/v1/archive/{id}/restore"))
10025 .method("POST")
10026 .body(Body::empty())
10027 .unwrap(),
10028 )
10029 .await
10030 .unwrap();
10031 assert_eq!(resp.status(), StatusCode::OK);
10032
10033 let list_app = Router::new()
10034 .route("/api/v1/archive", axum::routing::get(list_archive))
10035 .with_state(state);
10036 let resp = list_app
10037 .oneshot(
10038 axum::http::Request::builder()
10039 .uri("/api/v1/archive")
10040 .body(Body::empty())
10041 .unwrap(),
10042 )
10043 .await
10044 .unwrap();
10045 assert_eq!(resp.status(), StatusCode::OK);
10046 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10047 .await
10048 .unwrap();
10049 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10050 assert_eq!(v["count"], 0);
10051 }
10052
10053 #[tokio::test]
10054 async fn http_restore_archive_preserves_namespace_and_title() {
10055 let state = test_state();
10058 let id = insert_test_memory(&state, "h8a-rest-meta", "preserve-me").await;
10059 {
10060 let lock = state.lock().await;
10061 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10062 }
10063 let app = Router::new()
10064 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10065 .with_state(test_app_state(state.clone()));
10066 let resp = app
10067 .oneshot(
10068 axum::http::Request::builder()
10069 .uri(format!("/api/v1/archive/{id}/restore"))
10070 .method("POST")
10071 .body(Body::empty())
10072 .unwrap(),
10073 )
10074 .await
10075 .unwrap();
10076 assert_eq!(resp.status(), StatusCode::OK);
10077 let lock = state.lock().await;
10078 let got = db::get(&lock.0, &id).unwrap().unwrap();
10079 assert_eq!(got.namespace, "h8a-rest-meta");
10080 assert_eq!(got.title, "preserve-me");
10081 }
10082
10083 #[tokio::test]
10084 async fn http_restore_archive_after_purge_returns_404() {
10085 let state = test_state();
10088 let id = insert_test_memory(&state, "h8a-rest-purged", "row").await;
10089 {
10090 let lock = state.lock().await;
10091 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10092 db::purge_archive(&lock.0, None).unwrap();
10094 }
10095 let app = Router::new()
10096 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10097 .with_state(test_app_state(state));
10098 let resp = app
10099 .oneshot(
10100 axum::http::Request::builder()
10101 .uri(format!("/api/v1/archive/{id}/restore"))
10102 .method("POST")
10103 .body(Body::empty())
10104 .unwrap(),
10105 )
10106 .await
10107 .unwrap();
10108 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10109 }
10110
10111 #[tokio::test]
10112 async fn http_restore_archive_oversized_id_returns_400() {
10113 let state = test_state();
10116 let app = Router::new()
10117 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10118 .with_state(test_app_state(state));
10119 let huge = "a".repeat(200);
10120 let resp = app
10121 .oneshot(
10122 axum::http::Request::builder()
10123 .uri(format!("/api/v1/archive/{huge}/restore"))
10124 .method("POST")
10125 .body(Body::empty())
10126 .unwrap(),
10127 )
10128 .await
10129 .unwrap();
10130 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10131 }
10132
10133 #[tokio::test]
10136 async fn http_archive_stats_with_data_reports_total_and_breakdown() {
10137 let state = test_state();
10140 let id_a1 = insert_test_memory(&state, "h8a-stats-a", "row-1").await;
10141 let id_a2 = insert_test_memory(&state, "h8a-stats-a", "row-2").await;
10142 let id_b1 = insert_test_memory(&state, "h8a-stats-b", "row-3").await;
10143 {
10144 let lock = state.lock().await;
10145 db::archive_memory(&lock.0, &id_a1, Some("t")).unwrap();
10146 db::archive_memory(&lock.0, &id_a2, Some("t")).unwrap();
10147 db::archive_memory(&lock.0, &id_b1, Some("t")).unwrap();
10148 }
10149 let app = Router::new()
10150 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10151 .with_state(state);
10152 let resp = app
10153 .oneshot(
10154 axum::http::Request::builder()
10155 .uri("/api/v1/archive/stats")
10156 .body(Body::empty())
10157 .unwrap(),
10158 )
10159 .await
10160 .unwrap();
10161 assert_eq!(resp.status(), StatusCode::OK);
10162 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10163 .await
10164 .unwrap();
10165 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10166 assert_eq!(v["archived_total"], 3);
10167 let by_ns = v["by_namespace"].as_array().unwrap();
10168 assert_eq!(by_ns.len(), 2);
10169 assert_eq!(by_ns[0]["count"], 2);
10171 assert_eq!(by_ns[0]["namespace"], "h8a-stats-a");
10172 }
10173
10174 #[tokio::test]
10175 async fn http_archive_stats_empty_returns_total_zero_empty_breakdown() {
10176 let state = test_state();
10178 let app = Router::new()
10179 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10180 .with_state(state);
10181 let resp = app
10182 .oneshot(
10183 axum::http::Request::builder()
10184 .uri("/api/v1/archive/stats")
10185 .body(Body::empty())
10186 .unwrap(),
10187 )
10188 .await
10189 .unwrap();
10190 assert_eq!(resp.status(), StatusCode::OK);
10191 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10192 .await
10193 .unwrap();
10194 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10195 assert_eq!(v["archived_total"], 0);
10196 assert!(v["by_namespace"].as_array().unwrap().is_empty());
10197 }
10198
10199 #[tokio::test]
10200 async fn http_archive_stats_unaffected_by_active_rows() {
10201 let state = test_state();
10204 for i in 0..5 {
10206 insert_test_memory(&state, "h8a-stats-active", &format!("row-{i}")).await;
10207 }
10208 let app = Router::new()
10209 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10210 .with_state(state);
10211 let resp = app
10212 .oneshot(
10213 axum::http::Request::builder()
10214 .uri("/api/v1/archive/stats")
10215 .body(Body::empty())
10216 .unwrap(),
10217 )
10218 .await
10219 .unwrap();
10220 assert_eq!(resp.status(), StatusCode::OK);
10221 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10222 .await
10223 .unwrap();
10224 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10225 assert_eq!(v["archived_total"], 0);
10226 }
10227
10228 #[tokio::test]
10231 async fn http_forget_memories_no_filter_returns_400() {
10232 let state = test_state();
10236 let app = Router::new()
10237 .route("/api/v1/forget", axum_post(forget_memories))
10238 .with_state(state);
10239 let body = serde_json::json!({});
10240 let resp = app
10241 .oneshot(
10242 axum::http::Request::builder()
10243 .uri("/api/v1/forget")
10244 .method("POST")
10245 .header("content-type", "application/json")
10246 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10247 .unwrap(),
10248 )
10249 .await
10250 .unwrap();
10251 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10252 }
10253
10254 #[tokio::test]
10255 async fn http_forget_memories_pattern_only_deletes_matches() {
10256 let state = test_state();
10259 {
10260 let lock = state.lock().await;
10261 let now = Utc::now().to_rfc3339();
10262 for (i, content) in ["delete-me alpha", "keep-this beta", "delete-me gamma"]
10263 .iter()
10264 .enumerate()
10265 {
10266 let mem = Memory {
10267 id: Uuid::new_v4().to_string(),
10268 tier: Tier::Long,
10269 namespace: "h8a-forget-pat".into(),
10270 title: format!("row-{i}"),
10271 content: (*content).into(),
10272 tags: vec![],
10273 priority: 5,
10274 confidence: 1.0,
10275 source: "test".into(),
10276 access_count: 0,
10277 created_at: now.clone(),
10278 updated_at: now.clone(),
10279 last_accessed_at: None,
10280 expires_at: None,
10281 metadata: serde_json::json!({}),
10282 };
10283 db::insert(&lock.0, &mem).unwrap();
10284 }
10285 }
10286 let app = Router::new()
10287 .route("/api/v1/forget", axum_post(forget_memories))
10288 .with_state(state);
10289 let body = serde_json::json!({"pattern": "delete-me"});
10290 let resp = app
10291 .oneshot(
10292 axum::http::Request::builder()
10293 .uri("/api/v1/forget")
10294 .method("POST")
10295 .header("content-type", "application/json")
10296 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10297 .unwrap(),
10298 )
10299 .await
10300 .unwrap();
10301 assert_eq!(resp.status(), StatusCode::OK);
10302 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10303 .await
10304 .unwrap();
10305 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10306 assert_eq!(v["deleted"], 2);
10308 }
10309
10310 #[tokio::test]
10311 async fn http_forget_memories_by_tier_only_targets_tier() {
10312 let state = test_state();
10314 {
10315 let lock = state.lock().await;
10316 let now = Utc::now().to_rfc3339();
10317 for (i, tier) in [Tier::Short, Tier::Short, Tier::Long].iter().enumerate() {
10318 let mem = Memory {
10319 id: Uuid::new_v4().to_string(),
10320 tier: tier.clone(),
10321 namespace: "h8a-forget-tier".into(),
10322 title: format!("row-{i}"),
10323 content: format!("content {i}"),
10324 tags: vec![],
10325 priority: 5,
10326 confidence: 1.0,
10327 source: "test".into(),
10328 access_count: 0,
10329 created_at: now.clone(),
10330 updated_at: now.clone(),
10331 last_accessed_at: None,
10332 expires_at: None,
10333 metadata: serde_json::json!({}),
10334 };
10335 db::insert(&lock.0, &mem).unwrap();
10336 }
10337 }
10338 let app = Router::new()
10339 .route("/api/v1/forget", axum_post(forget_memories))
10340 .with_state(state);
10341 let body = serde_json::json!({"tier": "short"});
10342 let resp = app
10343 .oneshot(
10344 axum::http::Request::builder()
10345 .uri("/api/v1/forget")
10346 .method("POST")
10347 .header("content-type", "application/json")
10348 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10349 .unwrap(),
10350 )
10351 .await
10352 .unwrap();
10353 assert_eq!(resp.status(), StatusCode::OK);
10354 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10355 .await
10356 .unwrap();
10357 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10358 assert_eq!(v["deleted"], 2);
10359 }
10360
10361 #[tokio::test]
10362 async fn http_forget_memories_combined_filters_intersect() {
10363 let state = test_state();
10366 {
10367 let lock = state.lock().await;
10368 let now = Utc::now().to_rfc3339();
10369 for (ns, content) in [
10372 ("h8a-forget-and", "purge alpha"),
10373 ("h8a-forget-and", "purge beta"),
10374 ("h8a-forget-and", "keep gamma"),
10375 ("h8a-forget-other", "purge delta"),
10376 ] {
10377 let mem = Memory {
10378 id: Uuid::new_v4().to_string(),
10379 tier: Tier::Long,
10380 namespace: ns.into(),
10381 title: format!("row-{content}"),
10382 content: content.into(),
10383 tags: vec![],
10384 priority: 5,
10385 confidence: 1.0,
10386 source: "test".into(),
10387 access_count: 0,
10388 created_at: now.clone(),
10389 updated_at: now.clone(),
10390 last_accessed_at: None,
10391 expires_at: None,
10392 metadata: serde_json::json!({}),
10393 };
10394 db::insert(&lock.0, &mem).unwrap();
10395 }
10396 }
10397 let app = Router::new()
10398 .route("/api/v1/forget", axum_post(forget_memories))
10399 .with_state(state);
10400 let body = serde_json::json!({
10401 "namespace": "h8a-forget-and",
10402 "pattern": "purge"
10403 });
10404 let resp = app
10405 .oneshot(
10406 axum::http::Request::builder()
10407 .uri("/api/v1/forget")
10408 .method("POST")
10409 .header("content-type", "application/json")
10410 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10411 .unwrap(),
10412 )
10413 .await
10414 .unwrap();
10415 assert_eq!(resp.status(), StatusCode::OK);
10416 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10417 .await
10418 .unwrap();
10419 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10420 assert_eq!(v["deleted"], 2);
10422 }
10423
10424 #[tokio::test]
10425 async fn http_forget_memories_malformed_json_returns_400() {
10426 let state = test_state();
10428 let app = Router::new()
10429 .route("/api/v1/forget", axum_post(forget_memories))
10430 .with_state(state);
10431 let resp = app
10432 .oneshot(
10433 axum::http::Request::builder()
10434 .uri("/api/v1/forget")
10435 .method("POST")
10436 .header("content-type", "application/json")
10437 .body(Body::from("{not-json"))
10438 .unwrap(),
10439 )
10440 .await
10441 .unwrap();
10442 assert!(resp.status().is_client_error());
10443 }
10444
10445 #[tokio::test]
10446 async fn http_forget_memories_no_match_returns_zero_deleted() {
10447 let state = test_state();
10449 for i in 0..3 {
10452 insert_test_memory(&state, "h8a-forget-keep", &format!("k-{i}")).await;
10453 }
10454 let app = Router::new()
10455 .route("/api/v1/forget", axum_post(forget_memories))
10456 .with_state(state.clone());
10457 let body = serde_json::json!({"namespace": "h8a-forget-empty"});
10458 let resp = app
10459 .oneshot(
10460 axum::http::Request::builder()
10461 .uri("/api/v1/forget")
10462 .method("POST")
10463 .header("content-type", "application/json")
10464 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10465 .unwrap(),
10466 )
10467 .await
10468 .unwrap();
10469 assert_eq!(resp.status(), StatusCode::OK);
10470 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10471 .await
10472 .unwrap();
10473 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10474 assert_eq!(v["deleted"], 0);
10475 let lock = state.lock().await;
10477 let rows = db::list(
10478 &lock.0,
10479 Some("h8a-forget-keep"),
10480 None,
10481 10,
10482 0,
10483 None,
10484 None,
10485 None,
10486 None,
10487 None,
10488 )
10489 .unwrap();
10490 assert_eq!(rows.len(), 3);
10491 }
10492 #[tokio::test]
10511 async fn h8b_subscribe_https_url_returns_created() {
10512 let state = test_state();
10513 let app = Router::new()
10514 .route("/api/v1/subscriptions", axum_post(subscribe))
10515 .with_state(test_app_state(state));
10516
10517 let body = serde_json::json!({
10518 "url": "https://example.com/webhook",
10519 "events": "*",
10520 });
10521 let resp = app
10522 .oneshot(
10523 axum::http::Request::builder()
10524 .uri("/api/v1/subscriptions")
10525 .method("POST")
10526 .header("content-type", "application/json")
10527 .header("x-agent-id", "alice")
10528 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10529 .unwrap(),
10530 )
10531 .await
10532 .unwrap();
10533 assert_eq!(resp.status(), StatusCode::CREATED);
10534 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10535 .await
10536 .unwrap();
10537 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10538 assert!(v["id"].as_str().is_some(), "id must be returned");
10539 assert_eq!(v["url"], "https://example.com/webhook");
10540 assert_eq!(v["created_by"], "alice");
10541 }
10542
10543 #[tokio::test]
10546 async fn h8b_subscribe_missing_url_and_namespace_rejected() {
10547 let state = test_state();
10548 let app = Router::new()
10549 .route("/api/v1/subscriptions", axum_post(subscribe))
10550 .with_state(test_app_state(state));
10551
10552 let body = serde_json::json!({"events": "*"});
10553 let resp = app
10554 .oneshot(
10555 axum::http::Request::builder()
10556 .uri("/api/v1/subscriptions")
10557 .method("POST")
10558 .header("content-type", "application/json")
10559 .header("x-agent-id", "alice")
10560 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10561 .unwrap(),
10562 )
10563 .await
10564 .unwrap();
10565 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10566 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10567 .await
10568 .unwrap();
10569 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10570 assert!(v["error"].as_str().unwrap().contains("url or namespace"),);
10571 }
10572
10573 #[tokio::test]
10576 async fn h8b_subscribe_invalid_url_rejected() {
10577 let state = test_state();
10578 let app = Router::new()
10579 .route("/api/v1/subscriptions", axum_post(subscribe))
10580 .with_state(test_app_state(state));
10581
10582 let body = serde_json::json!({
10583 "url": "not-a-url",
10584 "events": "*",
10585 });
10586 let resp = app
10587 .oneshot(
10588 axum::http::Request::builder()
10589 .uri("/api/v1/subscriptions")
10590 .method("POST")
10591 .header("content-type", "application/json")
10592 .header("x-agent-id", "alice")
10593 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10594 .unwrap(),
10595 )
10596 .await
10597 .unwrap();
10598 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10599 }
10600
10601 #[tokio::test]
10606 async fn h8b_subscribe_rejects_link_local_metadata_ip() {
10607 let state = test_state();
10608 let app = Router::new()
10609 .route("/api/v1/subscriptions", axum_post(subscribe))
10610 .with_state(test_app_state(state));
10611
10612 let body = serde_json::json!({
10613 "url": "https://169.254.169.254/latest/meta-data/",
10614 "events": "*",
10615 });
10616 let resp = app
10617 .oneshot(
10618 axum::http::Request::builder()
10619 .uri("/api/v1/subscriptions")
10620 .method("POST")
10621 .header("content-type", "application/json")
10622 .header("x-agent-id", "alice")
10623 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10624 .unwrap(),
10625 )
10626 .await
10627 .unwrap();
10628 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10629 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10630 .await
10631 .unwrap();
10632 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10633 let err = v["error"].as_str().unwrap();
10634 assert!(
10637 err.contains("private") || err.contains("link-local") || err.contains("non-loopback"),
10638 "expected SSRF rejection, got: {err}",
10639 );
10640 }
10641
10642 #[tokio::test]
10645 async fn h8b_subscribe_namespace_shape_synthesizes_url() {
10646 let state = test_state();
10647 let app = Router::new()
10648 .route("/api/v1/subscriptions", axum_post(subscribe))
10649 .with_state(test_app_state(state));
10650
10651 let body = serde_json::json!({
10652 "agent_id": "alice",
10653 "namespace": "team/research",
10654 });
10655 let resp = app
10656 .oneshot(
10657 axum::http::Request::builder()
10658 .uri("/api/v1/subscriptions")
10659 .method("POST")
10660 .header("content-type", "application/json")
10661 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10662 .unwrap(),
10663 )
10664 .await
10665 .unwrap();
10666 assert_eq!(resp.status(), StatusCode::CREATED);
10667 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10668 .await
10669 .unwrap();
10670 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10671 assert_eq!(v["agent_id"], "alice");
10672 assert_eq!(v["namespace"], "team/research");
10673 assert!(
10674 v["url"]
10675 .as_str()
10676 .unwrap()
10677 .starts_with("http://localhost/_ns/"),
10678 "expected synthetic URL, got {}",
10679 v["url"],
10680 );
10681 }
10682
10683 #[tokio::test]
10686 async fn h8b_subscribe_event_filter_round_trips() {
10687 let state = test_state();
10688 let app = Router::new()
10689 .route("/api/v1/subscriptions", axum_post(subscribe))
10690 .with_state(test_app_state(state));
10691
10692 let body = serde_json::json!({
10693 "url": "https://example.com/hook",
10694 "events": "memory.created",
10695 "namespace_filter": "global",
10696 });
10697 let resp = app
10698 .oneshot(
10699 axum::http::Request::builder()
10700 .uri("/api/v1/subscriptions")
10701 .method("POST")
10702 .header("content-type", "application/json")
10703 .header("x-agent-id", "alice")
10704 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10705 .unwrap(),
10706 )
10707 .await
10708 .unwrap();
10709 assert_eq!(resp.status(), StatusCode::CREATED);
10710 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10711 .await
10712 .unwrap();
10713 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10714 assert_eq!(v["events"], "memory.created");
10715 assert_eq!(v["namespace_filter"], "global");
10716 }
10717
10718 #[tokio::test]
10723 async fn h8b_subscribe_persists_hmac_secret() {
10724 let state = test_state();
10725 let app = Router::new()
10726 .route("/api/v1/subscriptions", axum_post(subscribe))
10727 .with_state(test_app_state(state.clone()));
10728
10729 let body = serde_json::json!({
10730 "url": "https://example.com/signed-hook",
10731 "events": "*",
10732 "secret": "topsecret-hmac-key",
10733 });
10734 let resp = app
10735 .oneshot(
10736 axum::http::Request::builder()
10737 .uri("/api/v1/subscriptions")
10738 .method("POST")
10739 .header("content-type", "application/json")
10740 .header("x-agent-id", "alice")
10741 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10742 .unwrap(),
10743 )
10744 .await
10745 .unwrap();
10746 assert_eq!(resp.status(), StatusCode::CREATED);
10747 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10748 .await
10749 .unwrap();
10750 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10751 assert!(v.get("secret").is_none(), "secret leaked into response");
10753 let lock = state.lock().await;
10755 let subs = crate::subscriptions::list(&lock.0).unwrap();
10756 assert_eq!(subs.len(), 1);
10757 assert_eq!(subs[0].url, "https://example.com/signed-hook");
10758 }
10759
10760 #[tokio::test]
10765 async fn h8b_unsubscribe_by_id_happy_path() {
10766 let state = test_state();
10767 let id = {
10768 let lock = state.lock().await;
10769 crate::subscriptions::insert(
10770 &lock.0,
10771 &crate::subscriptions::NewSubscription {
10772 url: "https://example.com/h",
10773 events: "*",
10774 secret: None,
10775 namespace_filter: None,
10776 agent_filter: None,
10777 created_by: Some("alice"),
10778 },
10779 )
10780 .unwrap()
10781 };
10782
10783 let app = Router::new()
10784 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
10785 .with_state(test_app_state(state.clone()));
10786
10787 let resp = app
10788 .oneshot(
10789 axum::http::Request::builder()
10790 .uri(format!("/api/v1/subscriptions?id={id}"))
10791 .method("DELETE")
10792 .body(Body::empty())
10793 .unwrap(),
10794 )
10795 .await
10796 .unwrap();
10797 assert_eq!(resp.status(), StatusCode::OK);
10798 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10799 .await
10800 .unwrap();
10801 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10802 assert_eq!(v["removed"], true);
10803
10804 let lock = state.lock().await;
10806 assert!(crate::subscriptions::list(&lock.0).unwrap().is_empty());
10807 }
10808
10809 #[tokio::test]
10812 async fn h8b_unsubscribe_nonexistent_id_returns_removed_false() {
10813 let state = test_state();
10814 let app = Router::new()
10815 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
10816 .with_state(test_app_state(state));
10817
10818 let resp = app
10819 .oneshot(
10820 axum::http::Request::builder()
10821 .uri("/api/v1/subscriptions?id=does-not-exist")
10822 .method("DELETE")
10823 .body(Body::empty())
10824 .unwrap(),
10825 )
10826 .await
10827 .unwrap();
10828 assert_eq!(resp.status(), StatusCode::OK);
10829 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10830 .await
10831 .unwrap();
10832 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10833 assert_eq!(v["removed"], false);
10834 }
10835
10836 #[tokio::test]
10839 async fn h8b_unsubscribe_by_agent_and_namespace() {
10840 let state = test_state();
10841 {
10843 let lock = state.lock().await;
10844 crate::subscriptions::insert(
10845 &lock.0,
10846 &crate::subscriptions::NewSubscription {
10847 url: "http://localhost/_ns/alice/demo",
10848 events: "*",
10849 secret: None,
10850 namespace_filter: Some("demo"),
10851 agent_filter: Some("alice"),
10852 created_by: Some("alice"),
10853 },
10854 )
10855 .unwrap();
10856 }
10857
10858 let app = Router::new()
10859 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
10860 .with_state(test_app_state(state.clone()));
10861
10862 let resp = app
10863 .oneshot(
10864 axum::http::Request::builder()
10865 .uri("/api/v1/subscriptions?namespace=demo")
10866 .method("DELETE")
10867 .header("x-agent-id", "alice")
10868 .body(Body::empty())
10869 .unwrap(),
10870 )
10871 .await
10872 .unwrap();
10873 assert_eq!(resp.status(), StatusCode::OK);
10874 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10875 .await
10876 .unwrap();
10877 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10878 assert_eq!(v["removed"], true);
10879 }
10880
10881 #[tokio::test]
10883 async fn h8b_unsubscribe_missing_id_and_namespace_rejected() {
10884 let state = test_state();
10885 let app = Router::new()
10886 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
10887 .with_state(test_app_state(state));
10888
10889 let resp = app
10890 .oneshot(
10891 axum::http::Request::builder()
10892 .uri("/api/v1/subscriptions")
10893 .method("DELETE")
10894 .header("x-agent-id", "alice")
10895 .body(Body::empty())
10896 .unwrap(),
10897 )
10898 .await
10899 .unwrap();
10900 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10901 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10902 .await
10903 .unwrap();
10904 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10905 assert!(
10906 v["error"]
10907 .as_str()
10908 .unwrap()
10909 .contains("id or (agent_id, namespace)"),
10910 );
10911 }
10912
10913 #[tokio::test]
10918 async fn h8b_list_subscriptions_returns_seeded_rows() {
10919 let state = test_state();
10920 {
10921 let lock = state.lock().await;
10922 crate::subscriptions::insert(
10923 &lock.0,
10924 &crate::subscriptions::NewSubscription {
10925 url: "https://example.com/a",
10926 events: "*",
10927 secret: None,
10928 namespace_filter: Some("ns1"),
10929 agent_filter: Some("alice"),
10930 created_by: Some("alice"),
10931 },
10932 )
10933 .unwrap();
10934 crate::subscriptions::insert(
10935 &lock.0,
10936 &crate::subscriptions::NewSubscription {
10937 url: "https://example.com/b",
10938 events: "memory.updated",
10939 secret: None,
10940 namespace_filter: Some("ns2"),
10941 agent_filter: Some("bob"),
10942 created_by: Some("bob"),
10943 },
10944 )
10945 .unwrap();
10946 }
10947
10948 let app = Router::new()
10949 .route(
10950 "/api/v1/subscriptions",
10951 axum::routing::get(list_subscriptions),
10952 )
10953 .with_state(state);
10954
10955 let resp = app
10956 .oneshot(
10957 axum::http::Request::builder()
10958 .uri("/api/v1/subscriptions")
10959 .body(Body::empty())
10960 .unwrap(),
10961 )
10962 .await
10963 .unwrap();
10964 assert_eq!(resp.status(), StatusCode::OK);
10965 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10966 .await
10967 .unwrap();
10968 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10969 assert_eq!(v["count"], 2);
10970 let subs = v["subscriptions"].as_array().unwrap();
10971 assert_eq!(subs.len(), 2);
10972 for s in subs {
10974 assert!(s["namespace"].is_string());
10975 assert!(s["namespace_filter"].is_string());
10976 assert!(s["id"].is_string());
10977 }
10978 }
10979
10980 #[tokio::test]
10984 async fn h8b_list_subscriptions_agent_id_filter_excludes_others() {
10985 let state = test_state();
10986 {
10987 let lock = state.lock().await;
10988 crate::subscriptions::insert(
10989 &lock.0,
10990 &crate::subscriptions::NewSubscription {
10991 url: "https://example.com/a",
10992 events: "*",
10993 secret: None,
10994 namespace_filter: Some("ns1"),
10995 agent_filter: Some("alice"),
10996 created_by: Some("alice"),
10997 },
10998 )
10999 .unwrap();
11000 crate::subscriptions::insert(
11001 &lock.0,
11002 &crate::subscriptions::NewSubscription {
11003 url: "https://example.com/b",
11004 events: "*",
11005 secret: None,
11006 namespace_filter: Some("ns2"),
11007 agent_filter: Some("bob"),
11008 created_by: Some("bob"),
11009 },
11010 )
11011 .unwrap();
11012 }
11013
11014 let app = Router::new()
11015 .route(
11016 "/api/v1/subscriptions",
11017 axum::routing::get(list_subscriptions),
11018 )
11019 .with_state(state);
11020
11021 let resp = app
11022 .oneshot(
11023 axum::http::Request::builder()
11024 .uri("/api/v1/subscriptions?agent_id=alice")
11025 .body(Body::empty())
11026 .unwrap(),
11027 )
11028 .await
11029 .unwrap();
11030 assert_eq!(resp.status(), StatusCode::OK);
11031 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11032 .await
11033 .unwrap();
11034 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11035 assert_eq!(v["count"], 1);
11036 assert_eq!(v["subscriptions"][0]["namespace"], "ns1");
11037 }
11038
11039 #[tokio::test]
11044 async fn h8b_notify_happy_path_creates_message() {
11045 let state = test_state();
11046 let app = Router::new()
11047 .route("/api/v1/notify", axum_post(notify))
11048 .with_state(test_app_state(state.clone()));
11049
11050 let body = serde_json::json!({
11051 "target_agent_id": "bob",
11052 "title": "Hi bob",
11053 "payload": "hello there",
11054 });
11055 let resp = app
11056 .oneshot(
11057 axum::http::Request::builder()
11058 .uri("/api/v1/notify")
11059 .method("POST")
11060 .header("content-type", "application/json")
11061 .header("x-agent-id", "alice")
11062 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11063 .unwrap(),
11064 )
11065 .await
11066 .unwrap();
11067 assert_eq!(resp.status(), StatusCode::CREATED);
11068 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11069 .await
11070 .unwrap();
11071 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11072 assert_eq!(v["to"], "bob");
11073 assert!(v["id"].as_str().is_some());
11074 assert!(v["delivered_at"].as_str().is_some());
11075
11076 let lock = state.lock().await;
11078 let rows = db::list(
11079 &lock.0,
11080 Some("_messages/bob"),
11081 None,
11082 10,
11083 0,
11084 None,
11085 None,
11086 None,
11087 None,
11088 None,
11089 )
11090 .unwrap();
11091 assert_eq!(rows.len(), 1);
11092 assert_eq!(rows[0].title, "Hi bob");
11093 }
11094
11095 #[tokio::test]
11099 async fn h8b_notify_missing_target_agent_id_rejected() {
11100 let state = test_state();
11101 let app = Router::new()
11102 .route("/api/v1/notify", axum_post(notify))
11103 .with_state(test_app_state(state));
11104
11105 let body = serde_json::json!({
11107 "title": "stray",
11108 "payload": "no target",
11109 });
11110 let resp = app
11111 .oneshot(
11112 axum::http::Request::builder()
11113 .uri("/api/v1/notify")
11114 .method("POST")
11115 .header("content-type", "application/json")
11116 .header("x-agent-id", "alice")
11117 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11118 .unwrap(),
11119 )
11120 .await
11121 .unwrap();
11122 assert!(
11124 resp.status() == StatusCode::UNPROCESSABLE_ENTITY
11125 || resp.status() == StatusCode::BAD_REQUEST,
11126 "expected 4xx for missing target_agent_id, got {}",
11127 resp.status(),
11128 );
11129 }
11130
11131 #[tokio::test]
11134 async fn h8b_notify_invalid_target_agent_id_rejected() {
11135 let state = test_state();
11136 let app = Router::new()
11137 .route("/api/v1/notify", axum_post(notify))
11138 .with_state(test_app_state(state));
11139
11140 let body = serde_json::json!({
11141 "target_agent_id": "bob with spaces",
11142 "title": "Hi",
11143 "payload": "hello",
11144 });
11145 let resp = app
11146 .oneshot(
11147 axum::http::Request::builder()
11148 .uri("/api/v1/notify")
11149 .method("POST")
11150 .header("content-type", "application/json")
11151 .header("x-agent-id", "alice")
11152 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11153 .unwrap(),
11154 )
11155 .await
11156 .unwrap();
11157 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11158 }
11159
11160 #[tokio::test]
11163 async fn h8b_notify_oversized_payload_rejected() {
11164 let state = test_state();
11165 let app = Router::new()
11166 .route("/api/v1/notify", axum_post(notify))
11167 .with_state(test_app_state(state));
11168
11169 let big = "a".repeat(65_537);
11171 let body = serde_json::json!({
11172 "target_agent_id": "bob",
11173 "title": "huge",
11174 "payload": big,
11175 });
11176 let resp = app
11177 .oneshot(
11178 axum::http::Request::builder()
11179 .uri("/api/v1/notify")
11180 .method("POST")
11181 .header("content-type", "application/json")
11182 .header("x-agent-id", "alice")
11183 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11184 .unwrap(),
11185 )
11186 .await
11187 .unwrap();
11188 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11189 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11190 .await
11191 .unwrap();
11192 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11193 assert!(
11194 v["error"].as_str().unwrap().contains("max"),
11195 "expected size-limit error, got {:?}",
11196 v["error"],
11197 );
11198 }
11199
11200 #[tokio::test]
11203 async fn h8b_notify_accepts_content_alias_for_payload() {
11204 let state = test_state();
11205 let app = Router::new()
11206 .route("/api/v1/notify", axum_post(notify))
11207 .with_state(test_app_state(state));
11208
11209 let body = serde_json::json!({
11210 "target_agent_id": "bob",
11211 "title": "alias",
11212 "content": "via the content field",
11213 });
11214 let resp = app
11215 .oneshot(
11216 axum::http::Request::builder()
11217 .uri("/api/v1/notify")
11218 .method("POST")
11219 .header("content-type", "application/json")
11220 .header("x-agent-id", "alice")
11221 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11222 .unwrap(),
11223 )
11224 .await
11225 .unwrap();
11226 assert_eq!(resp.status(), StatusCode::CREATED);
11227 }
11228
11229 #[tokio::test]
11233 async fn h8b_get_inbox_empty_returns_zero() {
11234 let state = test_state();
11235 let app = Router::new()
11236 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11237 .with_state(test_app_state(state));
11238
11239 let resp = app
11240 .oneshot(
11241 axum::http::Request::builder()
11242 .uri("/api/v1/inbox?agent_id=alice")
11243 .body(Body::empty())
11244 .unwrap(),
11245 )
11246 .await
11247 .unwrap();
11248 assert_eq!(resp.status(), StatusCode::OK);
11249 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11250 .await
11251 .unwrap();
11252 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11253 assert_eq!(v["count"], 0);
11254 assert_eq!(v["messages"].as_array().unwrap().len(), 0);
11255 }
11256
11257 #[tokio::test]
11261 async fn h8b_get_inbox_returns_pending_after_notify() {
11262 let state = test_state();
11263
11264 let notify_app = Router::new()
11266 .route("/api/v1/notify", axum_post(notify))
11267 .with_state(test_app_state(state.clone()));
11268 let notify_body = serde_json::json!({
11269 "target_agent_id": "bob",
11270 "title": "ping",
11271 "payload": "wake up",
11272 });
11273 let resp = notify_app
11274 .oneshot(
11275 axum::http::Request::builder()
11276 .uri("/api/v1/notify")
11277 .method("POST")
11278 .header("content-type", "application/json")
11279 .header("x-agent-id", "alice")
11280 .body(Body::from(serde_json::to_vec(¬ify_body).unwrap()))
11281 .unwrap(),
11282 )
11283 .await
11284 .unwrap();
11285 assert_eq!(resp.status(), StatusCode::CREATED);
11286
11287 let inbox_app = Router::new()
11289 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11290 .with_state(test_app_state(state));
11291 let resp = inbox_app
11292 .oneshot(
11293 axum::http::Request::builder()
11294 .uri("/api/v1/inbox?agent_id=bob")
11295 .body(Body::empty())
11296 .unwrap(),
11297 )
11298 .await
11299 .unwrap();
11300 assert_eq!(resp.status(), StatusCode::OK);
11301 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11302 .await
11303 .unwrap();
11304 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11305 assert_eq!(v["count"], 1);
11306 let msg = &v["messages"][0];
11307 assert_eq!(msg["title"], "ping");
11308 let from = msg["from"].as_str().unwrap();
11313 assert!(
11314 from == "alice" || from.starts_with("ai:alice@"),
11315 "unexpected sender: {from}",
11316 );
11317 assert_eq!(msg["read"], false);
11318 }
11319
11320 #[tokio::test]
11324 async fn h8b_get_inbox_unread_only_filter_excludes_read() {
11325 let state = test_state();
11326 {
11328 let lock = state.lock().await;
11329 let now = Utc::now().to_rfc3339();
11330 let unread = Memory {
11331 id: Uuid::new_v4().to_string(),
11332 tier: Tier::Mid,
11333 namespace: "_messages/alice".into(),
11334 title: "unread".into(),
11335 content: "u".into(),
11336 tags: vec!["_message".into()],
11337 priority: 5,
11338 confidence: 1.0,
11339 source: "notify".into(),
11340 access_count: 0,
11341 created_at: now.clone(),
11342 updated_at: now.clone(),
11343 last_accessed_at: None,
11344 expires_at: None,
11345 metadata: serde_json::json!({"agent_id": "bob"}),
11346 };
11347 let read = Memory {
11348 id: Uuid::new_v4().to_string(),
11349 tier: Tier::Mid,
11350 namespace: "_messages/alice".into(),
11351 title: "read".into(),
11352 content: "r".into(),
11353 tags: vec!["_message".into()],
11354 priority: 5,
11355 confidence: 1.0,
11356 source: "notify".into(),
11357 access_count: 5,
11358 created_at: now.clone(),
11359 updated_at: now,
11360 last_accessed_at: None,
11361 expires_at: None,
11362 metadata: serde_json::json!({"agent_id": "bob"}),
11363 };
11364 db::insert(&lock.0, &unread).unwrap();
11365 db::insert(&lock.0, &read).unwrap();
11366 }
11367
11368 let app = Router::new()
11369 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11370 .with_state(test_app_state(state));
11371 let resp = app
11372 .oneshot(
11373 axum::http::Request::builder()
11374 .uri("/api/v1/inbox?agent_id=alice&unread_only=true")
11375 .body(Body::empty())
11376 .unwrap(),
11377 )
11378 .await
11379 .unwrap();
11380 assert_eq!(resp.status(), StatusCode::OK);
11381 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11382 .await
11383 .unwrap();
11384 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11385 assert_eq!(v["count"], 1);
11386 assert_eq!(v["messages"][0]["title"], "unread");
11387 assert_eq!(v["unread_only"], true);
11388 }
11389
11390 #[tokio::test]
11392 async fn h8b_get_inbox_limit_clamps_returned_count() {
11393 let state = test_state();
11394 {
11395 let lock = state.lock().await;
11396 let now = Utc::now().to_rfc3339();
11397 for i in 0..3 {
11398 let mem = Memory {
11399 id: Uuid::new_v4().to_string(),
11400 tier: Tier::Mid,
11401 namespace: "_messages/alice".into(),
11402 title: format!("msg-{i}"),
11403 content: "c".into(),
11404 tags: vec!["_message".into()],
11405 priority: 5,
11406 confidence: 1.0,
11407 source: "notify".into(),
11408 access_count: 0,
11409 created_at: now.clone(),
11410 updated_at: now.clone(),
11411 last_accessed_at: None,
11412 expires_at: None,
11413 metadata: serde_json::json!({"agent_id": "carol"}),
11414 };
11415 db::insert(&lock.0, &mem).unwrap();
11416 }
11417 }
11418
11419 let app = Router::new()
11420 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11421 .with_state(test_app_state(state));
11422 let resp = app
11423 .oneshot(
11424 axum::http::Request::builder()
11425 .uri("/api/v1/inbox?agent_id=alice&limit=2")
11426 .body(Body::empty())
11427 .unwrap(),
11428 )
11429 .await
11430 .unwrap();
11431 assert_eq!(resp.status(), StatusCode::OK);
11432 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11433 .await
11434 .unwrap();
11435 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11436 assert_eq!(v["count"], 2);
11437 }
11438
11439 #[tokio::test]
11442 async fn h8b_get_inbox_invalid_agent_id_rejected() {
11443 let state = test_state();
11444 let app = Router::new()
11445 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11446 .with_state(test_app_state(state));
11447
11448 let resp = app
11449 .oneshot(
11450 axum::http::Request::builder()
11451 .uri("/api/v1/inbox?agent_id=bad%20agent")
11452 .body(Body::empty())
11453 .unwrap(),
11454 )
11455 .await
11456 .unwrap();
11457 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11458 }
11459
11460 #[tokio::test]
11465 async fn h8b_session_start_with_valid_agent_id_echoes() {
11466 let state = test_state();
11467 let app = Router::new()
11468 .route("/api/v1/session/start", axum_post(session_start))
11469 .with_state(state);
11470
11471 let body = serde_json::json!({"agent_id": "alice"});
11472 let resp = app
11473 .oneshot(
11474 axum::http::Request::builder()
11475 .uri("/api/v1/session/start")
11476 .method("POST")
11477 .header("content-type", "application/json")
11478 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11479 .unwrap(),
11480 )
11481 .await
11482 .unwrap();
11483 assert_eq!(resp.status(), StatusCode::OK);
11484 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11485 .await
11486 .unwrap();
11487 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11488 assert!(v["session_id"].as_str().is_some());
11489 assert_eq!(v["agent_id"], "alice");
11490 }
11491
11492 #[tokio::test]
11494 async fn h8b_session_start_namespace_filter() {
11495 let state = test_state();
11496 {
11498 let lock = state.lock().await;
11499 let now = Utc::now().to_rfc3339();
11500 for (ns, title) in [("target-ns", "in-scope"), ("other-ns", "out")] {
11501 let mem = Memory {
11502 id: Uuid::new_v4().to_string(),
11503 tier: Tier::Long,
11504 namespace: ns.into(),
11505 title: title.into(),
11506 content: "body".into(),
11507 tags: vec![],
11508 priority: 5,
11509 confidence: 1.0,
11510 source: "api".into(),
11511 access_count: 0,
11512 created_at: now.clone(),
11513 updated_at: now.clone(),
11514 last_accessed_at: None,
11515 expires_at: None,
11516 metadata: serde_json::json!({"agent_id": "alice"}),
11517 };
11518 db::insert(&lock.0, &mem).unwrap();
11519 }
11520 }
11521
11522 let app = Router::new()
11523 .route("/api/v1/session/start", axum_post(session_start))
11524 .with_state(state);
11525 let body = serde_json::json!({"namespace": "target-ns", "limit": 5});
11526 let resp = app
11527 .oneshot(
11528 axum::http::Request::builder()
11529 .uri("/api/v1/session/start")
11530 .method("POST")
11531 .header("content-type", "application/json")
11532 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11533 .unwrap(),
11534 )
11535 .await
11536 .unwrap();
11537 assert_eq!(resp.status(), StatusCode::OK);
11538 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11539 .await
11540 .unwrap();
11541 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11542 let mems = v["memories"].as_array().unwrap();
11544 assert_eq!(mems.len(), 1);
11545 assert_eq!(mems[0]["title"], "in-scope");
11546 }
11547
11548 #[tokio::test]
11551 async fn h8b_session_start_returns_session_id_without_agent() {
11552 let state = test_state();
11553 let app = Router::new()
11554 .route("/api/v1/session/start", axum_post(session_start))
11555 .with_state(state);
11556 let body = serde_json::json!({});
11557 let resp = app
11558 .oneshot(
11559 axum::http::Request::builder()
11560 .uri("/api/v1/session/start")
11561 .method("POST")
11562 .header("content-type", "application/json")
11563 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11564 .unwrap(),
11565 )
11566 .await
11567 .unwrap();
11568 assert_eq!(resp.status(), StatusCode::OK);
11569 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11570 .await
11571 .unwrap();
11572 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11573 let sid = v["session_id"].as_str().unwrap();
11575 assert_eq!(sid.len(), 36);
11576 assert!(v.get("agent_id").is_none() || v["agent_id"].is_null());
11578 assert_eq!(v["mode"], "session_start");
11579 }
11580
11581 #[tokio::test]
11584 async fn h8b_session_start_preloads_recent_context() {
11585 let state = test_state();
11586 {
11587 let lock = state.lock().await;
11588 let now = Utc::now().to_rfc3339();
11589 let mem = Memory {
11590 id: Uuid::new_v4().to_string(),
11591 tier: Tier::Long,
11592 namespace: "global".into(),
11593 title: "preload-me".into(),
11594 content: "context".into(),
11595 tags: vec![],
11596 priority: 5,
11597 confidence: 1.0,
11598 source: "api".into(),
11599 access_count: 0,
11600 created_at: now.clone(),
11601 updated_at: now,
11602 last_accessed_at: None,
11603 expires_at: None,
11604 metadata: serde_json::json!({"agent_id": "alice"}),
11605 };
11606 db::insert(&lock.0, &mem).unwrap();
11607 }
11608
11609 let app = Router::new()
11610 .route("/api/v1/session/start", axum_post(session_start))
11611 .with_state(state);
11612 let body = serde_json::json!({"limit": 50});
11613 let resp = app
11614 .oneshot(
11615 axum::http::Request::builder()
11616 .uri("/api/v1/session/start")
11617 .method("POST")
11618 .header("content-type", "application/json")
11619 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11620 .unwrap(),
11621 )
11622 .await
11623 .unwrap();
11624 assert_eq!(resp.status(), StatusCode::OK);
11625 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11626 .await
11627 .unwrap();
11628 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11629 let mems = v["memories"].as_array().unwrap();
11630 assert!(
11631 mems.iter().any(|m| m["title"] == "preload-me"),
11632 "session_start must preload recent memories",
11633 );
11634 }
11635 #[tokio::test]
11651 async fn http_list_agents_empty_returns_zero_count() {
11652 let state = test_state();
11654 let app = Router::new()
11655 .route("/api/v1/agents", axum_get(list_agents))
11656 .with_state(state);
11657 let resp = app
11658 .oneshot(
11659 axum::http::Request::builder()
11660 .uri("/api/v1/agents")
11661 .body(Body::empty())
11662 .unwrap(),
11663 )
11664 .await
11665 .unwrap();
11666 assert_eq!(resp.status(), StatusCode::OK);
11667 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11668 .await
11669 .unwrap();
11670 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11671 assert_eq!(v["count"], 0);
11672 assert_eq!(v["agents"].as_array().unwrap().len(), 0);
11673 }
11674
11675 #[tokio::test]
11676 async fn http_list_agents_returns_registered_rows() {
11677 let state = test_state();
11680 {
11681 let lock = state.lock().await;
11682 db::register_agent(&lock.0, "alice", "human", &["read".into(), "write".into()])
11683 .unwrap();
11684 db::register_agent(&lock.0, "bob", "ai:claude-opus-4.7", &["recall".into()]).unwrap();
11685 }
11686 let app = Router::new()
11687 .route("/api/v1/agents", axum_get(list_agents))
11688 .with_state(state);
11689 let resp = app
11690 .oneshot(
11691 axum::http::Request::builder()
11692 .uri("/api/v1/agents")
11693 .body(Body::empty())
11694 .unwrap(),
11695 )
11696 .await
11697 .unwrap();
11698 assert_eq!(resp.status(), StatusCode::OK);
11699 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11700 .await
11701 .unwrap();
11702 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11703 assert_eq!(v["count"], 2);
11704 let agents = v["agents"].as_array().unwrap();
11705 let ids: Vec<&str> = agents
11706 .iter()
11707 .filter_map(|a| a["agent_id"].as_str())
11708 .collect();
11709 assert!(ids.contains(&"alice"));
11710 assert!(ids.contains(&"bob"));
11711 }
11712
11713 #[tokio::test]
11714 async fn http_list_agents_includes_types_and_capabilities() {
11715 let state = test_state();
11718 {
11719 let lock = state.lock().await;
11720 db::register_agent(
11721 &lock.0,
11722 "alpha",
11723 "ai:claude-opus-4.7",
11724 &["read".into(), "store".into(), "recall".into()],
11725 )
11726 .unwrap();
11727 }
11728 let app = Router::new()
11729 .route("/api/v1/agents", axum_get(list_agents))
11730 .with_state(state);
11731 let resp = app
11732 .oneshot(
11733 axum::http::Request::builder()
11734 .uri("/api/v1/agents")
11735 .body(Body::empty())
11736 .unwrap(),
11737 )
11738 .await
11739 .unwrap();
11740 assert_eq!(resp.status(), StatusCode::OK);
11741 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11742 .await
11743 .unwrap();
11744 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11745 let agents = v["agents"].as_array().unwrap();
11746 assert_eq!(agents.len(), 1);
11747 let a = &agents[0];
11748 assert_eq!(a["agent_id"], "alpha");
11749 assert_eq!(a["agent_type"], "ai:claude-opus-4.7");
11750 let caps = a["capabilities"].as_array().unwrap();
11751 assert_eq!(caps.len(), 3);
11752 let cap_strs: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
11753 assert!(cap_strs.contains(&"read"));
11754 assert!(cap_strs.contains(&"store"));
11755 assert!(cap_strs.contains(&"recall"));
11756 }
11757
11758 #[tokio::test]
11761 async fn http_register_agent_happy_path_returns_created() {
11762 let state = test_state();
11763 let app = Router::new()
11764 .route("/api/v1/agents", axum_post(register_agent))
11765 .with_state(test_app_state(state.clone()));
11766 let body = serde_json::json!({
11767 "agent_id": "alice",
11768 "agent_type": "human",
11769 "capabilities": ["read", "write"]
11770 });
11771 let resp = app
11772 .oneshot(
11773 axum::http::Request::builder()
11774 .uri("/api/v1/agents")
11775 .method("POST")
11776 .header("content-type", "application/json")
11777 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11778 .unwrap(),
11779 )
11780 .await
11781 .unwrap();
11782 assert_eq!(resp.status(), StatusCode::CREATED);
11783 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11784 .await
11785 .unwrap();
11786 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11787 assert_eq!(v["registered"], true);
11788 assert_eq!(v["agent_id"], "alice");
11789 assert_eq!(v["agent_type"], "human");
11790 let lock = state.lock().await;
11792 let agents = db::list_agents(&lock.0).unwrap();
11793 assert_eq!(agents.len(), 1);
11794 assert_eq!(agents[0].agent_id, "alice");
11795 }
11796
11797 #[tokio::test]
11798 async fn http_register_agent_missing_agent_type_400() {
11799 let state = test_state();
11802 let app = Router::new()
11803 .route("/api/v1/agents", axum_post(register_agent))
11804 .with_state(test_app_state(state));
11805 let body = serde_json::json!({
11806 "agent_id": "alice"
11807 });
11809 let resp = app
11810 .oneshot(
11811 axum::http::Request::builder()
11812 .uri("/api/v1/agents")
11813 .method("POST")
11814 .header("content-type", "application/json")
11815 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11816 .unwrap(),
11817 )
11818 .await
11819 .unwrap();
11820 assert!(
11821 resp.status().is_client_error(),
11822 "expected 4xx for missing agent_type, got {}",
11823 resp.status()
11824 );
11825 }
11826
11827 #[tokio::test]
11828 async fn http_register_agent_invalid_agent_id_with_space_400() {
11829 let state = test_state();
11831 let app = Router::new()
11832 .route("/api/v1/agents", axum_post(register_agent))
11833 .with_state(test_app_state(state));
11834 let body = serde_json::json!({
11835 "agent_id": "bad agent",
11836 "agent_type": "human",
11837 "capabilities": []
11838 });
11839 let resp = app
11840 .oneshot(
11841 axum::http::Request::builder()
11842 .uri("/api/v1/agents")
11843 .method("POST")
11844 .header("content-type", "application/json")
11845 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11846 .unwrap(),
11847 )
11848 .await
11849 .unwrap();
11850 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11851 }
11852
11853 #[tokio::test]
11854 async fn http_register_agent_duplicate_register_idempotent_preserves_registered_at() {
11855 let state = test_state();
11859 let app = Router::new()
11860 .route("/api/v1/agents", axum_post(register_agent))
11861 .with_state(test_app_state(state.clone()));
11862 let body = serde_json::json!({
11863 "agent_id": "twice",
11864 "agent_type": "human",
11865 "capabilities": ["read"]
11866 });
11867 let r1 = app
11868 .clone()
11869 .oneshot(
11870 axum::http::Request::builder()
11871 .uri("/api/v1/agents")
11872 .method("POST")
11873 .header("content-type", "application/json")
11874 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11875 .unwrap(),
11876 )
11877 .await
11878 .unwrap();
11879 assert_eq!(r1.status(), StatusCode::CREATED);
11880 let r2 = app
11881 .oneshot(
11882 axum::http::Request::builder()
11883 .uri("/api/v1/agents")
11884 .method("POST")
11885 .header("content-type", "application/json")
11886 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11887 .unwrap(),
11888 )
11889 .await
11890 .unwrap();
11891 assert_eq!(r2.status(), StatusCode::CREATED);
11892 let lock = state.lock().await;
11894 let agents = db::list_agents(&lock.0).unwrap();
11895 let twice: Vec<_> = agents.iter().filter(|a| a.agent_id == "twice").collect();
11896 assert_eq!(
11897 twice.len(),
11898 1,
11899 "duplicate register must collapse to one row"
11900 );
11901 }
11902
11903 #[tokio::test]
11904 async fn http_register_agent_capabilities_array_preserved() {
11905 let state = test_state();
11908 let app = Router::new()
11909 .route("/api/v1/agents", axum_post(register_agent))
11910 .with_state(test_app_state(state.clone()));
11911 let body = serde_json::json!({
11912 "agent_id": "capper",
11913 "agent_type": "ai:claude-opus-4.7",
11914 "capabilities": ["search", "store", "recall", "consolidate"]
11915 });
11916 let resp = app
11917 .oneshot(
11918 axum::http::Request::builder()
11919 .uri("/api/v1/agents")
11920 .method("POST")
11921 .header("content-type", "application/json")
11922 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11923 .unwrap(),
11924 )
11925 .await
11926 .unwrap();
11927 assert_eq!(resp.status(), StatusCode::CREATED);
11928 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11929 .await
11930 .unwrap();
11931 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11932 let echoed = v["capabilities"].as_array().unwrap();
11933 assert_eq!(echoed.len(), 4);
11934 let lock = state.lock().await;
11936 let agents = db::list_agents(&lock.0).unwrap();
11937 let me = agents.iter().find(|a| a.agent_id == "capper").unwrap();
11938 assert_eq!(me.capabilities.len(), 4);
11939 assert!(me.capabilities.contains(&"search".to_string()));
11940 assert!(me.capabilities.contains(&"store".to_string()));
11941 assert!(me.capabilities.contains(&"recall".to_string()));
11942 assert!(me.capabilities.contains(&"consolidate".to_string()));
11943 }
11944
11945 #[tokio::test]
11948 async fn http_list_pending_with_pending_actions_returns_them() {
11949 use crate::models::GovernedAction;
11951 let state = test_state();
11952 {
11953 let lock = state.lock().await;
11954 db::queue_pending_action(
11955 &lock.0,
11956 GovernedAction::Store,
11957 "ns-a",
11958 None,
11959 "alice",
11960 &serde_json::json!({"title": "first", "content": "c1"}),
11961 )
11962 .unwrap();
11963 db::queue_pending_action(
11964 &lock.0,
11965 GovernedAction::Store,
11966 "ns-b",
11967 None,
11968 "bob",
11969 &serde_json::json!({"title": "second", "content": "c2"}),
11970 )
11971 .unwrap();
11972 }
11973 let app = Router::new()
11974 .route("/api/v1/pending", axum_get(list_pending))
11975 .with_state(state);
11976 let resp = app
11977 .oneshot(
11978 axum::http::Request::builder()
11979 .uri("/api/v1/pending")
11980 .body(Body::empty())
11981 .unwrap(),
11982 )
11983 .await
11984 .unwrap();
11985 assert_eq!(resp.status(), StatusCode::OK);
11986 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11987 .await
11988 .unwrap();
11989 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11990 assert_eq!(v["count"], 2);
11991 assert_eq!(v["pending"].as_array().unwrap().len(), 2);
11992 }
11993
11994 #[tokio::test]
11995 async fn http_list_pending_filters_by_status_pending() {
11996 use crate::models::GovernedAction;
11997 let state = test_state();
11998 let kept_id = {
11999 let lock = state.lock().await;
12000 let id = db::queue_pending_action(
12002 &lock.0,
12003 GovernedAction::Store,
12004 "ns-keep",
12005 None,
12006 "alice",
12007 &serde_json::json!({"title": "stay", "content": "x"}),
12008 )
12009 .unwrap();
12010 let other = db::queue_pending_action(
12012 &lock.0,
12013 GovernedAction::Store,
12014 "ns-reject",
12015 None,
12016 "alice",
12017 &serde_json::json!({"title": "out", "content": "x"}),
12018 )
12019 .unwrap();
12020 db::decide_pending_action(&lock.0, &other, false, "alice").unwrap();
12021 id
12022 };
12023 let app = Router::new()
12024 .route("/api/v1/pending", axum_get(list_pending))
12025 .with_state(state);
12026 let resp = app
12027 .oneshot(
12028 axum::http::Request::builder()
12029 .uri("/api/v1/pending?status=pending")
12030 .body(Body::empty())
12031 .unwrap(),
12032 )
12033 .await
12034 .unwrap();
12035 assert_eq!(resp.status(), StatusCode::OK);
12036 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12037 .await
12038 .unwrap();
12039 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12040 let items = v["pending"].as_array().unwrap();
12041 assert_eq!(items.len(), 1);
12042 assert_eq!(items[0]["id"], kept_id);
12043 assert_eq!(items[0]["status"], "pending");
12044 }
12045
12046 #[tokio::test]
12047 async fn http_list_pending_filters_by_status_rejected() {
12048 use crate::models::GovernedAction;
12049 let state = test_state();
12050 {
12051 let lock = state.lock().await;
12052 let id = db::queue_pending_action(
12053 &lock.0,
12054 GovernedAction::Store,
12055 "ns-r",
12056 None,
12057 "alice",
12058 &serde_json::json!({"title": "rejected", "content": "x"}),
12059 )
12060 .unwrap();
12061 db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12062 db::queue_pending_action(
12064 &lock.0,
12065 GovernedAction::Store,
12066 "ns-p",
12067 None,
12068 "alice",
12069 &serde_json::json!({"title": "pending", "content": "x"}),
12070 )
12071 .unwrap();
12072 }
12073 let app = Router::new()
12074 .route("/api/v1/pending", axum_get(list_pending))
12075 .with_state(state);
12076 let resp = app
12077 .oneshot(
12078 axum::http::Request::builder()
12079 .uri("/api/v1/pending?status=rejected&limit=10")
12080 .body(Body::empty())
12081 .unwrap(),
12082 )
12083 .await
12084 .unwrap();
12085 assert_eq!(resp.status(), StatusCode::OK);
12086 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12087 .await
12088 .unwrap();
12089 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12090 let items = v["pending"].as_array().unwrap();
12091 assert_eq!(items.len(), 1);
12092 assert_eq!(items[0]["status"], "rejected");
12093 }
12094
12095 #[tokio::test]
12096 async fn http_list_pending_limit_clamped_to_1000() {
12097 let state = test_state();
12100 let app = Router::new()
12101 .route("/api/v1/pending", axum_get(list_pending))
12102 .with_state(state);
12103 let resp = app
12104 .oneshot(
12105 axum::http::Request::builder()
12106 .uri("/api/v1/pending?limit=99999")
12107 .body(Body::empty())
12108 .unwrap(),
12109 )
12110 .await
12111 .unwrap();
12112 assert_eq!(resp.status(), StatusCode::OK);
12113 }
12114
12115 #[tokio::test]
12118 async fn http_approve_pending_happy_path_executes_store() {
12119 use crate::models::GovernedAction;
12122 let state = test_state();
12123 let now_rfc = Utc::now().to_rfc3339();
12124 let pending_id = {
12125 let lock = state.lock().await;
12126 db::queue_pending_action(
12127 &lock.0,
12128 GovernedAction::Store,
12129 "approve-ns",
12130 None,
12131 "alice",
12132 &serde_json::json!({
12133 "id": Uuid::new_v4().to_string(),
12134 "tier": "long",
12135 "namespace": "approve-ns",
12136 "title": "approved-store",
12137 "content": "executed via approval",
12138 "tags": [],
12139 "priority": 5,
12140 "confidence": 1.0,
12141 "source": "api",
12142 "access_count": 0,
12143 "created_at": now_rfc,
12144 "updated_at": now_rfc,
12145 "metadata": {}
12146 }),
12147 )
12148 .unwrap()
12149 };
12150 let app = Router::new()
12151 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12152 .with_state(test_app_state(state.clone()));
12153 let resp = app
12154 .oneshot(
12155 axum::http::Request::builder()
12156 .uri(format!("/api/v1/pending/{pending_id}/approve"))
12157 .method("POST")
12158 .header("x-agent-id", "approver-alice")
12159 .body(Body::empty())
12160 .unwrap(),
12161 )
12162 .await
12163 .unwrap();
12164 assert_eq!(resp.status(), StatusCode::OK);
12165 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12166 .await
12167 .unwrap();
12168 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12169 assert_eq!(v["approved"], true);
12170 assert_eq!(v["executed"], true);
12171 assert_eq!(v["decided_by"], "approver-alice");
12172 let lock = state.lock().await;
12174 let pa = db::get_pending_action(&lock.0, &pending_id)
12175 .unwrap()
12176 .unwrap();
12177 assert_eq!(pa.status, "approved");
12178 assert_eq!(pa.decided_by.as_deref(), Some("approver-alice"));
12179 }
12180
12181 #[tokio::test]
12182 async fn http_approve_pending_invalid_id_format_400() {
12183 let state = test_state();
12187 let app = Router::new()
12188 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12189 .with_state(test_app_state(state));
12190 let resp = app
12191 .oneshot(
12192 axum::http::Request::builder()
12193 .uri("/api/v1/pending/bad%01id/approve")
12194 .method("POST")
12195 .header("x-agent-id", "alice")
12196 .body(Body::empty())
12197 .unwrap(),
12198 )
12199 .await
12200 .unwrap();
12201 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12202 }
12203
12204 #[tokio::test]
12205 async fn http_approve_pending_already_approved_is_rejected() {
12206 use crate::models::GovernedAction;
12209 let state = test_state();
12210 let pid = {
12211 let lock = state.lock().await;
12212 let id = db::queue_pending_action(
12213 &lock.0,
12214 GovernedAction::Store,
12215 "double-approve",
12216 None,
12217 "alice",
12218 &serde_json::json!({
12219 "tier": "long",
12220 "namespace": "double-approve",
12221 "title": "store",
12222 "content": "x",
12223 "tags": [], "priority": 5, "confidence": 1.0,
12224 "source": "api", "metadata": {}
12225 }),
12226 )
12227 .unwrap();
12228 db::decide_pending_action(&lock.0, &id, true, "alice").unwrap();
12229 id
12230 };
12231 let app = Router::new()
12232 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12233 .with_state(test_app_state(state));
12234 let resp = app
12235 .oneshot(
12236 axum::http::Request::builder()
12237 .uri(format!("/api/v1/pending/{pid}/approve"))
12238 .method("POST")
12239 .header("x-agent-id", "alice")
12240 .body(Body::empty())
12241 .unwrap(),
12242 )
12243 .await
12244 .unwrap();
12245 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
12246 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12247 .await
12248 .unwrap();
12249 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12250 let err = v["error"].as_str().unwrap_or("");
12251 assert!(
12252 err.contains("already decided") || err.contains("rejected"),
12253 "expected already-decided message, got {err}"
12254 );
12255 }
12256
12257 #[tokio::test]
12258 async fn http_approve_pending_executor_records_decided_by() {
12259 use crate::models::GovernedAction;
12263 let state = test_state();
12264 let now_rfc = Utc::now().to_rfc3339();
12265 let pid = {
12266 let lock = state.lock().await;
12267 db::queue_pending_action(
12268 &lock.0,
12269 GovernedAction::Store,
12270 "executor-ns",
12271 None,
12272 "requester-bob",
12273 &serde_json::json!({
12274 "id": Uuid::new_v4().to_string(),
12275 "tier": "long",
12276 "namespace": "executor-ns",
12277 "title": "e",
12278 "content": "y",
12279 "tags": [], "priority": 5, "confidence": 1.0,
12280 "source": "api",
12281 "access_count": 0,
12282 "created_at": now_rfc,
12283 "updated_at": now_rfc,
12284 "metadata": {}
12285 }),
12286 )
12287 .unwrap()
12288 };
12289 let app = Router::new()
12290 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12291 .with_state(test_app_state(state.clone()));
12292 let resp = app
12293 .oneshot(
12294 axum::http::Request::builder()
12295 .uri(format!("/api/v1/pending/{pid}/approve"))
12296 .method("POST")
12297 .header("x-agent-id", "executor-claude")
12298 .body(Body::empty())
12299 .unwrap(),
12300 )
12301 .await
12302 .unwrap();
12303 assert_eq!(resp.status(), StatusCode::OK);
12304 let lock = state.lock().await;
12305 let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12306 assert_eq!(pa.requested_by, "requester-bob");
12307 assert_eq!(pa.decided_by.as_deref(), Some("executor-claude"));
12308 assert_eq!(pa.status, "approved");
12309 }
12310
12311 #[tokio::test]
12312 async fn http_approve_pending_returns_memory_id_for_store_payload() {
12313 use crate::models::GovernedAction;
12316 let state = test_state();
12317 let now_rfc = Utc::now().to_rfc3339();
12318 let pid = {
12319 let lock = state.lock().await;
12320 db::queue_pending_action(
12321 &lock.0,
12322 GovernedAction::Store,
12323 "executed-write",
12324 None,
12325 "alice",
12326 &serde_json::json!({
12327 "id": Uuid::new_v4().to_string(),
12328 "tier": "long",
12329 "namespace": "executed-write",
12330 "title": "executed-mem",
12331 "content": "this exists after approval",
12332 "tags": [], "priority": 5, "confidence": 1.0,
12333 "source": "api",
12334 "access_count": 0,
12335 "created_at": now_rfc,
12336 "updated_at": now_rfc,
12337 "metadata": {}
12338 }),
12339 )
12340 .unwrap()
12341 };
12342 let app = Router::new()
12343 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12344 .with_state(test_app_state(state.clone()));
12345 let resp = app
12346 .oneshot(
12347 axum::http::Request::builder()
12348 .uri(format!("/api/v1/pending/{pid}/approve"))
12349 .method("POST")
12350 .header("x-agent-id", "alice")
12351 .body(Body::empty())
12352 .unwrap(),
12353 )
12354 .await
12355 .unwrap();
12356 assert_eq!(resp.status(), StatusCode::OK);
12357 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12358 .await
12359 .unwrap();
12360 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12361 let mem_id = v["memory_id"].as_str().expect("memory_id present");
12362 let lock = state.lock().await;
12363 let mem = db::get(&lock.0, mem_id).unwrap().expect("memory exists");
12364 assert_eq!(mem.title, "executed-mem");
12365 assert_eq!(mem.namespace, "executed-write");
12366 }
12367
12368 #[tokio::test]
12371 async fn http_reject_pending_happy_path_marks_rejected_no_execution() {
12372 use crate::models::GovernedAction;
12375 let state = test_state();
12376 let pid = {
12377 let lock = state.lock().await;
12378 db::queue_pending_action(
12379 &lock.0,
12380 GovernedAction::Store,
12381 "reject-ns",
12382 None,
12383 "alice",
12384 &serde_json::json!({
12385 "tier": "long",
12386 "namespace": "reject-ns",
12387 "title": "blocked",
12388 "content": "must not be created",
12389 "tags": [], "priority": 5, "confidence": 1.0,
12390 "source": "api", "metadata": {}
12391 }),
12392 )
12393 .unwrap()
12394 };
12395 let app = Router::new()
12396 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12397 .with_state(test_app_state(state.clone()));
12398 let resp = app
12399 .oneshot(
12400 axum::http::Request::builder()
12401 .uri(format!("/api/v1/pending/{pid}/reject"))
12402 .method("POST")
12403 .header("x-agent-id", "rejector-alice")
12404 .body(Body::empty())
12405 .unwrap(),
12406 )
12407 .await
12408 .unwrap();
12409 assert_eq!(resp.status(), StatusCode::OK);
12410 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12411 .await
12412 .unwrap();
12413 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12414 assert_eq!(v["rejected"], true);
12415 assert_eq!(v["decided_by"], "rejector-alice");
12416 let lock = state.lock().await;
12417 let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12418 assert_eq!(pa.status, "rejected");
12419 let rows = db::list(
12421 &lock.0,
12422 Some("reject-ns"),
12423 None,
12424 10,
12425 0,
12426 None,
12427 None,
12428 None,
12429 None,
12430 None,
12431 )
12432 .unwrap();
12433 assert!(
12434 rows.is_empty(),
12435 "rejection must not execute the queued payload"
12436 );
12437 }
12438
12439 #[tokio::test]
12440 async fn http_reject_pending_already_rejected_returns_404() {
12441 use crate::models::GovernedAction;
12444 let state = test_state();
12445 let pid = {
12446 let lock = state.lock().await;
12447 let id = db::queue_pending_action(
12448 &lock.0,
12449 GovernedAction::Store,
12450 "double-reject",
12451 None,
12452 "alice",
12453 &serde_json::json!({
12454 "tier": "long",
12455 "namespace": "double-reject",
12456 "title": "x",
12457 "content": "x",
12458 "tags": [], "priority": 5, "confidence": 1.0,
12459 "source": "api", "metadata": {}
12460 }),
12461 )
12462 .unwrap();
12463 db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12464 id
12465 };
12466 let app = Router::new()
12467 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12468 .with_state(test_app_state(state));
12469 let resp = app
12470 .oneshot(
12471 axum::http::Request::builder()
12472 .uri(format!("/api/v1/pending/{pid}/reject"))
12473 .method("POST")
12474 .header("x-agent-id", "alice")
12475 .body(Body::empty())
12476 .unwrap(),
12477 )
12478 .await
12479 .unwrap();
12480 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12481 }
12482
12483 #[tokio::test]
12484 async fn http_reject_pending_invalid_id_format_400() {
12485 let state = test_state();
12488 let app = Router::new()
12489 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12490 .with_state(test_app_state(state));
12491 let resp = app
12492 .oneshot(
12493 axum::http::Request::builder()
12494 .uri("/api/v1/pending/bad%01id/reject")
12495 .method("POST")
12496 .header("x-agent-id", "alice")
12497 .body(Body::empty())
12498 .unwrap(),
12499 )
12500 .await
12501 .unwrap();
12502 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12503 }
12504
12505 #[tokio::test]
12508 async fn http_consolidate_two_into_one_happy_path() {
12509 let state = test_state();
12512 let now = Utc::now().to_rfc3339();
12513 let (id_a, id_b) = {
12514 let lock = state.lock().await;
12515 let mk = |title: &str| Memory {
12516 id: Uuid::new_v4().to_string(),
12517 tier: Tier::Long,
12518 namespace: "merge-ns".into(),
12519 title: title.into(),
12520 content: format!("body for {title}"),
12521 tags: vec![],
12522 priority: 5,
12523 confidence: 1.0,
12524 source: "test".into(),
12525 access_count: 0,
12526 created_at: now.clone(),
12527 updated_at: now.clone(),
12528 last_accessed_at: None,
12529 expires_at: None,
12530 metadata: serde_json::json!({"agent_id": "alice"}),
12531 };
12532 let a = db::insert(&lock.0, &mk("draft-a")).unwrap();
12533 let b = db::insert(&lock.0, &mk("draft-b")).unwrap();
12534 (a, b)
12535 };
12536 let app = Router::new()
12537 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12538 .with_state(test_app_state(state.clone()));
12539 let body = serde_json::json!({
12540 "ids": [id_a, id_b],
12541 "title": "merged-result",
12542 "summary": "a merge of two drafts",
12543 "namespace": "merge-ns",
12544 "tier": "long"
12545 });
12546 let resp = app
12547 .oneshot(
12548 axum::http::Request::builder()
12549 .uri("/api/v1/consolidate")
12550 .method("POST")
12551 .header("content-type", "application/json")
12552 .header("x-agent-id", "consolidator")
12553 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12554 .unwrap(),
12555 )
12556 .await
12557 .unwrap();
12558 assert_eq!(resp.status(), StatusCode::CREATED);
12559 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12560 .await
12561 .unwrap();
12562 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12563 assert_eq!(v["consolidated"], 2);
12564 let new_id = v["id"].as_str().unwrap();
12565 let lock = state.lock().await;
12566 let merged = db::get(&lock.0, new_id).unwrap().unwrap();
12567 assert_eq!(merged.title, "merged-result");
12568 assert_eq!(merged.namespace, "merge-ns");
12569 assert!(db::get(&lock.0, &id_a).unwrap().is_none());
12571 assert!(db::get(&lock.0, &id_b).unwrap().is_none());
12572 }
12573
12574 #[tokio::test]
12575 async fn http_consolidate_single_id_400() {
12576 let state = test_state();
12579 let app = Router::new()
12580 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12581 .with_state(test_app_state(state));
12582 let body = serde_json::json!({
12583 "ids": [Uuid::new_v4().to_string()],
12584 "title": "lone-merge",
12585 "summary": "only one source",
12586 "namespace": "merge-ns"
12587 });
12588 let resp = app
12589 .oneshot(
12590 axum::http::Request::builder()
12591 .uri("/api/v1/consolidate")
12592 .method("POST")
12593 .header("content-type", "application/json")
12594 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12595 .unwrap(),
12596 )
12597 .await
12598 .unwrap();
12599 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12600 }
12601
12602 #[tokio::test]
12603 async fn http_consolidate_invalid_namespace_400() {
12604 let state = test_state();
12606 let app = Router::new()
12607 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12608 .with_state(test_app_state(state));
12609 let body = serde_json::json!({
12610 "ids": [Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
12611 "title": "merge",
12612 "summary": "x",
12613 "namespace": "bad ns"
12614 });
12615 let resp = app
12616 .oneshot(
12617 axum::http::Request::builder()
12618 .uri("/api/v1/consolidate")
12619 .method("POST")
12620 .header("content-type", "application/json")
12621 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12622 .unwrap(),
12623 )
12624 .await
12625 .unwrap();
12626 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12627 }
12628
12629 #[tokio::test]
12630 async fn http_consolidate_invalid_agent_id_400() {
12631 let state = test_state();
12633 let id_a = Uuid::new_v4().to_string();
12634 let id_b = Uuid::new_v4().to_string();
12635 let app = Router::new()
12636 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12637 .with_state(test_app_state(state));
12638 let body = serde_json::json!({
12639 "ids": [id_a, id_b],
12640 "title": "merge",
12641 "summary": "x",
12642 "namespace": "merge-ns"
12643 });
12644 let resp = app
12645 .oneshot(
12646 axum::http::Request::builder()
12647 .uri("/api/v1/consolidate")
12648 .method("POST")
12649 .header("content-type", "application/json")
12650 .header("x-agent-id", "bad agent id")
12651 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12652 .unwrap(),
12653 )
12654 .await
12655 .unwrap();
12656 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12657 }
12658
12659 #[tokio::test]
12660 async fn http_consolidate_max_id_count_cap_exceeded_400() {
12661 let state = test_state();
12663 let ids: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
12664 let app = Router::new()
12665 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12666 .with_state(test_app_state(state));
12667 let body = serde_json::json!({
12668 "ids": ids,
12669 "title": "too-many",
12670 "summary": "x",
12671 "namespace": "merge-ns"
12672 });
12673 let resp = app
12674 .oneshot(
12675 axum::http::Request::builder()
12676 .uri("/api/v1/consolidate")
12677 .method("POST")
12678 .header("content-type", "application/json")
12679 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12680 .unwrap(),
12681 )
12682 .await
12683 .unwrap();
12684 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12685 }
12686
12687 #[tokio::test]
12688 async fn http_consolidate_missing_source_500() {
12689 let state = test_state();
12693 let id_a = Uuid::new_v4().to_string();
12694 let id_b = Uuid::new_v4().to_string();
12695 let app = Router::new()
12696 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12697 .with_state(test_app_state(state));
12698 let body = serde_json::json!({
12699 "ids": [id_a, id_b],
12700 "title": "merge",
12701 "summary": "x",
12702 "namespace": "merge-ns"
12703 });
12704 let resp = app
12705 .oneshot(
12706 axum::http::Request::builder()
12707 .uri("/api/v1/consolidate")
12708 .method("POST")
12709 .header("content-type", "application/json")
12710 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12711 .unwrap(),
12712 )
12713 .await
12714 .unwrap();
12715 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
12716 }
12717
12718 #[tokio::test]
12721 async fn http_contradictions_empty_no_pairs() {
12722 let state = test_state();
12725 let app = Router::new()
12726 .route("/api/v1/contradictions", axum_get(detect_contradictions))
12727 .with_state(state);
12728 let resp = app
12729 .oneshot(
12730 axum::http::Request::builder()
12731 .uri("/api/v1/contradictions?namespace=empty-ns")
12732 .body(Body::empty())
12733 .unwrap(),
12734 )
12735 .await
12736 .unwrap();
12737 assert_eq!(resp.status(), StatusCode::OK);
12738 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12739 .await
12740 .unwrap();
12741 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12742 assert_eq!(v["memories"].as_array().unwrap().len(), 0);
12743 assert_eq!(v["links"].as_array().unwrap().len(), 0);
12744 }
12745
12746 #[tokio::test]
12747 async fn http_contradictions_synthesizes_links_for_same_title() {
12748 let state = test_state();
12751 let now = Utc::now().to_rfc3339();
12752 {
12753 let lock = state.lock().await;
12754 let mk = |title: &str, content: &str| Memory {
12756 id: Uuid::new_v4().to_string(),
12757 tier: Tier::Long,
12758 namespace: "contradict-ns".into(),
12759 title: title.into(),
12760 content: content.into(),
12761 tags: vec![],
12762 priority: 5,
12763 confidence: 1.0,
12764 source: "api".into(),
12765 access_count: 0,
12766 created_at: now.clone(),
12767 updated_at: now.clone(),
12768 last_accessed_at: None,
12769 expires_at: None,
12770 metadata: serde_json::json!({"topic": "earth-shape"}),
12771 };
12772 db::insert(&lock.0, &mk("alice-says", "earth is round")).unwrap();
12773 db::insert(&lock.0, &mk("bob-says", "earth is flat")).unwrap();
12774 }
12775 let app = Router::new()
12776 .route("/api/v1/contradictions", axum_get(detect_contradictions))
12777 .with_state(state);
12778 let resp = app
12779 .oneshot(
12780 axum::http::Request::builder()
12781 .uri("/api/v1/contradictions?namespace=contradict-ns&topic=earth-shape")
12782 .body(Body::empty())
12783 .unwrap(),
12784 )
12785 .await
12786 .unwrap();
12787 assert_eq!(resp.status(), StatusCode::OK);
12788 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12789 .await
12790 .unwrap();
12791 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12792 let memories = v["memories"].as_array().unwrap();
12793 assert_eq!(memories.len(), 2);
12794 let links = v["links"].as_array().unwrap();
12795 assert!(links.iter().any(|l| {
12796 l["relation"].as_str() == Some("contradicts")
12797 && l["synthesized"].as_bool() == Some(true)
12798 }));
12799 }
12800
12801 #[tokio::test]
12802 async fn http_contradictions_namespace_filter_isolates_results() {
12803 let state = test_state();
12806 let now = Utc::now().to_rfc3339();
12807 {
12808 let lock = state.lock().await;
12809 let mk = |ns: &str, content: &str| Memory {
12810 id: Uuid::new_v4().to_string(),
12811 tier: Tier::Long,
12812 namespace: ns.into(),
12813 title: "shared-topic".into(),
12814 content: content.into(),
12815 tags: vec![],
12816 priority: 5,
12817 confidence: 1.0,
12818 source: "api".into(),
12819 access_count: 0,
12820 created_at: now.clone(),
12821 updated_at: now.clone(),
12822 last_accessed_at: None,
12823 expires_at: None,
12824 metadata: serde_json::json!({}),
12825 };
12826 db::insert(&lock.0, &mk("ns-iso-a", "first opinion")).unwrap();
12827 db::insert(&lock.0, &mk("ns-iso-b", "different opinion")).unwrap();
12828 }
12829 let app = Router::new()
12830 .route("/api/v1/contradictions", axum_get(detect_contradictions))
12831 .with_state(state);
12832 let resp = app
12833 .oneshot(
12834 axum::http::Request::builder()
12835 .uri("/api/v1/contradictions?namespace=ns-iso-a")
12836 .body(Body::empty())
12837 .unwrap(),
12838 )
12839 .await
12840 .unwrap();
12841 assert_eq!(resp.status(), StatusCode::OK);
12842 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12843 .await
12844 .unwrap();
12845 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12846 let memories = v["memories"].as_array().unwrap();
12847 assert_eq!(memories.len(), 1, "ns filter must isolate results");
12848 assert_eq!(memories[0]["namespace"], "ns-iso-a");
12849 }
12850
12851 #[tokio::test]
12852 async fn http_contradictions_invalid_namespace_400() {
12853 let state = test_state();
12856 let app = Router::new()
12857 .route("/api/v1/contradictions", axum_get(detect_contradictions))
12858 .with_state(state);
12859 let resp = app
12860 .oneshot(
12861 axum::http::Request::builder()
12862 .uri("/api/v1/contradictions?namespace=bad%20ns")
12863 .body(Body::empty())
12864 .unwrap(),
12865 )
12866 .await
12867 .unwrap();
12868 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12869 }
12870
12871 #[tokio::test]
12874 async fn http_capabilities_returns_expected_shape() {
12875 let state = test_state();
12878 let app = Router::new()
12879 .route("/api/v1/capabilities", axum_get(get_capabilities))
12880 .with_state(test_app_state(state));
12881 let resp = app
12882 .oneshot(
12883 axum::http::Request::builder()
12884 .uri("/api/v1/capabilities")
12885 .body(Body::empty())
12886 .unwrap(),
12887 )
12888 .await
12889 .unwrap();
12890 assert_eq!(resp.status(), StatusCode::OK);
12891 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12892 .await
12893 .unwrap();
12894 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12895 assert!(v.get("tier").is_some(), "missing `tier`");
12896 assert!(v.get("version").is_some(), "missing `version`");
12897 assert!(v.get("features").is_some(), "missing `features`");
12898 assert!(v.get("models").is_some(), "missing `models`");
12899 assert_eq!(v["features"]["keyword_search"], true);
12901 assert_eq!(v["features"]["semantic_search"], false);
12902 assert_eq!(v["features"]["query_expansion"], false);
12903 }
12904
12905 #[tokio::test]
12909 async fn http_capabilities_v2_schema_includes_all_blocks() {
12910 let state = test_state();
12911 let app = Router::new()
12912 .route("/api/v1/capabilities", axum_get(get_capabilities))
12913 .with_state(test_app_state(state));
12914 let resp = app
12915 .oneshot(
12916 axum::http::Request::builder()
12917 .uri("/api/v1/capabilities")
12918 .body(Body::empty())
12919 .unwrap(),
12920 )
12921 .await
12922 .unwrap();
12923 assert_eq!(resp.status(), StatusCode::OK);
12924 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12925 .await
12926 .unwrap();
12927 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12928
12929 assert_eq!(v["schema_version"], "2");
12930
12931 assert!(v["permissions"].is_object());
12932 assert_eq!(v["permissions"]["mode"], "ask");
12933 assert!(v["permissions"]["active_rules"].is_number());
12934 assert!(v["permissions"]["rule_summary"].is_array());
12935
12936 assert!(v["hooks"].is_object());
12937 assert!(v["hooks"]["registered_count"].is_number());
12938 assert!(v["hooks"]["by_event"].is_object());
12939
12940 assert!(v["compaction"].is_object());
12941 assert_eq!(v["compaction"]["enabled"], false);
12942
12943 assert!(v["approval"].is_object());
12944 assert!(v["approval"]["pending_requests"].is_number());
12945 assert_eq!(v["approval"]["default_timeout_seconds"], 30);
12946
12947 assert!(v["transcripts"].is_object());
12948 assert_eq!(v["transcripts"]["enabled"], false);
12949 }
12950
12951 #[tokio::test]
12952 async fn http_capabilities_version_matches_pkg_version() {
12953 let state = test_state();
12956 let app = Router::new()
12957 .route("/api/v1/capabilities", axum_get(get_capabilities))
12958 .with_state(test_app_state(state));
12959 let resp = app
12960 .oneshot(
12961 axum::http::Request::builder()
12962 .uri("/api/v1/capabilities")
12963 .body(Body::empty())
12964 .unwrap(),
12965 )
12966 .await
12967 .unwrap();
12968 assert_eq!(resp.status(), StatusCode::OK);
12969 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12970 .await
12971 .unwrap();
12972 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12973 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
12974 assert_eq!(v["tier"], "keyword");
12975 }
12976 async fn h8d_spawn_mock_peer(
12997 behaviour: H8dPeerBehaviour,
12998 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
12999 use std::sync::atomic::{AtomicUsize, Ordering};
13000 use tokio::net::TcpListener;
13001
13002 let count = Arc::new(AtomicUsize::new(0));
13003 let count_for_peer = count.clone();
13004 #[derive(Clone)]
13005 struct PeerState {
13006 count: Arc<AtomicUsize>,
13007 behaviour: H8dPeerBehaviour,
13008 }
13009 async fn handler(
13010 axum::extract::State(s): axum::extract::State<PeerState>,
13011 Json(_body): Json<serde_json::Value>,
13012 ) -> (StatusCode, Json<serde_json::Value>) {
13013 s.count.fetch_add(1, Ordering::Relaxed);
13014 match s.behaviour {
13015 H8dPeerBehaviour::Ack => (
13016 StatusCode::OK,
13017 Json(json!({"applied": 1, "noop": 0, "skipped": 0})),
13018 ),
13019 H8dPeerBehaviour::Fail500 => (
13020 StatusCode::INTERNAL_SERVER_ERROR,
13021 Json(json!({"error": "stub failure"})),
13022 ),
13023 H8dPeerBehaviour::Fail503 => (
13024 StatusCode::SERVICE_UNAVAILABLE,
13025 Json(json!({"error": "stub unavailable"})),
13026 ),
13027 H8dPeerBehaviour::Fail400 => (
13028 StatusCode::BAD_REQUEST,
13029 Json(json!({"error": "stub bad request"})),
13030 ),
13031 H8dPeerBehaviour::Hang => {
13032 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
13033 (StatusCode::OK, Json(json!({"applied": 1})))
13034 }
13035 }
13036 }
13037 let app = Router::new()
13038 .route("/api/v1/sync/push", axum_post(handler))
13039 .with_state(PeerState {
13040 count: count_for_peer,
13041 behaviour,
13042 });
13043 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
13044 let addr = listener.local_addr().unwrap();
13045 tokio::spawn(async move {
13046 axum::serve(listener, app).await.ok();
13047 });
13048 (format!("http://{addr}"), count)
13049 }
13050
13051 #[derive(Clone, Copy)]
13052 enum H8dPeerBehaviour {
13053 Ack,
13055 Fail500,
13057 Fail503,
13059 Fail400,
13061 Hang,
13064 }
13065
13066 fn h8d_app_state_with_fed(
13070 db: Db,
13071 peer_urls: Vec<String>,
13072 w: usize,
13073 timeout_ms: u64,
13074 ) -> AppState {
13075 let fed = crate::federation::FederationConfig::build(
13076 w,
13077 &peer_urls,
13078 std::time::Duration::from_millis(timeout_ms),
13079 None,
13080 None,
13081 None,
13082 "ai:h8d-test".to_string(),
13083 )
13084 .unwrap()
13085 .expect("federation must be built");
13086 AppState {
13087 db,
13088 embedder: Arc::new(None),
13089 vector_index: Arc::new(Mutex::new(None)),
13090 federation: Arc::new(Some(fed)),
13091 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
13092 scoring: Arc::new(crate::config::ResolvedScoring::default()),
13093 }
13094 }
13095
13096 #[tokio::test]
13099 async fn http_get_namespace_standard_qs_returns_standard_for_existing_ns() {
13100 let state = test_state();
13104 let app_state = test_app_state(state.clone());
13105 let set_router = Router::new()
13106 .route(
13107 "/api/v1/namespaces/{ns}/standard",
13108 axum_post(set_namespace_standard),
13109 )
13110 .with_state(app_state);
13111 let resp = set_router
13112 .oneshot(
13113 axum::http::Request::builder()
13114 .uri("/api/v1/namespaces/qs-existing/standard")
13115 .method("POST")
13116 .header("content-type", "application/json")
13117 .body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
13118 .unwrap(),
13119 )
13120 .await
13121 .unwrap();
13122 assert_eq!(resp.status(), StatusCode::CREATED);
13123
13124 let get_router = Router::new()
13127 .route(
13128 "/api/v1/namespaces",
13129 axum::routing::get(get_namespace_standard_qs),
13130 )
13131 .with_state(state);
13132 let resp = get_router
13133 .oneshot(
13134 axum::http::Request::builder()
13135 .uri("/api/v1/namespaces?namespace=qs-existing")
13136 .body(Body::empty())
13137 .unwrap(),
13138 )
13139 .await
13140 .unwrap();
13141 assert_eq!(resp.status(), StatusCode::OK);
13142 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13143 .await
13144 .unwrap();
13145 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13146 assert_eq!(v["namespace"], "qs-existing");
13147 assert!(v["standard_id"].is_string(), "standard_id must be set");
13148 }
13149
13150 #[tokio::test]
13151 async fn http_get_namespace_standard_qs_returns_null_for_missing_ns_record() {
13152 let state = test_state();
13156 let app = Router::new()
13157 .route(
13158 "/api/v1/namespaces",
13159 axum::routing::get(get_namespace_standard_qs),
13160 )
13161 .with_state(state);
13162 let resp = app
13163 .oneshot(
13164 axum::http::Request::builder()
13165 .uri("/api/v1/namespaces?namespace=qs-never-set")
13166 .body(Body::empty())
13167 .unwrap(),
13168 )
13169 .await
13170 .unwrap();
13171 assert_eq!(resp.status(), StatusCode::OK);
13172 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13173 .await
13174 .unwrap();
13175 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13176 assert_eq!(v["namespace"], "qs-never-set");
13177 assert!(
13178 v["standard_id"].is_null(),
13179 "standard_id must be null for an unset namespace"
13180 );
13181 }
13182
13183 #[tokio::test]
13184 async fn http_get_namespace_standard_qs_falls_through_to_list_on_missing_param() {
13185 let state = test_state();
13190 let app = Router::new()
13191 .route(
13192 "/api/v1/namespaces",
13193 axum::routing::get(get_namespace_standard_qs),
13194 )
13195 .with_state(state);
13196 let resp = app
13197 .oneshot(
13198 axum::http::Request::builder()
13199 .uri("/api/v1/namespaces")
13200 .body(Body::empty())
13201 .unwrap(),
13202 )
13203 .await
13204 .unwrap();
13205 assert_eq!(resp.status(), StatusCode::OK);
13206 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13207 .await
13208 .unwrap();
13209 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13210 assert!(
13211 v["namespaces"].is_array(),
13212 "fallthrough must produce the list shape, got {v:?}"
13213 );
13214 }
13215
13216 #[tokio::test]
13217 async fn http_get_namespace_standard_qs_inherit_flag_returns_chain() {
13218 let state = test_state();
13221 let app = Router::new()
13222 .route(
13223 "/api/v1/namespaces",
13224 axum::routing::get(get_namespace_standard_qs),
13225 )
13226 .with_state(state);
13227 let resp = app
13228 .oneshot(
13229 axum::http::Request::builder()
13230 .uri("/api/v1/namespaces?namespace=child&inherit=true")
13231 .body(Body::empty())
13232 .unwrap(),
13233 )
13234 .await
13235 .unwrap();
13236 assert_eq!(resp.status(), StatusCode::OK);
13237 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13238 .await
13239 .unwrap();
13240 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13241 assert!(v["chain"].is_array(), "inherit must surface the chain");
13242 assert!(v["standards"].is_array());
13243 }
13244
13245 #[tokio::test]
13246 async fn http_get_namespace_standard_qs_invalid_namespace_returns_400() {
13247 let state = test_state();
13251 let app = Router::new()
13252 .route(
13253 "/api/v1/namespaces",
13254 axum::routing::get(get_namespace_standard_qs),
13255 )
13256 .with_state(state);
13257 let resp = app
13259 .oneshot(
13260 axum::http::Request::builder()
13261 .uri("/api/v1/namespaces?namespace=bad%20ns")
13262 .body(Body::empty())
13263 .unwrap(),
13264 )
13265 .await
13266 .unwrap();
13267 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13268 }
13269
13270 #[tokio::test]
13273 async fn http_set_namespace_standard_qs_happy_path_creates_placeholder() {
13274 let state = test_state();
13278 let app = Router::new()
13279 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13280 .with_state(test_app_state(state.clone()));
13281 let body = json!({"namespace": "qs-set-happy"});
13282 let resp = app
13283 .oneshot(
13284 axum::http::Request::builder()
13285 .uri("/api/v1/namespaces")
13286 .method("POST")
13287 .header("content-type", "application/json")
13288 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13289 .unwrap(),
13290 )
13291 .await
13292 .unwrap();
13293 assert_eq!(resp.status(), StatusCode::CREATED);
13294 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13295 .await
13296 .unwrap();
13297 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13298 assert_eq!(v["namespace"], "qs-set-happy");
13299 assert_eq!(v["set"], true);
13300 assert!(v["standard_id"].is_string());
13301 }
13302
13303 #[tokio::test]
13304 async fn http_set_namespace_standard_qs_missing_namespace_returns_400() {
13305 let state = test_state();
13308 let app = Router::new()
13309 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13310 .with_state(test_app_state(state));
13311 let body = json!({"governance": {"approver": "human"}});
13312 let resp = app
13313 .oneshot(
13314 axum::http::Request::builder()
13315 .uri("/api/v1/namespaces")
13316 .method("POST")
13317 .header("content-type", "application/json")
13318 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13319 .unwrap(),
13320 )
13321 .await
13322 .unwrap();
13323 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13324 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13325 .await
13326 .unwrap();
13327 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13328 assert!(
13329 v["error"].as_str().unwrap_or("").contains("namespace"),
13330 "error must mention the missing namespace, got {v:?}"
13331 );
13332 }
13333
13334 #[tokio::test]
13335 async fn http_set_namespace_standard_qs_invalid_governance_returns_400() {
13336 let state = test_state();
13339 let mem_id = {
13340 let lock = state.lock().await;
13341 let now = Utc::now().to_rfc3339();
13342 let mem = Memory {
13343 id: Uuid::new_v4().to_string(),
13344 tier: Tier::Long,
13345 namespace: "qs-set-bad-policy".into(),
13346 title: "anchor".into(),
13347 content: "anchor".into(),
13348 tags: vec![],
13349 priority: 5,
13350 confidence: 1.0,
13351 source: "test".into(),
13352 access_count: 0,
13353 created_at: now.clone(),
13354 updated_at: now,
13355 last_accessed_at: None,
13356 expires_at: None,
13357 metadata: serde_json::json!({}),
13358 };
13359 db::insert(&lock.0, &mem).unwrap()
13360 };
13361 let app = Router::new()
13362 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13363 .with_state(test_app_state(state));
13364 let body = json!({
13366 "namespace": "qs-set-bad-policy",
13367 "id": mem_id,
13368 "governance": {
13369 "approver": {"consensus": 0},
13370 "write": "approve",
13371 "promote": "log",
13372 "delete": "log"
13373 }
13374 });
13375 let resp = app
13376 .oneshot(
13377 axum::http::Request::builder()
13378 .uri("/api/v1/namespaces")
13379 .method("POST")
13380 .header("content-type", "application/json")
13381 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13382 .unwrap(),
13383 )
13384 .await
13385 .unwrap();
13386 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13387 }
13388
13389 #[tokio::test]
13390 async fn http_set_namespace_standard_qs_nested_standard_payload_works() {
13391 let state = test_state();
13395 let app = Router::new()
13396 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13397 .with_state(test_app_state(state));
13398 let body = json!({"standard": {"namespace": "qs-nested-ns"}});
13399 let resp = app
13400 .oneshot(
13401 axum::http::Request::builder()
13402 .uri("/api/v1/namespaces")
13403 .method("POST")
13404 .header("content-type", "application/json")
13405 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13406 .unwrap(),
13407 )
13408 .await
13409 .unwrap();
13410 assert_eq!(resp.status(), StatusCode::CREATED);
13411 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13412 .await
13413 .unwrap();
13414 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13415 assert_eq!(v["namespace"], "qs-nested-ns");
13416 }
13417
13418 #[tokio::test]
13421 async fn http_clear_namespace_standard_qs_happy_path_after_set() {
13422 let state = test_state();
13424 let app_state = test_app_state(state.clone());
13425 let set_router = Router::new()
13426 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13427 .with_state(app_state.clone());
13428 let _ = set_router
13429 .oneshot(
13430 axum::http::Request::builder()
13431 .uri("/api/v1/namespaces")
13432 .method("POST")
13433 .header("content-type", "application/json")
13434 .body(Body::from(
13435 serde_json::to_vec(&json!({"namespace": "qs-clear-happy"})).unwrap(),
13436 ))
13437 .unwrap(),
13438 )
13439 .await
13440 .unwrap();
13441
13442 let clear_router = Router::new()
13443 .route(
13444 "/api/v1/namespaces",
13445 axum::routing::delete(clear_namespace_standard_qs),
13446 )
13447 .with_state(app_state);
13448 let resp = clear_router
13449 .oneshot(
13450 axum::http::Request::builder()
13451 .uri("/api/v1/namespaces?namespace=qs-clear-happy")
13452 .method("DELETE")
13453 .body(Body::empty())
13454 .unwrap(),
13455 )
13456 .await
13457 .unwrap();
13458 assert_eq!(resp.status(), StatusCode::OK);
13459 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13460 .await
13461 .unwrap();
13462 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13463 assert_eq!(v["namespace"], "qs-clear-happy");
13464 }
13465
13466 #[tokio::test]
13467 async fn http_clear_namespace_standard_qs_idempotent_on_unset() {
13468 let state = test_state();
13472 let app = Router::new()
13473 .route(
13474 "/api/v1/namespaces",
13475 axum::routing::delete(clear_namespace_standard_qs),
13476 )
13477 .with_state(test_app_state(state));
13478 let resp = app
13479 .oneshot(
13480 axum::http::Request::builder()
13481 .uri("/api/v1/namespaces?namespace=qs-clear-noop")
13482 .method("DELETE")
13483 .body(Body::empty())
13484 .unwrap(),
13485 )
13486 .await
13487 .unwrap();
13488 assert_eq!(resp.status(), StatusCode::OK);
13489 }
13490
13491 #[tokio::test]
13492 async fn http_clear_namespace_standard_qs_missing_namespace_returns_400() {
13493 let state = test_state();
13496 let app = Router::new()
13497 .route(
13498 "/api/v1/namespaces",
13499 axum::routing::delete(clear_namespace_standard_qs),
13500 )
13501 .with_state(test_app_state(state));
13502 let resp = app
13503 .oneshot(
13504 axum::http::Request::builder()
13505 .uri("/api/v1/namespaces")
13506 .method("DELETE")
13507 .body(Body::empty())
13508 .unwrap(),
13509 )
13510 .await
13511 .unwrap();
13512 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13513 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13514 .await
13515 .unwrap();
13516 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13517 assert!(
13518 v["error"].as_str().unwrap_or("").contains("namespace"),
13519 "error must mention namespace, got {v:?}"
13520 );
13521 }
13522
13523 #[tokio::test]
13526 async fn http_set_qs_fanout_503_when_all_peers_down() {
13527 let state = test_state();
13530 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13531 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13532 let app = Router::new()
13533 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13534 .with_state(app_state);
13535 let resp = app
13536 .oneshot(
13537 axum::http::Request::builder()
13538 .uri("/api/v1/namespaces")
13539 .method("POST")
13540 .header("content-type", "application/json")
13541 .body(Body::from(
13542 serde_json::to_vec(&json!({"namespace": "qs-fed-down"})).unwrap(),
13543 ))
13544 .unwrap(),
13545 )
13546 .await
13547 .unwrap();
13548 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13549 }
13550
13551 #[tokio::test]
13552 async fn http_set_qs_fanout_503_payload_shape_includes_quorum_fields() {
13553 let state = test_state();
13558 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13559 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13560 let app = Router::new()
13561 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13562 .with_state(app_state);
13563 let resp = app
13564 .oneshot(
13565 axum::http::Request::builder()
13566 .uri("/api/v1/namespaces")
13567 .method("POST")
13568 .header("content-type", "application/json")
13569 .body(Body::from(
13570 serde_json::to_vec(&json!({"namespace": "qs-503-shape"})).unwrap(),
13571 ))
13572 .unwrap(),
13573 )
13574 .await
13575 .unwrap();
13576 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13577 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13578 .await
13579 .unwrap();
13580 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13581 assert_eq!(v["error"], "quorum_not_met");
13582 assert!(v["got"].as_u64().is_some(), "got must be a number");
13583 assert!(v["needed"].as_u64().is_some(), "needed must be a number");
13584 assert!(v["reason"].is_string(), "reason must be a string");
13585 assert_eq!(v["needed"].as_u64().unwrap(), 2);
13587 }
13588
13589 #[tokio::test]
13590 async fn http_set_qs_fanout_503_includes_retry_after_header() {
13591 let state = test_state();
13594 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13595 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13596 let app = Router::new()
13597 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13598 .with_state(app_state);
13599 let resp = app
13600 .oneshot(
13601 axum::http::Request::builder()
13602 .uri("/api/v1/namespaces")
13603 .method("POST")
13604 .header("content-type", "application/json")
13605 .body(Body::from(
13606 serde_json::to_vec(&json!({"namespace": "qs-503-retry-after"})).unwrap(),
13607 ))
13608 .unwrap(),
13609 )
13610 .await
13611 .unwrap();
13612 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13613 let retry = resp
13614 .headers()
13615 .get("retry-after")
13616 .and_then(|v| v.to_str().ok())
13617 .unwrap_or("");
13618 assert_eq!(retry, "2", "503 must include Retry-After: 2");
13619 }
13620
13621 #[tokio::test]
13622 async fn http_set_qs_fanout_quorum_met_with_one_peer_down() {
13623 let state = test_state();
13627 let (peer_up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13628 let (peer_down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13629 let app_state = h8d_app_state_with_fed(state, vec![peer_up, peer_down], 2, 1500);
13630 let app = Router::new()
13631 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13632 .with_state(app_state);
13633 let resp = app
13634 .oneshot(
13635 axum::http::Request::builder()
13636 .uri("/api/v1/namespaces")
13637 .method("POST")
13638 .header("content-type", "application/json")
13639 .body(Body::from(
13640 serde_json::to_vec(&json!({"namespace": "qs-quorum-met"})).unwrap(),
13641 ))
13642 .unwrap(),
13643 )
13644 .await
13645 .unwrap();
13646 assert_eq!(resp.status(), StatusCode::CREATED);
13647 }
13648
13649 #[tokio::test]
13650 async fn http_set_qs_fanout_quorum_not_met_strict_n_equals_w() {
13651 let state = test_state();
13654 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13655 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13656 let app = Router::new()
13657 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13658 .with_state(app_state);
13659 let resp = app
13660 .oneshot(
13661 axum::http::Request::builder()
13662 .uri("/api/v1/namespaces")
13663 .method("POST")
13664 .header("content-type", "application/json")
13665 .body(Body::from(
13666 serde_json::to_vec(&json!({"namespace": "qs-strict-quorum"})).unwrap(),
13667 ))
13668 .unwrap(),
13669 )
13670 .await
13671 .unwrap();
13672 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13673 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13674 .await
13675 .unwrap();
13676 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13677 assert_eq!(v["needed"].as_u64().unwrap(), 2);
13678 assert!(v["got"].as_u64().unwrap() < v["needed"].as_u64().unwrap());
13680 }
13681
13682 #[tokio::test]
13683 async fn http_set_qs_fanout_quorum_w_equals_one_any_success_writes_succeed() {
13684 let state = test_state();
13687 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13688 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 1, 1500);
13689 let app = Router::new()
13690 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13691 .with_state(app_state);
13692 let resp = app
13693 .oneshot(
13694 axum::http::Request::builder()
13695 .uri("/api/v1/namespaces")
13696 .method("POST")
13697 .header("content-type", "application/json")
13698 .body(Body::from(
13699 serde_json::to_vec(&json!({"namespace": "qs-w1-any"})).unwrap(),
13700 ))
13701 .unwrap(),
13702 )
13703 .await
13704 .unwrap();
13705 assert_eq!(resp.status(), StatusCode::CREATED);
13706 }
13707
13708 #[tokio::test]
13709 async fn http_set_qs_fanout_503_when_peer_hangs_past_deadline() {
13710 let state = test_state();
13714 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Hang).await;
13715 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 200);
13716 let app = Router::new()
13717 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13718 .with_state(app_state);
13719 let resp = app
13720 .oneshot(
13721 axum::http::Request::builder()
13722 .uri("/api/v1/namespaces")
13723 .method("POST")
13724 .header("content-type", "application/json")
13725 .body(Body::from(
13726 serde_json::to_vec(&json!({"namespace": "qs-hang"})).unwrap(),
13727 ))
13728 .unwrap(),
13729 )
13730 .await
13731 .unwrap();
13732 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13733 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13734 .await
13735 .unwrap();
13736 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13737 let reason = v["reason"].as_str().unwrap_or("");
13738 assert!(
13739 reason == "timeout" || reason == "unreachable",
13740 "expected timeout/unreachable, got {reason:?}"
13741 );
13742 }
13743
13744 #[tokio::test]
13745 async fn http_set_qs_fanout_503_when_peer_returns_503() {
13746 let state = test_state();
13751 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail503).await;
13752 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13753 let app = Router::new()
13754 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13755 .with_state(app_state);
13756 let resp = app
13757 .oneshot(
13758 axum::http::Request::builder()
13759 .uri("/api/v1/namespaces")
13760 .method("POST")
13761 .header("content-type", "application/json")
13762 .body(Body::from(
13763 serde_json::to_vec(&json!({"namespace": "qs-peer-503"})).unwrap(),
13764 ))
13765 .unwrap(),
13766 )
13767 .await
13768 .unwrap();
13769 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13770 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13771 .await
13772 .unwrap();
13773 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13774 assert_eq!(v["error"], "quorum_not_met");
13775 }
13776
13777 #[tokio::test]
13778 async fn http_set_qs_fanout_503_when_peer_returns_4xx() {
13779 let state = test_state();
13783 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail400).await;
13784 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13785 let app = Router::new()
13786 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13787 .with_state(app_state);
13788 let resp = app
13789 .oneshot(
13790 axum::http::Request::builder()
13791 .uri("/api/v1/namespaces")
13792 .method("POST")
13793 .header("content-type", "application/json")
13794 .body(Body::from(
13795 serde_json::to_vec(&json!({"namespace": "qs-peer-400"})).unwrap(),
13796 ))
13797 .unwrap(),
13798 )
13799 .await
13800 .unwrap();
13801 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13802 }
13803
13804 #[tokio::test]
13805 async fn http_set_qs_fanout_503_partition_minority_fails() {
13806 let state = test_state();
13809 let (up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13810 let (down1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13811 let (down2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13812 let app_state = h8d_app_state_with_fed(state, vec![up, down1, down2], 3, 1500);
13813 let app = Router::new()
13814 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13815 .with_state(app_state);
13816 let resp = app
13817 .oneshot(
13818 axum::http::Request::builder()
13819 .uri("/api/v1/namespaces")
13820 .method("POST")
13821 .header("content-type", "application/json")
13822 .body(Body::from(
13823 serde_json::to_vec(&json!({"namespace": "qs-minority"})).unwrap(),
13824 ))
13825 .unwrap(),
13826 )
13827 .await
13828 .unwrap();
13829 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13830 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13831 .await
13832 .unwrap();
13833 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13834 assert_eq!(v["needed"].as_u64().unwrap(), 3);
13835 assert!(v["got"].as_u64().unwrap() < 3);
13836 }
13837
13838 #[tokio::test]
13839 async fn http_set_qs_fanout_majority_tolerates_minority_partition() {
13840 let state = test_state();
13844 let (up1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13845 let (up2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13846 let (down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13847 let app_state = h8d_app_state_with_fed(state, vec![up1, up2, down], 3, 1500);
13848 let app = Router::new()
13849 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13850 .with_state(app_state);
13851 let resp = app
13852 .oneshot(
13853 axum::http::Request::builder()
13854 .uri("/api/v1/namespaces")
13855 .method("POST")
13856 .header("content-type", "application/json")
13857 .body(Body::from(
13858 serde_json::to_vec(&json!({"namespace": "qs-majority"})).unwrap(),
13859 ))
13860 .unwrap(),
13861 )
13862 .await
13863 .unwrap();
13864 assert_eq!(resp.status(), StatusCode::CREATED);
13865 }
13866
13867 #[tokio::test]
13868 async fn http_clear_qs_fanout_503_when_peer_down() {
13869 let state = test_state();
13874 let local_app_state = test_app_state(state.clone());
13877 let set_router = Router::new()
13878 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13879 .with_state(local_app_state);
13880 let _ = set_router
13881 .oneshot(
13882 axum::http::Request::builder()
13883 .uri("/api/v1/namespaces")
13884 .method("POST")
13885 .header("content-type", "application/json")
13886 .body(Body::from(
13887 serde_json::to_vec(&json!({"namespace": "qs-clear-fed"})).unwrap(),
13888 ))
13889 .unwrap(),
13890 )
13891 .await
13892 .unwrap();
13893
13894 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13895 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13896 let app = Router::new()
13897 .route(
13898 "/api/v1/namespaces",
13899 axum::routing::delete(clear_namespace_standard_qs),
13900 )
13901 .with_state(app_state);
13902 let resp = app
13903 .oneshot(
13904 axum::http::Request::builder()
13905 .uri("/api/v1/namespaces?namespace=qs-clear-fed")
13906 .method("DELETE")
13907 .body(Body::empty())
13908 .unwrap(),
13909 )
13910 .await
13911 .unwrap();
13912 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13913 let retry = resp
13914 .headers()
13915 .get("retry-after")
13916 .and_then(|v| v.to_str().ok())
13917 .unwrap_or("");
13918 assert_eq!(retry, "2", "clear 503 must include Retry-After: 2");
13919 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13920 .await
13921 .unwrap();
13922 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13923 assert_eq!(v["error"], "quorum_not_met");
13924 }
13925
13926 #[tokio::test]
13927 async fn http_set_qs_fanout_no_federation_returns_201_without_peers() {
13928 let state = test_state();
13932 let app = Router::new()
13933 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13934 .with_state(test_app_state(state));
13935 let resp = app
13936 .oneshot(
13937 axum::http::Request::builder()
13938 .uri("/api/v1/namespaces")
13939 .method("POST")
13940 .header("content-type", "application/json")
13941 .body(Body::from(
13942 serde_json::to_vec(&json!({"namespace": "qs-no-fed"})).unwrap(),
13943 ))
13944 .unwrap(),
13945 )
13946 .await
13947 .unwrap();
13948 assert_eq!(resp.status(), StatusCode::CREATED);
13949 }
13950
13951 #[tokio::test]
13952 async fn http_set_qs_fanout_peer_called_at_least_once_on_quorum_failure() {
13953 use std::sync::atomic::Ordering;
13957
13958 let state = test_state();
13959 let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13960 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13961 let app = Router::new()
13962 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13963 .with_state(app_state);
13964 let resp = app
13965 .oneshot(
13966 axum::http::Request::builder()
13967 .uri("/api/v1/namespaces")
13968 .method("POST")
13969 .header("content-type", "application/json")
13970 .body(Body::from(
13971 serde_json::to_vec(&json!({"namespace": "qs-fanout-attempt"})).unwrap(),
13972 ))
13973 .unwrap(),
13974 )
13975 .await
13976 .unwrap();
13977 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13978 for _ in 0..50 {
13980 if count.load(Ordering::Relaxed) >= 1 {
13981 break;
13982 }
13983 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
13984 }
13985 assert!(
13986 count.load(Ordering::Relaxed) >= 1,
13987 "leader must have attempted the fanout POST at least once"
13988 );
13989 }
13990
13991 #[tokio::test]
13992 async fn http_set_qs_fanout_peer_receives_post_on_happy_path() {
13993 use std::sync::atomic::Ordering;
13997
13998 let state = test_state();
13999 let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14000 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14001 let app = Router::new()
14002 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14003 .with_state(app_state);
14004 let resp = app
14005 .oneshot(
14006 axum::http::Request::builder()
14007 .uri("/api/v1/namespaces")
14008 .method("POST")
14009 .header("content-type", "application/json")
14010 .body(Body::from(
14011 serde_json::to_vec(&json!({"namespace": "qs-fanout-happy"})).unwrap(),
14012 ))
14013 .unwrap(),
14014 )
14015 .await
14016 .unwrap();
14017 assert_eq!(resp.status(), StatusCode::CREATED);
14018 for _ in 0..50 {
14023 if count.load(Ordering::Relaxed) >= 1 {
14024 break;
14025 }
14026 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
14027 }
14028 assert!(count.load(Ordering::Relaxed) >= 1);
14029 }
14030
14031 #[test]
14043 fn percent_decode_lossy_passes_through_plain_ascii() {
14044 let s = percent_decode_lossy("hello-world_123");
14045 assert_eq!(s, "hello-world_123");
14046 }
14047
14048 #[test]
14049 fn percent_decode_lossy_decodes_basic_escape() {
14050 let s = percent_decode_lossy("a%20b");
14051 assert_eq!(s, "a b");
14052 }
14053
14054 #[test]
14055 fn percent_decode_lossy_decodes_plus_and_ampersand() {
14056 let s = percent_decode_lossy("a%2Bb%26c");
14058 assert_eq!(s, "a+b&c");
14059 }
14060
14061 #[test]
14062 fn percent_decode_lossy_handles_invalid_hex_passthrough() {
14063 let s = percent_decode_lossy("a%ZZb");
14065 assert_eq!(s, "a%ZZb");
14066 }
14067
14068 #[test]
14069 fn percent_decode_lossy_handles_truncated_escape() {
14070 let s = percent_decode_lossy("a%2");
14072 assert_eq!(s, "a%2");
14073 let s2 = percent_decode_lossy("%");
14074 assert_eq!(s2, "%");
14075 }
14076
14077 #[test]
14078 fn percent_decode_lossy_decodes_full_byte_range() {
14079 let s = percent_decode_lossy("%41%42%43");
14081 assert_eq!(s, "ABC");
14082 }
14083
14084 #[test]
14085 fn percent_decode_lossy_empty_input_returns_empty() {
14086 let s = percent_decode_lossy("");
14087 assert_eq!(s, "");
14088 }
14089
14090 #[test]
14091 fn constant_time_eq_returns_true_for_equal_bytes() {
14092 assert!(constant_time_eq(b"hello", b"hello"));
14093 assert!(constant_time_eq(b"", b""));
14094 }
14095
14096 #[test]
14097 fn constant_time_eq_returns_false_for_different_bytes() {
14098 assert!(!constant_time_eq(b"hello", b"world"));
14099 }
14100
14101 #[test]
14102 fn constant_time_eq_returns_false_for_different_lengths() {
14103 assert!(!constant_time_eq(b"a", b"ab"));
14104 assert!(!constant_time_eq(b"abc", b""));
14105 }
14106
14107 #[test]
14108 fn constant_time_eq_compares_high_bytes_correctly() {
14109 let a = [0x80u8, 0x81, 0x82, 0xFF];
14111 let b = [0x80u8, 0x81, 0x82, 0xFF];
14112 assert!(constant_time_eq(&a, &b));
14113 let c = [0x80u8, 0x81, 0x82, 0xFE];
14114 assert!(!constant_time_eq(&a, &c));
14115 }
14116
14117 #[tokio::test]
14120 async fn api_key_query_param_with_percent_encoded_chars_matches() {
14121 let app = auth_app(Some("a+b"));
14125 let resp = app
14126 .oneshot(
14127 axum::http::Request::builder()
14128 .uri("/api/v1/memories?api_key=a%2Bb")
14129 .body(Body::empty())
14130 .unwrap(),
14131 )
14132 .await
14133 .unwrap();
14134 assert_eq!(resp.status(), StatusCode::OK);
14135 }
14136
14137 #[tokio::test]
14138 async fn api_key_query_param_wrong_value_rejected() {
14139 let app = auth_app(Some("secret"));
14140 let resp = app
14141 .oneshot(
14142 axum::http::Request::builder()
14143 .uri("/api/v1/memories?api_key=wrong")
14144 .body(Body::empty())
14145 .unwrap(),
14146 )
14147 .await
14148 .unwrap();
14149 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14150 }
14151
14152 #[tokio::test]
14153 async fn api_key_query_param_with_other_pairs_still_matches() {
14154 let app = auth_app(Some("secret"));
14158 let resp = app
14159 .oneshot(
14160 axum::http::Request::builder()
14161 .uri("/api/v1/memories?other=val&api_key=secret&trailing=x")
14162 .body(Body::empty())
14163 .unwrap(),
14164 )
14165 .await
14166 .unwrap();
14167 assert_eq!(resp.status(), StatusCode::OK);
14168 }
14169
14170 #[tokio::test]
14171 async fn api_key_header_with_invalid_utf8_falls_through() {
14172 let app = auth_app(Some("secret"));
14176 let bytes = [0x80u8, 0x81u8];
14178 let req = axum::http::Request::builder()
14179 .uri("/api/v1/memories")
14180 .header(
14181 "x-api-key",
14182 axum::http::HeaderValue::from_bytes(&bytes).unwrap(),
14183 )
14184 .body(Body::empty())
14185 .unwrap();
14186 let resp = app.oneshot(req).await.unwrap();
14187 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14188 }
14189
14190 #[tokio::test]
14193 async fn http_health_route_returns_200_with_status_ok() {
14194 let state = test_state();
14195 let app = Router::new()
14196 .route("/api/v1/health", axum_get(health))
14197 .with_state(test_app_state(state));
14198 let resp = app
14199 .oneshot(
14200 axum::http::Request::builder()
14201 .uri("/api/v1/health")
14202 .body(Body::empty())
14203 .unwrap(),
14204 )
14205 .await
14206 .unwrap();
14207 assert_eq!(resp.status(), StatusCode::OK);
14208 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14209 .await
14210 .unwrap();
14211 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14212 assert_eq!(v["status"], "ok");
14213 assert_eq!(v["service"], "ai-memory");
14214 assert_eq!(v["embedder_ready"], false);
14217 assert_eq!(v["federation_enabled"], false);
14218 }
14219
14220 #[tokio::test]
14223 async fn http_prometheus_metrics_returns_text_body() {
14224 let state = test_state();
14225 let app = Router::new()
14226 .route("/api/v1/metrics", axum_get(prometheus_metrics))
14227 .with_state(state);
14228 let resp = app
14229 .oneshot(
14230 axum::http::Request::builder()
14231 .uri("/api/v1/metrics")
14232 .body(Body::empty())
14233 .unwrap(),
14234 )
14235 .await
14236 .unwrap();
14237 assert_eq!(resp.status(), StatusCode::OK);
14238 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14241 .await
14242 .unwrap();
14243 assert!(!bytes.is_empty());
14244 }
14245
14246 #[tokio::test]
14249 async fn http_list_namespaces_returns_seeded_namespaces() {
14250 let state = test_state();
14251 let _ = insert_test_memory(&state, "ns-foo", "t1").await;
14252 let _ = insert_test_memory(&state, "ns-bar", "t2").await;
14253 let app = Router::new()
14254 .route("/api/v1/namespaces", axum_get(list_namespaces))
14255 .with_state(state);
14256 let resp = app
14257 .oneshot(
14258 axum::http::Request::builder()
14259 .uri("/api/v1/namespaces")
14260 .body(Body::empty())
14261 .unwrap(),
14262 )
14263 .await
14264 .unwrap();
14265 assert_eq!(resp.status(), StatusCode::OK);
14266 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14267 .await
14268 .unwrap();
14269 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14270 let ns = v["namespaces"].as_array().expect("namespaces array");
14271 assert!(!ns.is_empty());
14272 }
14273
14274 #[tokio::test]
14277 async fn http_get_taxonomy_no_prefix_returns_tree() {
14278 let state = test_state();
14279 let _ = insert_test_memory(&state, "tax/a", "t1").await;
14280 let _ = insert_test_memory(&state, "tax/b", "t2").await;
14281 let app = Router::new()
14282 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14283 .with_state(state);
14284 let resp = app
14285 .oneshot(
14286 axum::http::Request::builder()
14287 .uri("/api/v1/taxonomy")
14288 .body(Body::empty())
14289 .unwrap(),
14290 )
14291 .await
14292 .unwrap();
14293 assert_eq!(resp.status(), StatusCode::OK);
14294 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14295 .await
14296 .unwrap();
14297 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14298 assert!(v["tree"].is_array() || v["tree"].is_object());
14299 }
14300
14301 #[tokio::test]
14302 async fn http_get_taxonomy_invalid_prefix_returns_400() {
14303 let state = test_state();
14304 let app = Router::new()
14305 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14306 .with_state(state);
14307 let resp = app
14313 .oneshot(
14314 axum::http::Request::builder()
14315 .uri("/api/v1/taxonomy?prefix=foo%2F%2Fbar")
14316 .body(Body::empty())
14317 .unwrap(),
14318 )
14319 .await
14320 .unwrap();
14321 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14322 }
14323
14324 #[tokio::test]
14325 async fn http_get_taxonomy_with_depth_and_limit() {
14326 let state = test_state();
14327 let _ = insert_test_memory(&state, "tax2/a/b", "t").await;
14328 let app = Router::new()
14329 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14330 .with_state(state);
14331 let resp = app
14332 .oneshot(
14333 axum::http::Request::builder()
14334 .uri("/api/v1/taxonomy?prefix=tax2&depth=4&limit=100")
14335 .body(Body::empty())
14336 .unwrap(),
14337 )
14338 .await
14339 .unwrap();
14340 assert_eq!(resp.status(), StatusCode::OK);
14341 }
14342
14343 #[tokio::test]
14346 async fn http_get_memory_invalid_id_returns_400() {
14347 let state = test_state();
14348 let app = Router::new()
14349 .route("/api/v1/memories/{id}", axum_get(get_memory))
14350 .with_state(state);
14351 let big = "a".repeat(200);
14353 let resp = app
14354 .oneshot(
14355 axum::http::Request::builder()
14356 .uri(format!("/api/v1/memories/{big}"))
14357 .body(Body::empty())
14358 .unwrap(),
14359 )
14360 .await
14361 .unwrap();
14362 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14363 }
14364
14365 #[tokio::test]
14366 async fn http_get_memory_unknown_id_returns_404() {
14367 let state = test_state();
14368 let app = Router::new()
14369 .route("/api/v1/memories/{id}", axum_get(get_memory))
14370 .with_state(state);
14371 let id = "deadbeefdeadbeefdeadbeefdeadbeef";
14373 let resp = app
14374 .oneshot(
14375 axum::http::Request::builder()
14376 .uri(format!("/api/v1/memories/{id}"))
14377 .body(Body::empty())
14378 .unwrap(),
14379 )
14380 .await
14381 .unwrap();
14382 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14383 }
14384
14385 #[tokio::test]
14386 async fn http_get_memory_after_insert_returns_payload() {
14387 let state = test_state();
14388 let id = insert_test_memory(&state, "ns-get", "t-get").await;
14389 let app = Router::new()
14390 .route("/api/v1/memories/{id}", axum_get(get_memory))
14391 .with_state(state);
14392 let resp = app
14393 .oneshot(
14394 axum::http::Request::builder()
14395 .uri(format!("/api/v1/memories/{id}"))
14396 .body(Body::empty())
14397 .unwrap(),
14398 )
14399 .await
14400 .unwrap();
14401 assert_eq!(resp.status(), StatusCode::OK);
14402 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14403 .await
14404 .unwrap();
14405 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14406 assert_eq!(v["memory"]["id"], id);
14407 assert!(v["links"].is_array());
14408 }
14409
14410 #[tokio::test]
14413 async fn http_delete_memory_invalid_id_returns_400() {
14414 let state = test_state();
14415 let app = Router::new()
14416 .route(
14417 "/api/v1/memories/{id}",
14418 axum::routing::delete(delete_memory),
14419 )
14420 .with_state(test_app_state(state));
14421 let big = "b".repeat(200);
14422 let resp = app
14423 .oneshot(
14424 axum::http::Request::builder()
14425 .uri(format!("/api/v1/memories/{big}"))
14426 .method("DELETE")
14427 .body(Body::empty())
14428 .unwrap(),
14429 )
14430 .await
14431 .unwrap();
14432 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14433 }
14434
14435 #[tokio::test]
14436 async fn http_delete_memory_unknown_id_returns_404() {
14437 let state = test_state();
14438 let app = Router::new()
14439 .route(
14440 "/api/v1/memories/{id}",
14441 axum::routing::delete(delete_memory),
14442 )
14443 .with_state(test_app_state(state));
14444 let id = "cafebabecafebabecafebabecafebabe";
14445 let resp = app
14446 .oneshot(
14447 axum::http::Request::builder()
14448 .uri(format!("/api/v1/memories/{id}"))
14449 .method("DELETE")
14450 .body(Body::empty())
14451 .unwrap(),
14452 )
14453 .await
14454 .unwrap();
14455 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14456 }
14457
14458 #[tokio::test]
14459 async fn http_delete_memory_happy_path_returns_deleted_true() {
14460 let state = test_state();
14461 let id = insert_test_memory(&state, "ns-del", "t-del").await;
14462 let app = Router::new()
14463 .route(
14464 "/api/v1/memories/{id}",
14465 axum::routing::delete(delete_memory),
14466 )
14467 .with_state(test_app_state(state));
14468 let resp = app
14469 .oneshot(
14470 axum::http::Request::builder()
14471 .uri(format!("/api/v1/memories/{id}"))
14472 .method("DELETE")
14473 .body(Body::empty())
14474 .unwrap(),
14475 )
14476 .await
14477 .unwrap();
14478 assert_eq!(resp.status(), StatusCode::OK);
14479 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14480 .await
14481 .unwrap();
14482 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14483 assert_eq!(v["deleted"], true);
14484 }
14485
14486 #[tokio::test]
14487 async fn http_delete_memory_invalid_x_agent_id_returns_400() {
14488 let state = test_state();
14489 let id = insert_test_memory(&state, "ns-del-bad", "t").await;
14490 let app = Router::new()
14491 .route(
14492 "/api/v1/memories/{id}",
14493 axum::routing::delete(delete_memory),
14494 )
14495 .with_state(test_app_state(state));
14496 let resp = app
14498 .oneshot(
14499 axum::http::Request::builder()
14500 .uri(format!("/api/v1/memories/{id}"))
14501 .method("DELETE")
14502 .header("x-agent-id", "bad agent id")
14503 .body(Body::empty())
14504 .unwrap(),
14505 )
14506 .await
14507 .unwrap();
14508 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14509 }
14510
14511 #[tokio::test]
14514 async fn http_promote_memory_invalid_id_returns_400() {
14515 let state = test_state();
14516 let app = Router::new()
14517 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14518 .with_state(test_app_state(state));
14519 let big = "p".repeat(200);
14520 let resp = app
14521 .oneshot(
14522 axum::http::Request::builder()
14523 .uri(format!("/api/v1/memories/{big}/promote"))
14524 .method("POST")
14525 .body(Body::empty())
14526 .unwrap(),
14527 )
14528 .await
14529 .unwrap();
14530 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14531 }
14532
14533 #[tokio::test]
14534 async fn http_promote_memory_unknown_id_returns_404() {
14535 let state = test_state();
14536 let app = Router::new()
14537 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14538 .with_state(test_app_state(state));
14539 let id = "facefacefacefacefacefacefaceface";
14540 let resp = app
14541 .oneshot(
14542 axum::http::Request::builder()
14543 .uri(format!("/api/v1/memories/{id}/promote"))
14544 .method("POST")
14545 .body(Body::empty())
14546 .unwrap(),
14547 )
14548 .await
14549 .unwrap();
14550 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14551 }
14552
14553 #[tokio::test]
14554 async fn http_promote_memory_happy_path_clears_expires_at() {
14555 let state = test_state();
14556 let id = {
14558 let lock = state.lock().await;
14559 let now = Utc::now();
14560 let mem = Memory {
14561 id: Uuid::new_v4().to_string(),
14562 tier: Tier::Short,
14563 namespace: "ns-promote".into(),
14564 title: "to-promote".into(),
14565 content: "content".into(),
14566 tags: vec![],
14567 priority: 5,
14568 confidence: 1.0,
14569 source: "test".into(),
14570 access_count: 0,
14571 created_at: now.to_rfc3339(),
14572 updated_at: now.to_rfc3339(),
14573 last_accessed_at: None,
14574 expires_at: Some((now + Duration::seconds(3600)).to_rfc3339()),
14575 metadata: serde_json::json!({}),
14576 };
14577 db::insert(&lock.0, &mem).unwrap()
14578 };
14579 let app = Router::new()
14580 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14581 .with_state(test_app_state(state.clone()));
14582 let resp = app
14583 .oneshot(
14584 axum::http::Request::builder()
14585 .uri(format!("/api/v1/memories/{id}/promote"))
14586 .method("POST")
14587 .body(Body::empty())
14588 .unwrap(),
14589 )
14590 .await
14591 .unwrap();
14592 assert_eq!(resp.status(), StatusCode::OK);
14593 let lock = state.lock().await;
14595 let m = db::get(&lock.0, &id).unwrap().unwrap();
14596 assert_eq!(m.tier, Tier::Long);
14597 assert!(m.expires_at.is_none());
14598 }
14599
14600 #[tokio::test]
14603 async fn http_update_memory_unknown_id_returns_404() {
14604 let state = test_state();
14605 let app = Router::new()
14606 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14607 .with_state(test_app_state(state));
14608 let id = "1234567812345678123456781234567a";
14609 let body = serde_json::json!({"title": "new title"});
14610 let resp = app
14611 .oneshot(
14612 axum::http::Request::builder()
14613 .uri(format!("/api/v1/memories/{id}"))
14614 .method("PUT")
14615 .header("content-type", "application/json")
14616 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14617 .unwrap(),
14618 )
14619 .await
14620 .unwrap();
14621 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14622 }
14623
14624 #[tokio::test]
14625 async fn http_update_memory_happy_path_returns_updated_payload() {
14626 let state = test_state();
14627 let id = insert_test_memory(&state, "ns-upd", "old title").await;
14628 let app = Router::new()
14629 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14630 .with_state(test_app_state(state.clone()));
14631 let body = serde_json::json!({"title": "new title", "content": "new content"});
14632 let resp = app
14633 .oneshot(
14634 axum::http::Request::builder()
14635 .uri(format!("/api/v1/memories/{id}"))
14636 .method("PUT")
14637 .header("content-type", "application/json")
14638 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14639 .unwrap(),
14640 )
14641 .await
14642 .unwrap();
14643 assert_eq!(resp.status(), StatusCode::OK);
14644 let lock = state.lock().await;
14645 let m = db::get(&lock.0, &id).unwrap().unwrap();
14646 assert_eq!(m.title, "new title");
14647 assert_eq!(m.content, "new content");
14648 }
14649
14650 #[tokio::test]
14653 async fn http_create_link_happy_path_returns_201() {
14654 let state = test_state();
14655 let src = insert_test_memory(&state, "ns-link", "src").await;
14656 let tgt = insert_test_memory(&state, "ns-link", "tgt").await;
14657 let app = Router::new()
14658 .route("/api/v1/links", axum_post(create_link))
14659 .with_state(test_app_state(state));
14660 let body = serde_json::json!({
14661 "source_id": src,
14662 "target_id": tgt,
14663 "relation": "related_to",
14664 });
14665 let resp = app
14666 .oneshot(
14667 axum::http::Request::builder()
14668 .uri("/api/v1/links")
14669 .method("POST")
14670 .header("content-type", "application/json")
14671 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14672 .unwrap(),
14673 )
14674 .await
14675 .unwrap();
14676 assert_eq!(resp.status(), StatusCode::CREATED);
14677 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14678 .await
14679 .unwrap();
14680 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14681 assert_eq!(v["linked"], true);
14682 }
14683
14684 #[tokio::test]
14685 async fn http_create_link_invalid_link_returns_400() {
14686 let state = test_state();
14687 let app = Router::new()
14688 .route("/api/v1/links", axum_post(create_link))
14689 .with_state(test_app_state(state));
14690 let body = serde_json::json!({
14692 "source_id": "abc",
14693 "target_id": "abc",
14694 "relation": "related_to",
14695 });
14696 let resp = app
14697 .oneshot(
14698 axum::http::Request::builder()
14699 .uri("/api/v1/links")
14700 .method("POST")
14701 .header("content-type", "application/json")
14702 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14703 .unwrap(),
14704 )
14705 .await
14706 .unwrap();
14707 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14708 }
14709
14710 #[tokio::test]
14711 async fn http_get_links_invalid_id_returns_400() {
14712 let state = test_state();
14713 let app = Router::new()
14714 .route("/api/v1/memories/{id}/links", axum_get(get_links))
14715 .with_state(state);
14716 let big = "x".repeat(200);
14717 let resp = app
14718 .oneshot(
14719 axum::http::Request::builder()
14720 .uri(format!("/api/v1/memories/{big}/links"))
14721 .body(Body::empty())
14722 .unwrap(),
14723 )
14724 .await
14725 .unwrap();
14726 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14727 }
14728
14729 #[tokio::test]
14730 async fn http_get_links_after_create_returns_link() {
14731 let state = test_state();
14732 let src = insert_test_memory(&state, "ns-getlinks", "src").await;
14733 let tgt = insert_test_memory(&state, "ns-getlinks", "tgt").await;
14734 {
14735 let lock = state.lock().await;
14736 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
14737 }
14738 let app = Router::new()
14739 .route("/api/v1/memories/{id}/links", axum_get(get_links))
14740 .with_state(state);
14741 let resp = app
14742 .oneshot(
14743 axum::http::Request::builder()
14744 .uri(format!("/api/v1/memories/{src}/links"))
14745 .body(Body::empty())
14746 .unwrap(),
14747 )
14748 .await
14749 .unwrap();
14750 assert_eq!(resp.status(), StatusCode::OK);
14751 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14752 .await
14753 .unwrap();
14754 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14755 let links = v["links"].as_array().expect("links array");
14756 assert!(!links.is_empty());
14757 }
14758
14759 #[tokio::test]
14760 async fn http_delete_link_after_create_returns_deleted_true() {
14761 let state = test_state();
14762 let src = insert_test_memory(&state, "ns-dellink", "src").await;
14763 let tgt = insert_test_memory(&state, "ns-dellink", "tgt").await;
14764 {
14765 let lock = state.lock().await;
14766 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
14767 }
14768 let app = Router::new()
14769 .route("/api/v1/links", axum::routing::delete(delete_link))
14770 .with_state(test_app_state(state));
14771 let body = serde_json::json!({
14772 "source_id": src,
14773 "target_id": tgt,
14774 "relation": "related_to",
14775 });
14776 let resp = app
14777 .oneshot(
14778 axum::http::Request::builder()
14779 .uri("/api/v1/links")
14780 .method("DELETE")
14781 .header("content-type", "application/json")
14782 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14783 .unwrap(),
14784 )
14785 .await
14786 .unwrap();
14787 assert_eq!(resp.status(), StatusCode::OK);
14788 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14789 .await
14790 .unwrap();
14791 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14792 assert_eq!(v["deleted"], true);
14793 }
14794
14795 #[tokio::test]
14798 async fn http_get_stats_with_data_returns_total() {
14799 let state = test_state();
14800 let _ = insert_test_memory(&state, "ns-stats", "t1").await;
14801 let _ = insert_test_memory(&state, "ns-stats", "t2").await;
14802 let app = Router::new()
14803 .route("/api/v1/stats", axum_get(get_stats))
14804 .with_state(state);
14805 let resp = app
14806 .oneshot(
14807 axum::http::Request::builder()
14808 .uri("/api/v1/stats")
14809 .body(Body::empty())
14810 .unwrap(),
14811 )
14812 .await
14813 .unwrap();
14814 assert_eq!(resp.status(), StatusCode::OK);
14815 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14816 .await
14817 .unwrap();
14818 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14819 assert_eq!(v["total"], 2);
14820 }
14821
14822 #[tokio::test]
14823 async fn http_export_memories_with_data_returns_count() {
14824 let state = test_state();
14825 let _ = insert_test_memory(&state, "ns-export", "t1").await;
14826 let _ = insert_test_memory(&state, "ns-export", "t2").await;
14827 let app = Router::new()
14828 .route("/api/v1/export", axum_get(export_memories))
14829 .with_state(state);
14830 let resp = app
14831 .oneshot(
14832 axum::http::Request::builder()
14833 .uri("/api/v1/export")
14834 .body(Body::empty())
14835 .unwrap(),
14836 )
14837 .await
14838 .unwrap();
14839 assert_eq!(resp.status(), StatusCode::OK);
14840 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
14841 .await
14842 .unwrap();
14843 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14844 assert_eq!(v["count"], 2);
14845 assert!(v["exported_at"].is_string());
14846 }
14847
14848 #[tokio::test]
14851 async fn http_import_memories_inserts_valid_rows() {
14852 let state = test_state();
14853 let app = Router::new()
14854 .route("/api/v1/import", axum_post(import_memories))
14855 .with_state(state);
14856 let now = Utc::now().to_rfc3339();
14857 let mem = serde_json::json!({
14858 "id": Uuid::new_v4().to_string(),
14859 "tier": "long",
14860 "namespace": "imported",
14861 "title": "imported-row",
14862 "content": "imported content",
14863 "tags": [],
14864 "priority": 5,
14865 "confidence": 1.0,
14866 "source": "import",
14867 "access_count": 0,
14868 "created_at": now,
14869 "updated_at": now,
14870 "last_accessed_at": null,
14871 "expires_at": null,
14872 "metadata": {},
14873 });
14874 let body = serde_json::json!({"memories": [mem]});
14875 let resp = app
14876 .oneshot(
14877 axum::http::Request::builder()
14878 .uri("/api/v1/import")
14879 .method("POST")
14880 .header("content-type", "application/json")
14881 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14882 .unwrap(),
14883 )
14884 .await
14885 .unwrap();
14886 assert_eq!(resp.status(), StatusCode::OK);
14887 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14888 .await
14889 .unwrap();
14890 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14891 assert_eq!(v["imported"], 1);
14892 }
14893
14894 #[tokio::test]
14897 async fn http_recall_get_invalid_as_agent_returns_400() {
14898 let state = test_state();
14899 let app = Router::new()
14900 .route("/api/v1/recall", axum_get(recall_memories_get))
14901 .with_state(test_app_state(state));
14902 let resp = app
14904 .oneshot(
14905 axum::http::Request::builder()
14906 .uri("/api/v1/recall?context=hello&as_agent=bad%20agent")
14907 .body(Body::empty())
14908 .unwrap(),
14909 )
14910 .await
14911 .unwrap();
14912 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14913 }
14914
14915 #[tokio::test]
14916 async fn http_recall_post_invalid_as_agent_returns_400() {
14917 let state = test_state();
14918 let app = Router::new()
14919 .route("/api/v1/recall", axum_post(recall_memories_post))
14920 .with_state(test_app_state(state));
14921 let body = serde_json::json!({"context": "x", "as_agent": "bad agent"});
14922 let resp = app
14923 .oneshot(
14924 axum::http::Request::builder()
14925 .uri("/api/v1/recall")
14926 .method("POST")
14927 .header("content-type", "application/json")
14928 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14929 .unwrap(),
14930 )
14931 .await
14932 .unwrap();
14933 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14934 }
14935
14936 #[tokio::test]
14937 async fn http_recall_post_zero_budget_tokens_returns_400() {
14938 let state = test_state();
14939 let app = Router::new()
14940 .route("/api/v1/recall", axum_post(recall_memories_post))
14941 .with_state(test_app_state(state));
14942 let body = serde_json::json!({"context": "x", "budget_tokens": 0});
14943 let resp = app
14944 .oneshot(
14945 axum::http::Request::builder()
14946 .uri("/api/v1/recall")
14947 .method("POST")
14948 .header("content-type", "application/json")
14949 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14950 .unwrap(),
14951 )
14952 .await
14953 .unwrap();
14954 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14955 }
14956
14957 #[tokio::test]
14960 async fn http_search_invalid_as_agent_returns_400() {
14961 let state = test_state();
14962 let app = Router::new()
14963 .route("/api/v1/search", axum_get(search_memories))
14964 .with_state(state);
14965 let resp = app
14967 .oneshot(
14968 axum::http::Request::builder()
14969 .uri("/api/v1/search?q=hello&as_agent=bad%20agent")
14970 .body(Body::empty())
14971 .unwrap(),
14972 )
14973 .await
14974 .unwrap();
14975 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14976 }
14977
14978 #[tokio::test]
14981 async fn http_forget_memories_with_nothing_to_match_returns_zero() {
14982 let state = test_state();
14983 let app = Router::new()
14984 .route("/api/v1/forget", axum_post(forget_memories))
14985 .with_state(state);
14986 let body = serde_json::json!({"namespace": "no-such-ns"});
14987 let resp = app
14988 .oneshot(
14989 axum::http::Request::builder()
14990 .uri("/api/v1/forget")
14991 .method("POST")
14992 .header("content-type", "application/json")
14993 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14994 .unwrap(),
14995 )
14996 .await
14997 .unwrap();
14998 assert_eq!(resp.status(), StatusCode::OK);
14999 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15000 .await
15001 .unwrap();
15002 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15003 assert_eq!(v["deleted"], 0);
15004 }
15005
15006 #[tokio::test]
15009 async fn http_run_gc_after_insert_returns_zero_when_nothing_expired() {
15010 let state = test_state();
15011 let _ = insert_test_memory(&state, "gc-ns", "title").await;
15012 let app = Router::new()
15013 .route("/api/v1/gc", axum_post(run_gc))
15014 .with_state(state);
15015 let resp = app
15016 .oneshot(
15017 axum::http::Request::builder()
15018 .uri("/api/v1/gc")
15019 .method("POST")
15020 .body(Body::empty())
15021 .unwrap(),
15022 )
15023 .await
15024 .unwrap();
15025 assert_eq!(resp.status(), StatusCode::OK);
15026 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15027 .await
15028 .unwrap();
15029 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15030 assert_eq!(v["expired_deleted"], 0);
15031 }
15032
15033 #[tokio::test]
15036 async fn http_list_pending_default_limit_returns_count_zero_for_empty() {
15037 let state = test_state();
15038 let app = Router::new()
15039 .route("/api/v1/pending", axum_get(list_pending))
15040 .with_state(state);
15041 let resp = app
15042 .oneshot(
15043 axum::http::Request::builder()
15044 .uri("/api/v1/pending")
15045 .body(Body::empty())
15046 .unwrap(),
15047 )
15048 .await
15049 .unwrap();
15050 assert_eq!(resp.status(), StatusCode::OK);
15051 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15052 .await
15053 .unwrap();
15054 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15055 assert_eq!(v["count"], 0);
15056 }
15057
15058 #[tokio::test]
15061 async fn http_restore_archive_invalid_id_returns_400() {
15062 let state = test_state();
15063 let app = Router::new()
15064 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15065 .with_state(test_app_state(state));
15066 let big = "r".repeat(200);
15067 let resp = app
15068 .oneshot(
15069 axum::http::Request::builder()
15070 .uri(format!("/api/v1/archive/{big}/restore"))
15071 .method("POST")
15072 .body(Body::empty())
15073 .unwrap(),
15074 )
15075 .await
15076 .unwrap();
15077 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15078 }
15079
15080 #[tokio::test]
15081 async fn http_restore_archive_unknown_id_returns_404() {
15082 let state = test_state();
15083 let app = Router::new()
15084 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15085 .with_state(test_app_state(state));
15086 let id = "0123456701234567012345670123456a";
15087 let resp = app
15088 .oneshot(
15089 axum::http::Request::builder()
15090 .uri(format!("/api/v1/archive/{id}/restore"))
15091 .method("POST")
15092 .body(Body::empty())
15093 .unwrap(),
15094 )
15095 .await
15096 .unwrap();
15097 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
15098 }
15099
15100 #[tokio::test]
15101 async fn http_restore_archive_happy_path_returns_restored_true() {
15102 let state = test_state();
15103 let id = insert_test_memory(&state, "ns-restore", "row").await;
15104 {
15105 let lock = state.lock().await;
15106 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15107 }
15108 let app = Router::new()
15109 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15110 .with_state(test_app_state(state));
15111 let resp = app
15112 .oneshot(
15113 axum::http::Request::builder()
15114 .uri(format!("/api/v1/archive/{id}/restore"))
15115 .method("POST")
15116 .body(Body::empty())
15117 .unwrap(),
15118 )
15119 .await
15120 .unwrap();
15121 assert_eq!(resp.status(), StatusCode::OK);
15122 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15123 .await
15124 .unwrap();
15125 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15126 assert_eq!(v["restored"], true);
15127 }
15128
15129 #[tokio::test]
15132 async fn http_entity_get_by_alias_with_namespace_filter_returns_found_false() {
15133 let state = test_state();
15134 let app = Router::new()
15135 .route("/api/v1/entities/by_alias", axum_get(entity_get_by_alias))
15136 .with_state(state);
15137 let resp = app
15138 .oneshot(
15139 axum::http::Request::builder()
15140 .uri("/api/v1/entities/by_alias?alias=Acme&namespace=corp")
15141 .body(Body::empty())
15142 .unwrap(),
15143 )
15144 .await
15145 .unwrap();
15146 assert_eq!(resp.status(), StatusCode::OK);
15147 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15148 .await
15149 .unwrap();
15150 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15151 assert_eq!(v["found"], false);
15152 }
15153
15154 #[tokio::test]
15157 async fn http_kg_timeline_with_valid_since_and_until_succeeds() {
15158 let state = test_state();
15159 let id = insert_test_memory(&state, "kg-tl", "src").await;
15160 let app = Router::new()
15161 .route("/api/v1/kg/timeline", axum_get(kg_timeline))
15162 .with_state(state);
15163 let resp = app
15164 .oneshot(
15165 axum::http::Request::builder()
15166 .uri(format!(
15167 "/api/v1/kg/timeline?source_id={id}&since=2020-01-01T00:00:00Z&until=2030-01-01T00:00:00Z&limit=100"
15168 ))
15169 .body(Body::empty())
15170 .unwrap(),
15171 )
15172 .await
15173 .unwrap();
15174 assert_eq!(resp.status(), StatusCode::OK);
15175 }
15176
15177 #[tokio::test]
15180 async fn http_session_start_with_namespace_returns_session_id() {
15181 let state = test_state();
15182 let _ = insert_test_memory(&state, "session-ns", "row").await;
15183 let app = Router::new()
15184 .route("/api/v1/session/start", axum_post(session_start))
15185 .with_state(state);
15186 let body =
15187 serde_json::json!({"namespace": "session-ns", "limit": 5, "agent_id": "ai:tester"});
15188 let resp = app
15189 .oneshot(
15190 axum::http::Request::builder()
15191 .uri("/api/v1/session/start")
15192 .method("POST")
15193 .header("content-type", "application/json")
15194 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15195 .unwrap(),
15196 )
15197 .await
15198 .unwrap();
15199 assert_eq!(resp.status(), StatusCode::OK);
15200 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15201 .await
15202 .unwrap();
15203 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15204 assert!(v["session_id"].is_string());
15205 assert_eq!(v["agent_id"], "ai:tester");
15206 }
15207
15208 #[tokio::test]
15211 async fn http_notify_missing_payload_and_content_returns_400() {
15212 let state = test_state();
15213 let app = Router::new()
15214 .route("/api/v1/notify", axum_post(notify))
15215 .with_state(test_app_state(state));
15216 let body = serde_json::json!({
15217 "target_agent_id": "ai:bob",
15218 "title": "ping",
15219 });
15220 let resp = app
15221 .oneshot(
15222 axum::http::Request::builder()
15223 .uri("/api/v1/notify")
15224 .method("POST")
15225 .header("x-agent-id", "ai:alice")
15226 .header("content-type", "application/json")
15227 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15228 .unwrap(),
15229 )
15230 .await
15231 .unwrap();
15232 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15233 }
15234
15235 #[tokio::test]
15236 async fn http_notify_with_payload_field_returns_201() {
15237 let state = test_state();
15238 {
15240 let lock = state.lock().await;
15241 db::register_agent(&lock.0, "ai:alice", "ai:human", &[]).unwrap();
15242 db::register_agent(&lock.0, "ai:bob", "ai:human", &[]).unwrap();
15243 }
15244 let app = Router::new()
15245 .route("/api/v1/notify", axum_post(notify))
15246 .with_state(test_app_state(state));
15247 let body = serde_json::json!({
15248 "target_agent_id": "ai:bob",
15249 "title": "ping",
15250 "payload": "hi bob",
15251 });
15252 let resp = app
15253 .oneshot(
15254 axum::http::Request::builder()
15255 .uri("/api/v1/notify")
15256 .method("POST")
15257 .header("x-agent-id", "ai:alice")
15258 .header("content-type", "application/json")
15259 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15260 .unwrap(),
15261 )
15262 .await
15263 .unwrap();
15264 assert_eq!(resp.status(), StatusCode::CREATED);
15265 }
15266
15267 #[tokio::test]
15270 async fn http_subscribe_missing_url_and_namespace_returns_400() {
15271 let state = test_state();
15272 let app = Router::new()
15273 .route("/api/v1/subscribe", axum_post(subscribe))
15274 .with_state(test_app_state(state));
15275 let body = serde_json::json!({"agent_id": "ai:alice"});
15277 let resp = app
15278 .oneshot(
15279 axum::http::Request::builder()
15280 .uri("/api/v1/subscribe")
15281 .method("POST")
15282 .header("content-type", "application/json")
15283 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15284 .unwrap(),
15285 )
15286 .await
15287 .unwrap();
15288 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15289 }
15290
15291 #[tokio::test]
15292 async fn http_subscribe_with_namespace_synthesizes_loopback_url_and_returns_201() {
15293 let state = test_state();
15294 let app = Router::new()
15295 .route("/api/v1/subscribe", axum_post(subscribe))
15296 .with_state(test_app_state(state));
15297 let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15298 let resp = app
15299 .oneshot(
15300 axum::http::Request::builder()
15301 .uri("/api/v1/subscribe")
15302 .method("POST")
15303 .header("content-type", "application/json")
15304 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15305 .unwrap(),
15306 )
15307 .await
15308 .unwrap();
15309 assert_eq!(resp.status(), StatusCode::CREATED);
15310 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15311 .await
15312 .unwrap();
15313 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15314 assert_eq!(v["namespace"], "team/alice");
15315 assert_eq!(v["agent_id"], "ai:alice");
15316 }
15317
15318 #[tokio::test]
15319 async fn http_unsubscribe_missing_id_and_namespace_returns_400() {
15320 let state = test_state();
15321 let app = Router::new()
15322 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15323 .with_state(test_app_state(state));
15324 let resp = app
15326 .oneshot(
15327 axum::http::Request::builder()
15328 .uri("/api/v1/subscribe")
15329 .method("DELETE")
15330 .header("x-agent-id", "ai:alice")
15331 .body(Body::empty())
15332 .unwrap(),
15333 )
15334 .await
15335 .unwrap();
15336 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15337 }
15338
15339 #[tokio::test]
15340 async fn http_unsubscribe_by_agent_namespace_after_subscribe_returns_removed() {
15341 let state = test_state();
15342 let sub_app = Router::new()
15345 .route("/api/v1/subscribe", axum_post(subscribe))
15346 .with_state(test_app_state(state.clone()));
15347 let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15348 let resp = sub_app
15349 .oneshot(
15350 axum::http::Request::builder()
15351 .uri("/api/v1/subscribe")
15352 .method("POST")
15353 .header("content-type", "application/json")
15354 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15355 .unwrap(),
15356 )
15357 .await
15358 .unwrap();
15359 assert_eq!(resp.status(), StatusCode::CREATED);
15360
15361 let app = Router::new()
15362 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15363 .with_state(test_app_state(state));
15364 let resp = app
15365 .oneshot(
15366 axum::http::Request::builder()
15367 .uri("/api/v1/subscribe?agent_id=ai:alice&namespace=team/alice")
15368 .method("DELETE")
15369 .body(Body::empty())
15370 .unwrap(),
15371 )
15372 .await
15373 .unwrap();
15374 assert_eq!(resp.status(), StatusCode::OK);
15375 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15376 .await
15377 .unwrap();
15378 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15379 assert_eq!(v["removed"], true);
15380 }
15381
15382 #[tokio::test]
15385 async fn http_list_subscriptions_returns_subscription_rows() {
15386 let state = test_state();
15387 let sub_app = Router::new()
15389 .route("/api/v1/subscribe", axum_post(subscribe))
15390 .with_state(test_app_state(state.clone()));
15391 let body = serde_json::json!({"agent_id": "ai:carol", "namespace": "team/carol"});
15392 let resp = sub_app
15393 .oneshot(
15394 axum::http::Request::builder()
15395 .uri("/api/v1/subscribe")
15396 .method("POST")
15397 .header("content-type", "application/json")
15398 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15399 .unwrap(),
15400 )
15401 .await
15402 .unwrap();
15403 assert_eq!(resp.status(), StatusCode::CREATED);
15404
15405 let app = Router::new()
15406 .route("/api/v1/subscriptions", axum_get(list_subscriptions))
15407 .with_state(state);
15408 let resp = app
15409 .oneshot(
15410 axum::http::Request::builder()
15411 .uri("/api/v1/subscriptions")
15412 .body(Body::empty())
15413 .unwrap(),
15414 )
15415 .await
15416 .unwrap();
15417 assert_eq!(resp.status(), StatusCode::OK);
15418 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15419 .await
15420 .unwrap();
15421 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15422 assert!(v["count"].as_u64().unwrap() >= 1);
15423 }
15424
15425 #[tokio::test]
15428 async fn http_kg_query_after_create_link_returns_node() {
15429 let state = test_state();
15430 let src = insert_test_memory(&state, "kg-q", "src").await;
15431 let tgt = insert_test_memory(&state, "kg-q", "tgt").await;
15432 {
15433 let lock = state.lock().await;
15434 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15435 }
15436 let app = Router::new()
15437 .route("/api/v1/kg/query", axum_post(kg_query))
15438 .with_state(state);
15439 let body = serde_json::json!({"source_id": src, "max_depth": 1, "limit": 10});
15440 let resp = app
15441 .oneshot(
15442 axum::http::Request::builder()
15443 .uri("/api/v1/kg/query")
15444 .method("POST")
15445 .header("content-type", "application/json")
15446 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15447 .unwrap(),
15448 )
15449 .await
15450 .unwrap();
15451 assert_eq!(resp.status(), StatusCode::OK);
15452 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15453 .await
15454 .unwrap();
15455 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15456 assert_eq!(v["source_id"], src);
15457 let mems = v["memories"].as_array().expect("memories array");
15458 assert!(!mems.is_empty());
15459 }
15460
15461 #[tokio::test]
15462 async fn http_kg_invalidate_round_trip_marks_link() {
15463 let state = test_state();
15464 let src = insert_test_memory(&state, "kg-inv", "src").await;
15465 let tgt = insert_test_memory(&state, "kg-inv", "tgt").await;
15466 {
15467 let lock = state.lock().await;
15468 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15469 }
15470 let app = Router::new()
15471 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
15472 .with_state(state);
15473 let body = serde_json::json!({
15474 "source_id": src,
15475 "target_id": tgt,
15476 "relation": "related_to",
15477 "valid_until": "2030-01-01T00:00:00Z",
15478 });
15479 let resp = app
15480 .oneshot(
15481 axum::http::Request::builder()
15482 .uri("/api/v1/kg/invalidate")
15483 .method("POST")
15484 .header("content-type", "application/json")
15485 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15486 .unwrap(),
15487 )
15488 .await
15489 .unwrap();
15490 assert_eq!(resp.status(), StatusCode::OK);
15491 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15492 .await
15493 .unwrap();
15494 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15495 assert_eq!(v["found"], true);
15496 }
15497
15498 #[tokio::test]
15501 async fn http_list_archive_returns_archived_rows() {
15502 let state = test_state();
15503 let id = insert_test_memory(&state, "ns-archive", "row").await;
15504 {
15505 let lock = state.lock().await;
15506 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15507 }
15508 let app = Router::new()
15509 .route("/api/v1/archive", axum_get(list_archive))
15510 .with_state(state);
15511 let resp = app
15512 .oneshot(
15513 axum::http::Request::builder()
15514 .uri("/api/v1/archive?namespace=ns-archive&limit=10&offset=0")
15515 .body(Body::empty())
15516 .unwrap(),
15517 )
15518 .await
15519 .unwrap();
15520 assert_eq!(resp.status(), StatusCode::OK);
15521 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15522 .await
15523 .unwrap();
15524 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15525 assert!(v["count"].as_u64().unwrap() >= 1);
15526 }
15527
15528 #[tokio::test]
15531 async fn http_archive_by_ids_with_explicit_reason_records_it() {
15532 let state = test_state();
15533 let id = insert_test_memory(&state, "ns-arch", "row").await;
15534 let app = Router::new()
15535 .route("/api/v1/archive", axum_post(archive_by_ids))
15536 .with_state(test_app_state(state));
15537 let body = serde_json::json!({"ids": [id], "reason": "user requested"});
15538 let resp = app
15539 .oneshot(
15540 axum::http::Request::builder()
15541 .uri("/api/v1/archive")
15542 .method("POST")
15543 .header("content-type", "application/json")
15544 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15545 .unwrap(),
15546 )
15547 .await
15548 .unwrap();
15549 assert_eq!(resp.status(), StatusCode::OK);
15550 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15551 .await
15552 .unwrap();
15553 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15554 assert_eq!(v["reason"], "user requested");
15555 assert_eq!(v["count"], 1);
15556 }
15557
15558 fn over_max_string_vec(n: usize) -> Vec<String> {
15561 (0..n).map(|i| format!("id-{i:040}")).collect()
15562 }
15563
15564 #[tokio::test]
15565 async fn http_sync_push_oversize_deletions_returns_400() {
15566 let state = test_state();
15567 let app = Router::new()
15568 .route("/api/v1/sync/push", axum_post(sync_push))
15569 .with_state(test_app_state(state));
15570 let body = serde_json::json!({
15571 "sender_agent_id": "ai:peer",
15572 "memories": [],
15573 "deletions": over_max_string_vec(MAX_BULK_SIZE + 1),
15574 });
15575 let resp = app
15576 .oneshot(
15577 axum::http::Request::builder()
15578 .uri("/api/v1/sync/push")
15579 .method("POST")
15580 .header("content-type", "application/json")
15581 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15582 .unwrap(),
15583 )
15584 .await
15585 .unwrap();
15586 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15587 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15588 .await
15589 .unwrap();
15590 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15591 assert!(
15592 v["error"]
15593 .as_str()
15594 .unwrap()
15595 .contains("deletions per request"),
15596 "{v:?}"
15597 );
15598 }
15599
15600 #[tokio::test]
15601 async fn http_sync_push_oversize_archives_returns_400() {
15602 let state = test_state();
15603 let app = Router::new()
15604 .route("/api/v1/sync/push", axum_post(sync_push))
15605 .with_state(test_app_state(state));
15606 let body = serde_json::json!({
15607 "sender_agent_id": "ai:peer",
15608 "memories": [],
15609 "archives": over_max_string_vec(MAX_BULK_SIZE + 1),
15610 });
15611 let resp = app
15612 .oneshot(
15613 axum::http::Request::builder()
15614 .uri("/api/v1/sync/push")
15615 .method("POST")
15616 .header("content-type", "application/json")
15617 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15618 .unwrap(),
15619 )
15620 .await
15621 .unwrap();
15622 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15623 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15624 .await
15625 .unwrap();
15626 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15627 assert!(v["error"].as_str().unwrap().contains("archives"));
15628 }
15629
15630 #[tokio::test]
15631 async fn http_sync_push_oversize_restores_returns_400() {
15632 let state = test_state();
15633 let app = Router::new()
15634 .route("/api/v1/sync/push", axum_post(sync_push))
15635 .with_state(test_app_state(state));
15636 let body = serde_json::json!({
15637 "sender_agent_id": "ai:peer",
15638 "memories": [],
15639 "restores": over_max_string_vec(MAX_BULK_SIZE + 1),
15640 });
15641 let resp = app
15642 .oneshot(
15643 axum::http::Request::builder()
15644 .uri("/api/v1/sync/push")
15645 .method("POST")
15646 .header("content-type", "application/json")
15647 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15648 .unwrap(),
15649 )
15650 .await
15651 .unwrap();
15652 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15653 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15654 .await
15655 .unwrap();
15656 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15657 assert!(v["error"].as_str().unwrap().contains("restores"));
15658 }
15659
15660 #[tokio::test]
15661 async fn http_sync_push_oversize_namespace_meta_clears_returns_400() {
15662 let state = test_state();
15663 let app = Router::new()
15664 .route("/api/v1/sync/push", axum_post(sync_push))
15665 .with_state(test_app_state(state));
15666 let body = serde_json::json!({
15667 "sender_agent_id": "ai:peer",
15668 "memories": [],
15669 "namespace_meta_clears": over_max_string_vec(MAX_BULK_SIZE + 1),
15670 });
15671 let resp = app
15672 .oneshot(
15673 axum::http::Request::builder()
15674 .uri("/api/v1/sync/push")
15675 .method("POST")
15676 .header("content-type", "application/json")
15677 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15678 .unwrap(),
15679 )
15680 .await
15681 .unwrap();
15682 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15683 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15684 .await
15685 .unwrap();
15686 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15687 assert!(
15688 v["error"]
15689 .as_str()
15690 .unwrap()
15691 .contains("namespace_meta_clears")
15692 );
15693 }
15694
15695 #[tokio::test]
15696 async fn http_sync_push_invalid_sender_agent_id_returns_400() {
15697 let state = test_state();
15698 let app = Router::new()
15699 .route("/api/v1/sync/push", axum_post(sync_push))
15700 .with_state(test_app_state(state));
15701 let body = serde_json::json!({
15703 "sender_agent_id": "bad agent id",
15704 "memories": [],
15705 });
15706 let resp = app
15707 .oneshot(
15708 axum::http::Request::builder()
15709 .uri("/api/v1/sync/push")
15710 .method("POST")
15711 .header("content-type", "application/json")
15712 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15713 .unwrap(),
15714 )
15715 .await
15716 .unwrap();
15717 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15718 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15719 .await
15720 .unwrap();
15721 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15722 assert!(v["error"].as_str().unwrap().contains("sender_agent_id"));
15723 }
15724
15725 #[tokio::test]
15726 async fn http_sync_push_invalid_x_agent_id_header_returns_400() {
15727 let state = test_state();
15728 let app = Router::new()
15729 .route("/api/v1/sync/push", axum_post(sync_push))
15730 .with_state(test_app_state(state));
15731 let body = serde_json::json!({
15732 "sender_agent_id": "ai:peer",
15733 "memories": [],
15734 });
15735 let resp = app
15736 .oneshot(
15737 axum::http::Request::builder()
15738 .uri("/api/v1/sync/push")
15739 .method("POST")
15740 .header("content-type", "application/json")
15741 .header("x-agent-id", "bad agent id")
15742 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15743 .unwrap(),
15744 )
15745 .await
15746 .unwrap();
15747 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15748 }
15749
15750 #[tokio::test]
15753 async fn http_sync_push_pending_invalid_id_skipped() {
15754 let state = test_state();
15755 let app = Router::new()
15756 .route("/api/v1/sync/push", axum_post(sync_push))
15757 .with_state(test_app_state(state));
15758 let bad_id = "x".repeat(200); let body = serde_json::json!({
15760 "sender_agent_id": "ai:peer",
15761 "memories": [],
15762 "pendings": [{
15763 "id": bad_id,
15764 "action_type": "store",
15765 "memory_id": null,
15766 "namespace": "ns",
15767 "payload": {},
15768 "requested_by": "ai:peer",
15769 "requested_at": "2024-01-01T00:00:00Z",
15770 "status": "pending",
15771 "approvals": [],
15772 }],
15773 });
15774 let resp = app
15775 .oneshot(
15776 axum::http::Request::builder()
15777 .uri("/api/v1/sync/push")
15778 .method("POST")
15779 .header("content-type", "application/json")
15780 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15781 .unwrap(),
15782 )
15783 .await
15784 .unwrap();
15785 assert_eq!(resp.status(), StatusCode::OK);
15786 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15787 .await
15788 .unwrap();
15789 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15790 assert_eq!(v["skipped"], 1);
15791 assert_eq!(v["pendings_applied"], 0);
15792 }
15793
15794 #[tokio::test]
15795 async fn http_sync_push_links_invalid_id_skipped() {
15796 let state = test_state();
15797 let app = Router::new()
15798 .route("/api/v1/sync/push", axum_post(sync_push))
15799 .with_state(test_app_state(state));
15800 let body = serde_json::json!({
15802 "sender_agent_id": "ai:peer",
15803 "memories": [],
15804 "links": [{
15805 "source_id": "abc",
15806 "target_id": "abc",
15807 "relation": "related_to",
15808 "created_at": "2024-01-01T00:00:00Z",
15809 }],
15810 });
15811 let resp = app
15812 .oneshot(
15813 axum::http::Request::builder()
15814 .uri("/api/v1/sync/push")
15815 .method("POST")
15816 .header("content-type", "application/json")
15817 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15818 .unwrap(),
15819 )
15820 .await
15821 .unwrap();
15822 assert_eq!(resp.status(), StatusCode::OK);
15823 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15824 .await
15825 .unwrap();
15826 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15827 assert_eq!(v["skipped"], 1);
15828 assert_eq!(v["links_applied"], 0);
15829 }
15830
15831 #[tokio::test]
15832 async fn http_sync_push_dry_run_links_no_apply() {
15833 let state = test_state();
15834 let src = insert_test_memory(&state, "dryrun-links", "src").await;
15835 let tgt = insert_test_memory(&state, "dryrun-links", "tgt").await;
15836 let app = Router::new()
15837 .route("/api/v1/sync/push", axum_post(sync_push))
15838 .with_state(test_app_state(state));
15839 let body = serde_json::json!({
15840 "sender_agent_id": "ai:peer",
15841 "memories": [],
15842 "links": [{
15843 "source_id": src,
15844 "target_id": tgt,
15845 "relation": "related_to",
15846 "created_at": "2024-01-01T00:00:00Z",
15847 }],
15848 "dry_run": true,
15849 });
15850 let resp = app
15851 .oneshot(
15852 axum::http::Request::builder()
15853 .uri("/api/v1/sync/push")
15854 .method("POST")
15855 .header("content-type", "application/json")
15856 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15857 .unwrap(),
15858 )
15859 .await
15860 .unwrap();
15861 assert_eq!(resp.status(), StatusCode::OK);
15862 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15863 .await
15864 .unwrap();
15865 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15866 assert_eq!(v["links_applied"], 0);
15867 assert_eq!(v["dry_run"], true);
15868 }
15869
15870 #[tokio::test]
15873 async fn http_consolidate_invalid_title_returns_400() {
15874 let state = test_state();
15875 let id1 = insert_test_memory(&state, "ns-cons", "a").await;
15876 let id2 = insert_test_memory(&state, "ns-cons", "b").await;
15877 let app = Router::new()
15878 .route("/api/v1/consolidate", axum_post(consolidate_memories))
15879 .with_state(test_app_state(state));
15880 let body = serde_json::json!({
15881 "ids": [id1, id2],
15882 "title": "",
15883 "summary": "Summary text",
15884 "namespace": "ns-cons",
15885 });
15886 let resp = app
15887 .oneshot(
15888 axum::http::Request::builder()
15889 .uri("/api/v1/consolidate")
15890 .method("POST")
15891 .header("content-type", "application/json")
15892 .header("x-agent-id", "ai:tester")
15893 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15894 .unwrap(),
15895 )
15896 .await
15897 .unwrap();
15898 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15899 }
15900
15901 #[tokio::test]
15904 async fn http_bulk_create_zero_body_returns_zero_created() {
15905 let state = test_state();
15906 let app = Router::new()
15907 .route("/api/v1/memories/bulk", axum_post(bulk_create))
15908 .with_state(test_app_state(state));
15909 let body: Vec<serde_json::Value> = Vec::new();
15910 let resp = app
15911 .oneshot(
15912 axum::http::Request::builder()
15913 .uri("/api/v1/memories/bulk")
15914 .method("POST")
15915 .header("content-type", "application/json")
15916 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15917 .unwrap(),
15918 )
15919 .await
15920 .unwrap();
15921 assert_eq!(resp.status(), StatusCode::OK);
15922 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15923 .await
15924 .unwrap();
15925 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15926 assert_eq!(v["created"], 0);
15927 }
15928
15929 #[tokio::test]
15932 async fn http_entity_register_with_x_agent_id_header_succeeds() {
15933 let state = test_state();
15934 let app = Router::new()
15935 .route("/api/v1/entities", axum_post(entity_register))
15936 .with_state(state);
15937 let body = serde_json::json!({
15938 "canonical_name": "Acme Inc",
15939 "namespace": "corp",
15940 "aliases": ["acme", "ACME"],
15941 });
15942 let resp = app
15943 .oneshot(
15944 axum::http::Request::builder()
15945 .uri("/api/v1/entities")
15946 .method("POST")
15947 .header("content-type", "application/json")
15948 .header("x-agent-id", "ai:tester")
15949 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15950 .unwrap(),
15951 )
15952 .await
15953 .unwrap();
15954 assert_eq!(resp.status(), StatusCode::CREATED);
15955 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15956 .await
15957 .unwrap();
15958 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15959 assert_eq!(v["created"], true);
15960 assert_eq!(v["canonical_name"], "Acme Inc");
15961 }
15962
15963 #[tokio::test]
15966 async fn http_get_inbox_without_caller_uses_anonymous_default() {
15967 let state = test_state();
15971 let app = Router::new()
15972 .route("/api/v1/inbox", axum_get(get_inbox))
15973 .with_state(test_app_state(state));
15974 let resp = app
15975 .oneshot(
15976 axum::http::Request::builder()
15977 .uri("/api/v1/inbox")
15978 .body(Body::empty())
15979 .unwrap(),
15980 )
15981 .await
15982 .unwrap();
15983 assert_eq!(resp.status(), StatusCode::OK);
15984 }
15985
15986 #[tokio::test]
15989 async fn http_approve_pending_with_bad_header_agent_id_returns_400() {
15990 let state = test_state();
15991 let app = Router::new()
15992 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
15993 .with_state(test_app_state(state));
15994 let id = "abcdef0123456789abcdef0123456789";
15995 let resp = app
15996 .oneshot(
15997 axum::http::Request::builder()
15998 .uri(format!("/api/v1/pending/{id}/approve"))
15999 .method("POST")
16000 .header("x-agent-id", "bad agent id")
16001 .body(Body::empty())
16002 .unwrap(),
16003 )
16004 .await
16005 .unwrap();
16006 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16007 }
16008
16009 #[tokio::test]
16012 async fn http_reject_pending_with_bad_header_agent_id_returns_400() {
16013 let state = test_state();
16014 let app = Router::new()
16015 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
16016 .with_state(test_app_state(state));
16017 let id = "abcdef0123456789abcdef0123456789";
16018 let resp = app
16019 .oneshot(
16020 axum::http::Request::builder()
16021 .uri(format!("/api/v1/pending/{id}/reject"))
16022 .method("POST")
16023 .header("x-agent-id", "bad agent id")
16024 .body(Body::empty())
16025 .unwrap(),
16026 )
16027 .await
16028 .unwrap();
16029 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16030 }
16031
16032 #[tokio::test]
16035 async fn http_create_memory_invalid_x_agent_id_header_returns_400() {
16036 let state = test_state();
16037 let app = Router::new()
16038 .route("/api/v1/memories", axum_post(create_memory))
16039 .with_state(test_app_state(state));
16040 let body = serde_json::json!({
16041 "tier": "long",
16042 "namespace": "test",
16043 "title": "t",
16044 "content": "c",
16045 "tags": [],
16046 "priority": 5,
16047 "confidence": 1.0,
16048 "source": "api",
16049 "metadata": {}
16050 });
16051 let resp = app
16052 .oneshot(
16053 axum::http::Request::builder()
16054 .uri("/api/v1/memories")
16055 .method("POST")
16056 .header("content-type", "application/json")
16057 .header("x-agent-id", "bad agent id")
16058 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16059 .unwrap(),
16060 )
16061 .await
16062 .unwrap();
16063 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16064 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16065 .await
16066 .unwrap();
16067 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16068 assert!(v["error"].as_str().unwrap().contains("agent_id"));
16069 }
16070
16071 #[tokio::test]
16074 async fn http_create_memory_invalid_scope_returns_400() {
16075 let state = test_state();
16076 let app = Router::new()
16077 .route("/api/v1/memories", axum_post(create_memory))
16078 .with_state(test_app_state(state));
16079 let body = serde_json::json!({
16082 "tier": "long",
16083 "namespace": "test",
16084 "title": "t",
16085 "content": "c",
16086 "tags": [],
16087 "priority": 5,
16088 "confidence": 1.0,
16089 "source": "api",
16090 "metadata": {},
16091 "scope": "not-a-valid-scope-token"
16092 });
16093 let resp = app
16094 .oneshot(
16095 axum::http::Request::builder()
16096 .uri("/api/v1/memories")
16097 .method("POST")
16098 .header("content-type", "application/json")
16099 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16100 .unwrap(),
16101 )
16102 .await
16103 .unwrap();
16104 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16105 }
16106
16107 #[tokio::test]
16110 async fn http_list_memories_invalid_agent_id_filter_returns_400() {
16111 let state = test_state();
16112 let app = Router::new()
16113 .route("/api/v1/memories", axum_get(list_memories))
16114 .with_state(state);
16115 let resp = app
16116 .oneshot(
16117 axum::http::Request::builder()
16118 .uri("/api/v1/memories?agent_id=bad%20id")
16119 .body(Body::empty())
16120 .unwrap(),
16121 )
16122 .await
16123 .unwrap();
16124 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16125 }
16126
16127 #[tokio::test]
16130 async fn http_check_duplicate_blank_namespace_treated_as_none() {
16131 let state = test_state();
16134 let app = Router::new()
16135 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
16136 .with_state(test_app_state(state));
16137 let body = serde_json::json!({"title": "t", "content": "c", "namespace": " "});
16138 let resp = app
16139 .oneshot(
16140 axum::http::Request::builder()
16141 .uri("/api/v1/check_duplicate")
16142 .method("POST")
16143 .header("content-type", "application/json")
16144 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16145 .unwrap(),
16146 )
16147 .await
16148 .unwrap();
16149 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
16150 }
16151
16152 #[tokio::test]
16157 async fn http_archive_by_ids_with_no_reason_defaults_to_archive() {
16158 let state = test_state();
16159 let id = insert_test_memory(&state, "ns-arch-default", "row").await;
16160 let app = Router::new()
16161 .route("/api/v1/archive", axum_post(archive_by_ids))
16162 .with_state(test_app_state(state));
16163 let body = serde_json::json!({"ids": [id]});
16164 let resp = app
16165 .oneshot(
16166 axum::http::Request::builder()
16167 .uri("/api/v1/archive")
16168 .method("POST")
16169 .header("content-type", "application/json")
16170 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16171 .unwrap(),
16172 )
16173 .await
16174 .unwrap();
16175 assert_eq!(resp.status(), StatusCode::OK);
16176 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16177 .await
16178 .unwrap();
16179 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16180 assert_eq!(v["reason"], "archive");
16181 }
16182
16183 async fn seed_governance_policy(state: &Db, ns: &str, policy: serde_json::Value) {
16194 let lock = state.lock().await;
16195 let now = Utc::now().to_rfc3339();
16196 let standard = Memory {
16197 id: Uuid::new_v4().to_string(),
16198 tier: Tier::Long,
16199 namespace: ns.into(),
16200 title: format!("_standard:{ns}"),
16201 content: format!("standard for {ns}"),
16202 tags: vec!["_namespace_standard".to_string()],
16203 priority: 5,
16204 confidence: 1.0,
16205 source: "test".into(),
16206 access_count: 0,
16207 created_at: now.clone(),
16208 updated_at: now,
16209 last_accessed_at: None,
16210 expires_at: None,
16211 metadata: serde_json::json!({
16212 "agent_id": "ai:owner",
16213 "governance": policy,
16214 }),
16215 };
16216 let standard_id = db::insert(&lock.0, &standard).unwrap();
16217 db::set_namespace_standard(&lock.0, ns, &standard_id, None).unwrap();
16218 }
16219
16220 #[tokio::test]
16221 async fn http_create_memory_governance_pending_returns_202() {
16222 let state = test_state();
16223 seed_governance_policy(
16224 &state,
16225 "gov-create",
16226 serde_json::json!({
16227 "write": "approve",
16228 "delete": "owner",
16229 "promote": "any",
16230 "approver": "human",
16231 }),
16232 )
16233 .await;
16234 let app = Router::new()
16235 .route("/api/v1/memories", axum_post(create_memory))
16236 .with_state(test_app_state(state));
16237 let body = serde_json::json!({
16238 "tier": "long",
16239 "namespace": "gov-create",
16240 "title": "queued",
16241 "content": "should be queued, not stored",
16242 "tags": [],
16243 "priority": 5,
16244 "confidence": 1.0,
16245 "source": "api",
16246 "metadata": {},
16247 });
16248 let resp = app
16249 .oneshot(
16250 axum::http::Request::builder()
16251 .uri("/api/v1/memories")
16252 .method("POST")
16253 .header("content-type", "application/json")
16254 .header("x-agent-id", "ai:caller")
16255 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16256 .unwrap(),
16257 )
16258 .await
16259 .unwrap();
16260 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16261 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16262 .await
16263 .unwrap();
16264 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16265 assert_eq!(v["status"], "pending");
16266 assert_eq!(v["action"], "store");
16267 assert!(v["pending_id"].is_string());
16268 }
16269
16270 #[tokio::test]
16271 async fn http_create_memory_governance_deny_returns_403() {
16272 let state = test_state();
16274 seed_governance_policy(
16275 &state,
16276 "gov-deny",
16277 serde_json::json!({"write": "registered", "approver": "human"}),
16278 )
16279 .await;
16280 let app = Router::new()
16281 .route("/api/v1/memories", axum_post(create_memory))
16282 .with_state(test_app_state(state));
16283 let body = serde_json::json!({
16284 "tier": "long",
16285 "namespace": "gov-deny",
16286 "title": "rejected",
16287 "content": "rejected content",
16288 "tags": [],
16289 "priority": 5,
16290 "confidence": 1.0,
16291 "source": "api",
16292 "metadata": {},
16293 });
16294 let resp = app
16295 .oneshot(
16296 axum::http::Request::builder()
16297 .uri("/api/v1/memories")
16298 .method("POST")
16299 .header("content-type", "application/json")
16300 .header("x-agent-id", "ai:unregistered")
16301 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16302 .unwrap(),
16303 )
16304 .await
16305 .unwrap();
16306 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16307 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16308 .await
16309 .unwrap();
16310 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16311 assert!(v["error"].as_str().unwrap().contains("governance"));
16312 }
16313
16314 #[tokio::test]
16315 async fn http_delete_memory_governance_pending_returns_202() {
16316 let state = test_state();
16317 seed_governance_policy(
16318 &state,
16319 "gov-delete",
16320 serde_json::json!({
16321 "write": "any",
16322 "delete": "approve",
16323 "promote": "any",
16324 "approver": "human",
16325 }),
16326 )
16327 .await;
16328 let id = insert_test_memory(&state, "gov-delete", "to-delete").await;
16329 let app = Router::new()
16330 .route(
16331 "/api/v1/memories/{id}",
16332 axum::routing::delete(delete_memory),
16333 )
16334 .with_state(test_app_state(state));
16335 let resp = app
16336 .oneshot(
16337 axum::http::Request::builder()
16338 .uri(format!("/api/v1/memories/{id}"))
16339 .method("DELETE")
16340 .header("x-agent-id", "ai:caller")
16341 .body(Body::empty())
16342 .unwrap(),
16343 )
16344 .await
16345 .unwrap();
16346 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16347 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16348 .await
16349 .unwrap();
16350 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16351 assert_eq!(v["status"], "pending");
16352 assert_eq!(v["action"], "delete");
16353 assert_eq!(v["memory_id"], id);
16354 }
16355
16356 #[tokio::test]
16357 async fn http_delete_memory_governance_deny_returns_403() {
16358 let state = test_state();
16359 seed_governance_policy(
16360 &state,
16361 "gov-delete-deny",
16362 serde_json::json!({"write": "any", "delete": "owner", "approver": "human"}),
16363 )
16364 .await;
16365 let id = insert_test_memory(&state, "gov-delete-deny", "row").await;
16371 let app = Router::new()
16372 .route(
16373 "/api/v1/memories/{id}",
16374 axum::routing::delete(delete_memory),
16375 )
16376 .with_state(test_app_state(state));
16377 let resp = app
16378 .oneshot(
16379 axum::http::Request::builder()
16380 .uri(format!("/api/v1/memories/{id}"))
16381 .method("DELETE")
16382 .header("x-agent-id", "ai:other")
16383 .body(Body::empty())
16384 .unwrap(),
16385 )
16386 .await
16387 .unwrap();
16388 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16389 }
16390
16391 #[tokio::test]
16392 async fn http_promote_memory_governance_pending_returns_202() {
16393 let state = test_state();
16394 seed_governance_policy(
16395 &state,
16396 "gov-promote",
16397 serde_json::json!({
16398 "write": "any",
16399 "delete": "any",
16400 "promote": "approve",
16401 "approver": "human",
16402 }),
16403 )
16404 .await;
16405 let id = insert_test_memory(&state, "gov-promote", "to-promote").await;
16406 let app = Router::new()
16407 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
16408 .with_state(test_app_state(state));
16409 let resp = app
16410 .oneshot(
16411 axum::http::Request::builder()
16412 .uri(format!("/api/v1/memories/{id}/promote"))
16413 .method("POST")
16414 .header("x-agent-id", "ai:caller")
16415 .body(Body::empty())
16416 .unwrap(),
16417 )
16418 .await
16419 .unwrap();
16420 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16421 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16422 .await
16423 .unwrap();
16424 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16425 assert_eq!(v["status"], "pending");
16426 assert_eq!(v["action"], "promote");
16427 assert_eq!(v["memory_id"], id);
16428 }
16429
16430 #[tokio::test]
16433 async fn http_create_memory_with_top_level_scope_succeeds() {
16434 let state = test_state();
16435 let app = Router::new()
16436 .route("/api/v1/memories", axum_post(create_memory))
16437 .with_state(test_app_state(state));
16438 let body = serde_json::json!({
16439 "tier": "long",
16440 "namespace": "scoped",
16441 "title": "with scope",
16442 "content": "scoped content",
16443 "tags": [],
16444 "priority": 5,
16445 "confidence": 1.0,
16446 "source": "api",
16447 "metadata": {},
16448 "scope": "private"
16449 });
16450 let resp = app
16451 .oneshot(
16452 axum::http::Request::builder()
16453 .uri("/api/v1/memories")
16454 .method("POST")
16455 .header("content-type", "application/json")
16456 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16457 .unwrap(),
16458 )
16459 .await
16460 .unwrap();
16461 assert_eq!(resp.status(), StatusCode::CREATED);
16462 }
16463
16464 #[tokio::test]
16467 async fn http_create_memory_clamps_extreme_priority_to_range() {
16468 let state = test_state();
16469 let app = Router::new()
16470 .route("/api/v1/memories", axum_post(create_memory))
16471 .with_state(test_app_state(state.clone()));
16472 let body = serde_json::json!({
16475 "tier": "long",
16476 "namespace": "clamp",
16477 "title": "clamp",
16478 "content": "c",
16479 "tags": [],
16480 "priority": 10,
16481 "confidence": 1.0,
16482 "source": "api",
16483 "metadata": {},
16484 });
16485 let resp = app
16486 .oneshot(
16487 axum::http::Request::builder()
16488 .uri("/api/v1/memories")
16489 .method("POST")
16490 .header("content-type", "application/json")
16491 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16492 .unwrap(),
16493 )
16494 .await
16495 .unwrap();
16496 assert_eq!(resp.status(), StatusCode::CREATED);
16497 let lock = state.lock().await;
16499 let rows = db::list(
16500 &lock.0,
16501 Some("clamp"),
16502 None,
16503 10,
16504 0,
16505 None,
16506 None,
16507 None,
16508 None,
16509 None,
16510 )
16511 .unwrap();
16512 assert_eq!(rows[0].priority, 10);
16513 }
16514
16515 #[tokio::test]
16518 async fn http_update_memory_with_oversized_title_returns_400() {
16519 let state = test_state();
16520 let id = insert_test_memory(&state, "ns-bigtitle", "old").await;
16521 let app = Router::new()
16522 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
16523 .with_state(test_app_state(state));
16524 let big_title = "T".repeat(10_000);
16526 let body = serde_json::json!({"title": big_title});
16527 let resp = app
16528 .oneshot(
16529 axum::http::Request::builder()
16530 .uri(format!("/api/v1/memories/{id}"))
16531 .method("PUT")
16532 .header("content-type", "application/json")
16533 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16534 .unwrap(),
16535 )
16536 .await
16537 .unwrap();
16538 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16539 }
16540
16541 #[tokio::test]
16544 async fn http_purge_archive_no_query_returns_purged_zero_for_empty_archive() {
16545 let state = test_state();
16546 let app = Router::new()
16547 .route("/api/v1/archive", axum::routing::delete(purge_archive))
16548 .with_state(state);
16549 let resp = app
16550 .oneshot(
16551 axum::http::Request::builder()
16552 .uri("/api/v1/archive")
16553 .method("DELETE")
16554 .body(Body::empty())
16555 .unwrap(),
16556 )
16557 .await
16558 .unwrap();
16559 assert_eq!(resp.status(), StatusCode::OK);
16560 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16561 .await
16562 .unwrap();
16563 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16564 assert_eq!(v["purged"], 0);
16565 }
16566
16567 #[tokio::test]
16570 async fn http_contradictions_topic_only_returns_ok_empty() {
16571 let state = test_state();
16572 let app = Router::new()
16573 .route("/api/v1/contradictions", axum_get(detect_contradictions))
16574 .with_state(state);
16575 let resp = app
16576 .oneshot(
16577 axum::http::Request::builder()
16578 .uri("/api/v1/contradictions?topic=missing-topic")
16579 .body(Body::empty())
16580 .unwrap(),
16581 )
16582 .await
16583 .unwrap();
16584 assert_eq!(resp.status(), StatusCode::OK);
16585 }
16586
16587 #[tokio::test]
16590 async fn http_entity_register_aliases_with_blanks_filtered() {
16591 let state = test_state();
16592 let app = Router::new()
16593 .route("/api/v1/entities", axum_post(entity_register))
16594 .with_state(state);
16595 let body = serde_json::json!({
16596 "canonical_name": "Globex",
16597 "namespace": "corp2",
16598 "aliases": ["", "globex", " ", "GLOBEX"],
16599 });
16600 let resp = app
16601 .oneshot(
16602 axum::http::Request::builder()
16603 .uri("/api/v1/entities")
16604 .method("POST")
16605 .header("content-type", "application/json")
16606 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16607 .unwrap(),
16608 )
16609 .await
16610 .unwrap();
16611 assert_eq!(resp.status(), StatusCode::CREATED);
16612 }
16613
16614 #[tokio::test]
16617 async fn http_subscribe_with_explicit_url_succeeds() {
16618 let state = test_state();
16619 let app = Router::new()
16620 .route("/api/v1/subscribe", axum_post(subscribe))
16621 .with_state(test_app_state(state));
16622 let body = serde_json::json!({
16623 "agent_id": "ai:webhook-user",
16624 "url": "http://localhost:9999/webhook",
16625 "events": "store",
16626 "secret": "shhh",
16627 "namespace_filter": "team",
16628 });
16629 let resp = app
16630 .oneshot(
16631 axum::http::Request::builder()
16632 .uri("/api/v1/subscribe")
16633 .method("POST")
16634 .header("content-type", "application/json")
16635 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16636 .unwrap(),
16637 )
16638 .await
16639 .unwrap();
16640 assert_eq!(resp.status(), StatusCode::CREATED);
16641 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16642 .await
16643 .unwrap();
16644 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16645 assert_eq!(v["url"], "http://localhost:9999/webhook");
16646 assert_eq!(v["events"], "store");
16647 }
16648
16649 #[tokio::test]
16652 async fn http_unsubscribe_by_unknown_id_returns_ok_unchanged() {
16653 let state = test_state();
16654 let app = Router::new()
16655 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
16656 .with_state(test_app_state(state));
16657 let resp = app
16660 .oneshot(
16661 axum::http::Request::builder()
16662 .uri("/api/v1/subscribe?id=does-not-exist")
16663 .method("DELETE")
16664 .body(Body::empty())
16665 .unwrap(),
16666 )
16667 .await
16668 .unwrap();
16669 assert!(
16672 resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST,
16673 "got {}",
16674 resp.status()
16675 );
16676 }
16677}