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 on_conflict_mode = body.on_conflict.as_deref().unwrap_or("error");
295 if !matches!(on_conflict_mode, "error" | "merge" | "version") {
296 return (
297 StatusCode::BAD_REQUEST,
298 Json(json!({
299 "error": format!(
300 "invalid on_conflict '{on_conflict_mode}' (expected error|merge|version)"
301 )
302 })),
303 )
304 .into_response();
305 }
306
307 let now = Utc::now();
308 let lock = state.lock().await;
309 let expires_at = body.expires_at.or_else(|| {
310 body.ttl_secs
311 .or(lock.2.ttl_for_tier(&body.tier))
312 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
313 });
314
315 let resolved_title = match on_conflict_mode {
321 "error" => match db::find_by_title_namespace(&lock.0, &body.title, &body.namespace) {
322 Ok(Some(existing_id)) => {
323 return (
324 StatusCode::CONFLICT,
325 Json(json!({
326 "code": "CONFLICT",
327 "error": format!(
328 "memory with title '{}' already exists in namespace '{}'",
329 body.title, body.namespace
330 ),
331 "existing_id": existing_id,
332 })),
333 )
334 .into_response();
335 }
336 Ok(None) => body.title.clone(),
337 Err(e) => {
338 tracing::error!("on_conflict lookup failed: {e}");
339 return (
340 StatusCode::INTERNAL_SERVER_ERROR,
341 Json(json!({"error": "conflict check failed"})),
342 )
343 .into_response();
344 }
345 },
346 "version" => match db::next_versioned_title(&lock.0, &body.title, &body.namespace) {
347 Ok(t) => t,
348 Err(e) => {
349 tracing::error!("on_conflict=version failed: {e}");
350 return (
351 StatusCode::INTERNAL_SERVER_ERROR,
352 Json(json!({"error": "could not pick a versioned title"})),
353 )
354 .into_response();
355 }
356 },
357 _ => body.title.clone(),
358 };
359
360 let mem = Memory {
361 id: Uuid::new_v4().to_string(),
362 tier: body.tier,
363 namespace: body.namespace,
364 title: resolved_title,
365 content: body.content,
366 tags: body.tags,
367 priority: body.priority.clamp(1, 10),
368 confidence: body.confidence.clamp(0.0, 1.0),
369 source: body.source,
370 access_count: 0,
371 created_at: now.to_rfc3339(),
372 updated_at: now.to_rfc3339(),
373 last_accessed_at: None,
374 expires_at,
375 metadata,
376 };
377
378 {
380 use crate::models::{GovernanceDecision, GovernedAction};
381 let agent_for_gov = mem
382 .metadata
383 .get("agent_id")
384 .and_then(|v| v.as_str())
385 .unwrap_or_default()
386 .to_string();
387 let payload = serde_json::to_value(&mem).unwrap_or_default();
388 match db::enforce_governance(
389 &lock.0,
390 GovernedAction::Store,
391 &mem.namespace,
392 &agent_for_gov,
393 None,
394 None,
395 &payload,
396 ) {
397 Ok(GovernanceDecision::Allow) => {}
398 Ok(GovernanceDecision::Deny(reason)) => {
399 return (
400 StatusCode::FORBIDDEN,
401 Json(json!({"error": format!("store denied by governance: {reason}")})),
402 )
403 .into_response();
404 }
405 Ok(GovernanceDecision::Pending(pending_id)) => {
406 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
410 let namespace = mem.namespace.clone();
411 drop(lock);
412 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
413 match crate::federation::broadcast_pending_quorum(fed, pa).await {
414 Ok(tracker) => {
415 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
416 let payload =
417 crate::federation::QuorumNotMetPayload::from_err(&err);
418 return (
419 StatusCode::SERVICE_UNAVAILABLE,
420 [("Retry-After", "2")],
421 Json(serde_json::to_value(&payload).unwrap_or_default()),
422 )
423 .into_response();
424 }
425 }
426 Err(err) => {
427 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
428 return (
429 StatusCode::SERVICE_UNAVAILABLE,
430 [("Retry-After", "2")],
431 Json(serde_json::to_value(&payload).unwrap_or_default()),
432 )
433 .into_response();
434 }
435 }
436 }
437 return (
438 StatusCode::ACCEPTED,
439 Json(json!({
440 "status": "pending",
441 "pending_id": pending_id,
442 "reason": "governance requires approval",
443 "action": "store",
444 "namespace": namespace,
445 })),
446 )
447 .into_response();
448 }
449 Err(e) => {
450 tracing::error!("governance error: {e}");
451 return (
452 StatusCode::INTERNAL_SERVER_ERROR,
453 Json(json!({"error": "governance check failed"})),
454 )
455 .into_response();
456 }
457 }
458 }
459
460 let contradictions =
462 db::find_contradictions(&lock.0, &mem.title, &mem.namespace).unwrap_or_default();
463 let contradiction_ids: Vec<String> = contradictions
464 .iter()
465 .filter(|c| c.id != mem.id)
466 .map(|c| c.id.clone())
467 .collect();
468
469 match db::insert(&lock.0, &mem) {
470 Ok(actual_id) => {
471 if let Some(ref vec) = embedding
476 && let Err(e) = db::set_embedding(&lock.0, &actual_id, vec)
477 {
478 tracing::warn!("failed to store embedding for {actual_id}: {e}");
479 }
480 drop(lock);
482 if let Some(vec) = embedding {
483 let mut idx_lock = app.vector_index.lock().await;
484 if let Some(idx) = idx_lock.as_mut() {
485 idx.insert(actual_id.clone(), vec);
486 }
487 }
488 let resolved_agent_id = mem
490 .metadata
491 .get("agent_id")
492 .and_then(|v| v.as_str())
493 .map(str::to_string);
494 crate::audit::emit(crate::audit::EventBuilder::new(
496 crate::audit::AuditAction::Store,
497 crate::audit::actor(
498 resolved_agent_id.clone().unwrap_or_default(),
499 "http_body",
500 mem.metadata
501 .get("scope")
502 .and_then(|v| v.as_str())
503 .map(str::to_string),
504 ),
505 crate::audit::target_memory(
506 actual_id.clone(),
507 mem.namespace.clone(),
508 Some(mem.title.clone()),
509 Some(mem.tier.to_string()),
510 mem.metadata
511 .get("scope")
512 .and_then(|v| v.as_str())
513 .map(str::to_string),
514 ),
515 ));
516 let mut response = json!({
517 "id": actual_id,
518 "tier": mem.tier,
519 "namespace": mem.namespace,
520 "title": mem.title,
521 "agent_id": resolved_agent_id,
522 });
523 if !contradiction_ids.is_empty() {
524 response["potential_contradictions"] = json!(contradiction_ids);
525 }
526 if let Some(fed) = app.federation.as_ref() {
533 let mut mem_echo = mem.clone();
534 mem_echo.id = actual_id.clone();
535 match crate::federation::broadcast_store_quorum(fed, &mem_echo).await {
536 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
537 Ok(got) => {
538 response["quorum_acks"] = json!(got);
539 return (StatusCode::CREATED, Json(response)).into_response();
540 }
541 Err(err) => {
542 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
543 return (
544 StatusCode::SERVICE_UNAVAILABLE,
545 [("Retry-After", "2")],
546 Json(serde_json::to_value(&payload).unwrap_or_default()),
547 )
548 .into_response();
549 }
550 },
551 Err(err) => {
552 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
553 return (
554 StatusCode::SERVICE_UNAVAILABLE,
555 [("Retry-After", "2")],
556 Json(serde_json::to_value(&payload).unwrap_or_default()),
557 )
558 .into_response();
559 }
560 }
561 }
562 (StatusCode::CREATED, Json(response)).into_response()
563 }
564 Err(e) => {
565 tracing::error!("handler error: {e}");
566 (
567 StatusCode::INTERNAL_SERVER_ERROR,
568 Json(json!({"error": "internal server error"})),
569 )
570 .into_response()
571 }
572 }
573}
574
575pub async fn register_agent(
576 State(app): State<AppState>,
577 Json(body): Json<RegisterAgentBody>,
578) -> impl IntoResponse {
579 if let Err(e) = validate::validate_agent_id(&body.agent_id) {
580 return (
581 StatusCode::BAD_REQUEST,
582 Json(json!({"error": e.to_string()})),
583 )
584 .into_response();
585 }
586 if let Err(e) = validate::validate_agent_type(&body.agent_type) {
587 return (
588 StatusCode::BAD_REQUEST,
589 Json(json!({"error": e.to_string()})),
590 )
591 .into_response();
592 }
593 let capabilities = body.capabilities.unwrap_or_default();
594 if let Err(e) = validate::validate_capabilities(&capabilities) {
595 return (
596 StatusCode::BAD_REQUEST,
597 Json(json!({"error": e.to_string()})),
598 )
599 .into_response();
600 }
601
602 let lock = app.db.lock().await;
603 let register_result =
604 db::register_agent(&lock.0, &body.agent_id, &body.agent_type, &capabilities);
605 let registered_mem = match ®ister_result {
610 Ok(id) => db::get(&lock.0, id).ok().flatten(),
611 Err(_) => None,
612 };
613 drop(lock);
614
615 match register_result {
616 Ok(id) => {
617 if let (Some(fed), Some(mem)) = (app.federation.as_ref(), registered_mem.as_ref()) {
618 match crate::federation::broadcast_store_quorum(fed, mem).await {
619 Ok(tracker) => {
620 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
621 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
622 return (
623 StatusCode::SERVICE_UNAVAILABLE,
624 [("Retry-After", "2")],
625 Json(serde_json::to_value(&payload).unwrap_or_default()),
626 )
627 .into_response();
628 }
629 }
630 Err(e) => {
631 tracing::warn!("register_agent fanout error (local committed): {e:?}");
632 }
633 }
634 }
635 (
636 StatusCode::CREATED,
637 Json(json!({
638 "registered": true,
639 "id": id,
640 "agent_id": body.agent_id,
641 "agent_type": body.agent_type,
642 "capabilities": capabilities,
643 })),
644 )
645 .into_response()
646 }
647 Err(e) => {
648 tracing::error!("handler error: {e}");
649 (
650 StatusCode::INTERNAL_SERVER_ERROR,
651 Json(json!({"error": "internal server error"})),
652 )
653 .into_response()
654 }
655 }
656}
657
658#[derive(Deserialize)]
663pub struct PendingListQuery {
664 #[serde(default)]
665 pub status: Option<String>,
666 #[serde(default = "default_pending_limit")]
667 pub limit: Option<usize>,
668}
669
670#[allow(clippy::unnecessary_wraps)]
671fn default_pending_limit() -> Option<usize> {
672 Some(100)
673}
674
675pub async fn list_pending(
676 State(state): State<Db>,
677 Query(p): Query<PendingListQuery>,
678) -> impl IntoResponse {
679 let limit = p.limit.unwrap_or(100).min(1000);
680 let lock = state.lock().await;
681 match db::list_pending_actions(&lock.0, p.status.as_deref(), limit) {
682 Ok(items) => Json(json!({"count": items.len(), "pending": items})).into_response(),
683 Err(e) => {
684 tracing::error!("handler error: {e}");
685 (
686 StatusCode::INTERNAL_SERVER_ERROR,
687 Json(json!({"error": "internal server error"})),
688 )
689 .into_response()
690 }
691 }
692}
693
694#[allow(clippy::too_many_lines)]
695pub async fn approve_pending(
696 State(app): State<AppState>,
697 headers: HeaderMap,
698 Path(id): Path<String>,
699) -> impl IntoResponse {
700 use crate::db::ApproveOutcome;
701 use crate::models::PendingDecision;
702 let state = app.db.clone();
703 if let Err(e) = validate::validate_id(&id) {
704 return (
705 StatusCode::BAD_REQUEST,
706 Json(json!({"error": e.to_string()})),
707 )
708 .into_response();
709 }
710 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
711 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
712 Ok(a) => a,
713 Err(e) => {
714 return (
715 StatusCode::BAD_REQUEST,
716 Json(json!({"error": format!("invalid agent_id: {e}")})),
717 )
718 .into_response();
719 }
720 };
721 let lock = state.lock().await;
722 match db::approve_with_approver_type(&lock.0, &id, &agent_id) {
723 Ok(ApproveOutcome::Approved) => match db::execute_pending_action(&lock.0, &id) {
724 Ok(memory_id) => {
725 let produced_mem = memory_id
730 .as_deref()
731 .and_then(|mid| db::get(&lock.0, mid).ok().flatten());
732 drop(lock);
733 if let Some(fed) = app.federation.as_ref() {
734 let decision = PendingDecision {
735 id: id.clone(),
736 approved: true,
737 decider: agent_id.clone(),
738 };
739 match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await
740 {
741 Ok(tracker) => {
742 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
743 let payload =
744 crate::federation::QuorumNotMetPayload::from_err(&err);
745 return (
746 StatusCode::SERVICE_UNAVAILABLE,
747 [("Retry-After", "2")],
748 Json(serde_json::to_value(&payload).unwrap_or_default()),
749 )
750 .into_response();
751 }
752 }
753 Err(err) => {
754 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
755 return (
756 StatusCode::SERVICE_UNAVAILABLE,
757 [("Retry-After", "2")],
758 Json(serde_json::to_value(&payload).unwrap_or_default()),
759 )
760 .into_response();
761 }
762 }
763 if let Some(ref mem) = produced_mem
768 && let Some(resp) = fanout_or_503(&app, mem).await
769 {
770 return resp;
771 }
772 }
773 Json(json!({
774 "approved": true,
775 "id": id,
776 "decided_by": agent_id,
777 "executed": true,
778 "memory_id": memory_id,
779 }))
780 .into_response()
781 }
782 Err(e) => {
783 tracing::error!("execute pending error: {e}");
784 (
785 StatusCode::INTERNAL_SERVER_ERROR,
786 Json(json!({"error": "approved but execution failed"})),
787 )
788 .into_response()
789 }
790 },
791 Ok(ApproveOutcome::Pending { votes, quorum }) => (
792 StatusCode::ACCEPTED,
793 Json(json!({
794 "approved": false,
795 "status": "pending",
796 "id": id,
797 "votes": votes,
798 "quorum": quorum,
799 "reason": "consensus threshold not yet reached",
800 })),
801 )
802 .into_response(),
803 Ok(ApproveOutcome::Rejected(reason)) => (
804 StatusCode::FORBIDDEN,
805 Json(json!({"error": format!("approve rejected: {reason}")})),
806 )
807 .into_response(),
808 Err(e) => {
809 tracing::error!("handler error: {e}");
810 (
811 StatusCode::INTERNAL_SERVER_ERROR,
812 Json(json!({"error": "internal server error"})),
813 )
814 .into_response()
815 }
816 }
817}
818
819pub async fn reject_pending(
820 State(app): State<AppState>,
821 headers: HeaderMap,
822 Path(id): Path<String>,
823) -> impl IntoResponse {
824 use crate::models::PendingDecision;
825 let state = app.db.clone();
826 if let Err(e) = validate::validate_id(&id) {
827 return (
828 StatusCode::BAD_REQUEST,
829 Json(json!({"error": e.to_string()})),
830 )
831 .into_response();
832 }
833 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
834 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
835 Ok(a) => a,
836 Err(e) => {
837 return (
838 StatusCode::BAD_REQUEST,
839 Json(json!({"error": format!("invalid agent_id: {e}")})),
840 )
841 .into_response();
842 }
843 };
844 let lock = state.lock().await;
845 match db::decide_pending_action(&lock.0, &id, false, &agent_id) {
846 Ok(true) => {
847 drop(lock);
848 if let Some(fed) = app.federation.as_ref() {
850 let decision = PendingDecision {
851 id: id.clone(),
852 approved: false,
853 decider: agent_id.clone(),
854 };
855 match crate::federation::broadcast_pending_decision_quorum(fed, &decision).await {
856 Ok(tracker) => {
857 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
858 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
859 return (
860 StatusCode::SERVICE_UNAVAILABLE,
861 [("Retry-After", "2")],
862 Json(serde_json::to_value(&payload).unwrap_or_default()),
863 )
864 .into_response();
865 }
866 }
867 Err(err) => {
868 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
869 return (
870 StatusCode::SERVICE_UNAVAILABLE,
871 [("Retry-After", "2")],
872 Json(serde_json::to_value(&payload).unwrap_or_default()),
873 )
874 .into_response();
875 }
876 }
877 }
878 Json(json!({"rejected": true, "id": id, "decided_by": agent_id})).into_response()
879 }
880 Ok(false) => (
881 StatusCode::NOT_FOUND,
882 Json(json!({"error": "pending action not found or already decided"})),
883 )
884 .into_response(),
885 Err(e) => {
886 tracing::error!("handler error: {e}");
887 (
888 StatusCode::INTERNAL_SERVER_ERROR,
889 Json(json!({"error": "internal server error"})),
890 )
891 .into_response()
892 }
893 }
894}
895
896pub async fn list_agents(State(state): State<Db>) -> impl IntoResponse {
897 let lock = state.lock().await;
898 match db::list_agents(&lock.0) {
899 Ok(agents) => (
900 StatusCode::OK,
901 Json(json!({"count": agents.len(), "agents": agents})),
902 )
903 .into_response(),
904 Err(e) => {
905 tracing::error!("handler error: {e}");
906 (
907 StatusCode::INTERNAL_SERVER_ERROR,
908 Json(json!({"error": "internal server error"})),
909 )
910 .into_response()
911 }
912 }
913}
914
915pub async fn get_memory(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
916 if let Err(e) = validate::validate_id(&id) {
917 return (
918 StatusCode::BAD_REQUEST,
919 Json(json!({"error": e.to_string()})),
920 )
921 .into_response();
922 }
923 let lock = state.lock().await;
924 match db::resolve_id(&lock.0, &id) {
925 Ok(Some(mem)) => {
926 let links = db::get_links(&lock.0, &mem.id).unwrap_or_default();
927 Json(json!({"memory": mem, "links": links})).into_response()
928 }
929 Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
930 Err(e) => {
931 let msg = e.to_string();
932 if msg.contains("ambiguous ID prefix") {
933 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
934 }
935 tracing::error!("handler error: {e}");
936 (
937 StatusCode::INTERNAL_SERVER_ERROR,
938 Json(json!({"error": "internal server error"})),
939 )
940 .into_response()
941 }
942 }
943}
944
945#[allow(clippy::too_many_lines)]
946pub async fn update_memory(
947 State(app): State<AppState>,
948 Path(id): Path<String>,
949 Json(body): Json<UpdateMemory>,
950) -> impl IntoResponse {
951 let state = app.db.clone();
952 if let Err(e) = validate::validate_id(&id) {
953 return (
954 StatusCode::BAD_REQUEST,
955 Json(json!({"error": e.to_string()})),
956 )
957 .into_response();
958 }
959 if let Err(e) = validate::validate_update(&body) {
960 return (
961 StatusCode::BAD_REQUEST,
962 Json(json!({"error": e.to_string()})),
963 )
964 .into_response();
965 }
966 let lock = state.lock().await;
967 let resolved_id = match db::resolve_id(&lock.0, &id) {
969 Ok(Some(mem)) => mem.id,
970 Ok(None) => {
971 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
972 }
973 Err(e) => {
974 let msg = e.to_string();
975 if msg.contains("ambiguous ID prefix") {
976 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
977 }
978 tracing::error!("handler error: {e}");
979 return (
980 StatusCode::INTERNAL_SERVER_ERROR,
981 Json(json!({"error": "internal server error"})),
982 )
983 .into_response();
984 }
985 };
986 let preserved_metadata = body.metadata.as_ref().map(|new_meta| {
989 let existing_meta = db::get(&lock.0, &resolved_id).ok().flatten().map_or_else(
990 || serde_json::Value::Object(serde_json::Map::new()),
991 |m| m.metadata,
992 );
993 crate::identity::preserve_agent_id(&existing_meta, new_meta)
994 });
995 match db::update(
996 &lock.0,
997 &resolved_id,
998 body.title.as_deref(),
999 body.content.as_deref(),
1000 body.tier.as_ref(),
1001 body.namespace.as_deref(),
1002 body.tags.as_ref(),
1003 body.priority,
1004 body.confidence,
1005 body.expires_at.as_deref(),
1006 preserved_metadata.as_ref(),
1007 ) {
1008 Ok((true, _)) => {
1009 let mem = db::get(&lock.0, &resolved_id).ok().flatten();
1010 let content_changed = body.title.is_some() || body.content.is_some();
1015 let mut lock_opt = Some(lock);
1016 if content_changed && let Some(ref m) = mem {
1017 let text = format!("{} {}", m.title, m.content);
1018 if let Some(emb) = app.embedder.as_ref().as_ref() {
1019 match emb.embed(&text) {
1020 Ok(vec) => {
1021 if let Some(ref l) = lock_opt
1022 && let Err(e) = db::set_embedding(&l.0, &resolved_id, &vec)
1023 {
1024 tracing::warn!(
1025 "failed to refresh embedding for {resolved_id}: {e}"
1026 );
1027 }
1028 lock_opt.take();
1030 let mut idx_lock = app.vector_index.lock().await;
1031 if let Some(idx) = idx_lock.as_mut() {
1032 idx.remove(&resolved_id);
1033 idx.insert(resolved_id.clone(), vec);
1034 }
1035 }
1036 Err(e) => tracing::warn!("embedding regeneration failed: {e}"),
1037 }
1038 }
1039 }
1040 drop(lock_opt);
1043 if let (Some(fed), Some(m)) = (app.federation.as_ref(), mem.as_ref())
1047 && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
1048 && let Err(err) = crate::federation::finalise_quorum(&tracker)
1049 {
1050 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1051 return (
1052 StatusCode::SERVICE_UNAVAILABLE,
1053 [("Retry-After", "2")],
1054 Json(serde_json::to_value(&payload).unwrap_or_default()),
1055 )
1056 .into_response();
1057 }
1058 Json(json!(mem)).into_response()
1059 }
1060 Ok((false, _)) => {
1061 (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
1062 }
1063 Err(e) => {
1064 let msg = e.to_string();
1065 if msg.contains("already exists in namespace") {
1066 return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
1067 }
1068 tracing::error!("handler error: {e}");
1069 (
1070 StatusCode::INTERNAL_SERVER_ERROR,
1071 Json(json!({"error": "internal server error"})),
1072 )
1073 .into_response()
1074 }
1075 }
1076}
1077
1078#[allow(clippy::too_many_lines)]
1079pub async fn delete_memory(
1080 State(app): State<AppState>,
1081 headers: HeaderMap,
1082 Path(id): Path<String>,
1083) -> impl IntoResponse {
1084 let state = app.db.clone();
1085 if let Err(e) = validate::validate_id(&id) {
1086 return (
1087 StatusCode::BAD_REQUEST,
1088 Json(json!({"error": e.to_string()})),
1089 )
1090 .into_response();
1091 }
1092 let lock = state.lock().await;
1093 let target = match db::resolve_id(&lock.0, &id) {
1095 Ok(Some(m)) => m,
1096 Ok(None) => {
1097 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1098 }
1099 Err(e) => {
1100 let msg = e.to_string();
1101 if msg.contains("ambiguous ID prefix") {
1102 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1103 }
1104 tracing::error!("handler error: {e}");
1105 return (
1106 StatusCode::INTERNAL_SERVER_ERROR,
1107 Json(json!({"error": "internal server error"})),
1108 )
1109 .into_response();
1110 }
1111 };
1112
1113 {
1115 use crate::models::{GovernanceDecision, GovernedAction};
1116 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1117 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1118 Ok(a) => a,
1119 Err(e) => {
1120 return (
1121 StatusCode::BAD_REQUEST,
1122 Json(json!({"error": format!("invalid agent_id: {e}")})),
1123 )
1124 .into_response();
1125 }
1126 };
1127 let mem_owner = target
1128 .metadata
1129 .get("agent_id")
1130 .and_then(|v| v.as_str())
1131 .map(str::to_string);
1132 let payload = json!({"id": target.id, "title": target.title});
1133 match db::enforce_governance(
1134 &lock.0,
1135 GovernedAction::Delete,
1136 &target.namespace,
1137 &agent_id,
1138 Some(&target.id),
1139 mem_owner.as_deref(),
1140 &payload,
1141 ) {
1142 Ok(GovernanceDecision::Allow) => {}
1143 Ok(GovernanceDecision::Deny(reason)) => {
1144 return (
1145 StatusCode::FORBIDDEN,
1146 Json(json!({"error": format!("delete denied by governance: {reason}")})),
1147 )
1148 .into_response();
1149 }
1150 Ok(GovernanceDecision::Pending(pending_id)) => {
1151 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1154 let target_id = target.id.clone();
1155 drop(lock);
1156 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1157 match crate::federation::broadcast_pending_quorum(fed, pa).await {
1158 Ok(tracker) => {
1159 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1160 let payload =
1161 crate::federation::QuorumNotMetPayload::from_err(&err);
1162 return (
1163 StatusCode::SERVICE_UNAVAILABLE,
1164 [("Retry-After", "2")],
1165 Json(serde_json::to_value(&payload).unwrap_or_default()),
1166 )
1167 .into_response();
1168 }
1169 }
1170 Err(err) => {
1171 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1172 return (
1173 StatusCode::SERVICE_UNAVAILABLE,
1174 [("Retry-After", "2")],
1175 Json(serde_json::to_value(&payload).unwrap_or_default()),
1176 )
1177 .into_response();
1178 }
1179 }
1180 }
1181 return (
1182 StatusCode::ACCEPTED,
1183 Json(json!({
1184 "status": "pending",
1185 "pending_id": pending_id,
1186 "reason": "governance requires approval",
1187 "action": "delete",
1188 "memory_id": target_id,
1189 })),
1190 )
1191 .into_response();
1192 }
1193 Err(e) => {
1194 tracing::error!("governance error: {e}");
1195 return (
1196 StatusCode::INTERNAL_SERVER_ERROR,
1197 Json(json!({"error": "governance check failed"})),
1198 )
1199 .into_response();
1200 }
1201 }
1202 }
1203
1204 let delete_outcome = db::delete(&lock.0, &target.id);
1205 if matches!(delete_outcome, Ok(true)) {
1213 let details = serde_json::to_value(crate::subscriptions::DeleteEventDetails {
1214 title: target.title.clone(),
1215 tier: target.tier.to_string(),
1216 })
1217 .ok();
1218 let owner_aid = target
1219 .metadata
1220 .get("agent_id")
1221 .and_then(|v| v.as_str())
1222 .map(str::to_string);
1223 crate::subscriptions::dispatch_event_with_details(
1224 &lock.0,
1225 "memory_delete",
1226 &target.id,
1227 &target.namespace,
1228 owner_aid.as_deref(),
1229 &lock.1,
1230 details,
1231 );
1232 }
1233 drop(lock);
1236 match delete_outcome {
1237 Ok(true) => {
1238 let owner = target
1240 .metadata
1241 .get("agent_id")
1242 .and_then(|v| v.as_str())
1243 .map(str::to_string)
1244 .unwrap_or_else(|| {
1245 headers
1246 .get("x-agent-id")
1247 .and_then(|v| v.to_str().ok())
1248 .unwrap_or("anonymous")
1249 .to_string()
1250 });
1251 crate::audit::emit(crate::audit::EventBuilder::new(
1252 crate::audit::AuditAction::Delete,
1253 crate::audit::actor(owner, "http_header", None),
1254 crate::audit::target_memory(
1255 target.id.clone(),
1256 target.namespace.clone(),
1257 Some(target.title.clone()),
1258 Some(target.tier.to_string()),
1259 None,
1260 ),
1261 ));
1262 if let Some(fed) = app.federation.as_ref()
1264 && let Ok(tracker) =
1265 crate::federation::broadcast_delete_quorum(fed, &target.id).await
1266 && let Err(err) = crate::federation::finalise_quorum(&tracker)
1267 {
1268 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1269 return (
1270 StatusCode::SERVICE_UNAVAILABLE,
1271 [("Retry-After", "2")],
1272 Json(serde_json::to_value(&payload).unwrap_or_default()),
1273 )
1274 .into_response();
1275 }
1276 Json(json!({"deleted": true})).into_response()
1277 }
1278 _ => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
1279 }
1280}
1281
1282#[allow(clippy::too_many_lines)]
1283pub async fn promote_memory(
1284 State(app): State<AppState>,
1285 headers: HeaderMap,
1286 Path(id): Path<String>,
1287) -> impl IntoResponse {
1288 let state = app.db.clone();
1289 if let Err(e) = validate::validate_id(&id) {
1290 return (
1291 StatusCode::BAD_REQUEST,
1292 Json(json!({"error": e.to_string()})),
1293 )
1294 .into_response();
1295 }
1296 let lock = state.lock().await;
1297 let target = match db::resolve_id(&lock.0, &id) {
1299 Ok(Some(mem)) => mem,
1300 Ok(None) => {
1301 return (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response();
1302 }
1303 Err(e) => {
1304 let msg = e.to_string();
1305 if msg.contains("ambiguous ID prefix") {
1306 return (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response();
1307 }
1308 tracing::error!("handler error: {e}");
1309 return (
1310 StatusCode::INTERNAL_SERVER_ERROR,
1311 Json(json!({"error": "internal server error"})),
1312 )
1313 .into_response();
1314 }
1315 };
1316 {
1318 use crate::models::{GovernanceDecision, GovernedAction};
1319 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
1320 let agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
1321 Ok(a) => a,
1322 Err(e) => {
1323 return (
1324 StatusCode::BAD_REQUEST,
1325 Json(json!({"error": format!("invalid agent_id: {e}")})),
1326 )
1327 .into_response();
1328 }
1329 };
1330 let mem_owner = target
1331 .metadata
1332 .get("agent_id")
1333 .and_then(|v| v.as_str())
1334 .map(str::to_string);
1335 let payload = json!({"id": target.id});
1336 match db::enforce_governance(
1337 &lock.0,
1338 GovernedAction::Promote,
1339 &target.namespace,
1340 &agent_id,
1341 Some(&target.id),
1342 mem_owner.as_deref(),
1343 &payload,
1344 ) {
1345 Ok(GovernanceDecision::Allow) => {}
1346 Ok(GovernanceDecision::Deny(reason)) => {
1347 return (
1348 StatusCode::FORBIDDEN,
1349 Json(json!({"error": format!("promote denied by governance: {reason}")})),
1350 )
1351 .into_response();
1352 }
1353 Ok(GovernanceDecision::Pending(pending_id)) => {
1354 let pending_row = db::get_pending_action(&lock.0, &pending_id).ok().flatten();
1356 let target_id = target.id.clone();
1357 drop(lock);
1358 if let (Some(pa), Some(fed)) = (pending_row.as_ref(), app.federation.as_ref()) {
1359 match crate::federation::broadcast_pending_quorum(fed, pa).await {
1360 Ok(tracker) => {
1361 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
1362 let payload =
1363 crate::federation::QuorumNotMetPayload::from_err(&err);
1364 return (
1365 StatusCode::SERVICE_UNAVAILABLE,
1366 [("Retry-After", "2")],
1367 Json(serde_json::to_value(&payload).unwrap_or_default()),
1368 )
1369 .into_response();
1370 }
1371 }
1372 Err(err) => {
1373 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1374 return (
1375 StatusCode::SERVICE_UNAVAILABLE,
1376 [("Retry-After", "2")],
1377 Json(serde_json::to_value(&payload).unwrap_or_default()),
1378 )
1379 .into_response();
1380 }
1381 }
1382 }
1383 return (
1384 StatusCode::ACCEPTED,
1385 Json(json!({
1386 "status": "pending",
1387 "pending_id": pending_id,
1388 "reason": "governance requires approval",
1389 "action": "promote",
1390 "memory_id": target_id,
1391 })),
1392 )
1393 .into_response();
1394 }
1395 Err(e) => {
1396 tracing::error!("governance error: {e}");
1397 return (
1398 StatusCode::INTERNAL_SERVER_ERROR,
1399 Json(json!({"error": "governance check failed"})),
1400 )
1401 .into_response();
1402 }
1403 }
1404 }
1405
1406 let resolved_id = target.id.clone();
1407 match db::update(
1408 &lock.0,
1409 &resolved_id,
1410 None,
1411 None,
1412 Some(&Tier::Long),
1413 None,
1414 None,
1415 None,
1416 None,
1417 None,
1418 None,
1419 ) {
1420 Ok((true, _)) => {
1421 if let Err(e) = lock.0.execute(
1422 "UPDATE memories SET expires_at = NULL WHERE id = ?1",
1423 rusqlite::params![resolved_id],
1424 ) {
1425 tracing::error!("promote clear expiry failed: {e}");
1426 return (
1427 StatusCode::INTERNAL_SERVER_ERROR,
1428 Json(json!({"error": "internal server error"})),
1429 )
1430 .into_response();
1431 }
1432 let promoted_mem = db::get(&lock.0, &resolved_id).ok().flatten();
1435 let owner_aid = target
1439 .metadata
1440 .get("agent_id")
1441 .and_then(|v| v.as_str())
1442 .map(str::to_string);
1443 let details = serde_json::to_value(crate::subscriptions::PromoteEventDetails {
1444 mode: "tier".to_string(),
1445 tier: Some("long".to_string()),
1446 to_namespace: None,
1447 clone_id: None,
1448 })
1449 .ok();
1450 crate::subscriptions::dispatch_event_with_details(
1451 &lock.0,
1452 "memory_promote",
1453 &resolved_id,
1454 &target.namespace,
1455 owner_aid.as_deref(),
1456 &lock.1,
1457 details,
1458 );
1459 drop(lock);
1460 if let (Some(fed), Some(m)) = (app.federation.as_ref(), promoted_mem.as_ref())
1461 && let Ok(tracker) = crate::federation::broadcast_store_quorum(fed, m).await
1462 && let Err(err) = crate::federation::finalise_quorum(&tracker)
1463 {
1464 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
1465 return (
1466 StatusCode::SERVICE_UNAVAILABLE,
1467 [("Retry-After", "2")],
1468 Json(serde_json::to_value(&payload).unwrap_or_default()),
1469 )
1470 .into_response();
1471 }
1472 Json(json!({"promoted": true, "id": resolved_id, "tier": "long"})).into_response()
1473 }
1474 Ok((false, _)) => {
1475 (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response()
1476 }
1477 Err(e) => {
1478 tracing::error!("handler error: {e}");
1479 (
1480 StatusCode::INTERNAL_SERVER_ERROR,
1481 Json(json!({"error": "internal server error"})),
1482 )
1483 .into_response()
1484 }
1485 }
1486}
1487
1488pub async fn list_memories(
1489 State(state): State<Db>,
1490 Query(p): Query<ListQuery>,
1491) -> impl IntoResponse {
1492 if let Some(ref aid) = p.agent_id
1494 && let Err(e) = validate::validate_agent_id(aid)
1495 {
1496 return (
1497 StatusCode::BAD_REQUEST,
1498 Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1499 )
1500 .into_response();
1501 }
1502 let lock = state.lock().await;
1503 let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1510 match db::list(
1511 &lock.0,
1512 p.namespace.as_deref(),
1513 p.tier.as_ref(),
1514 limit,
1515 p.offset.unwrap_or(0),
1516 p.min_priority,
1517 p.since.as_deref(),
1518 p.until.as_deref(),
1519 p.tags.as_deref(),
1520 p.agent_id.as_deref(),
1521 ) {
1522 Ok(mems) => Json(json!({"memories": mems, "count": mems.len()})).into_response(),
1523 Err(e) => {
1524 tracing::error!("handler error: {e}");
1525 (
1526 StatusCode::INTERNAL_SERVER_ERROR,
1527 Json(json!({"error": "internal server error"})),
1528 )
1529 .into_response()
1530 }
1531 }
1532}
1533
1534pub async fn search_memories(
1535 State(state): State<Db>,
1536 Query(p): Query<SearchQuery>,
1537) -> impl IntoResponse {
1538 if p.q.trim().is_empty() {
1539 return (
1540 StatusCode::BAD_REQUEST,
1541 Json(json!({"error": "query is required"})),
1542 )
1543 .into_response();
1544 }
1545 if let Some(ref aid) = p.agent_id
1547 && let Err(e) = validate::validate_agent_id(aid)
1548 {
1549 return (
1550 StatusCode::BAD_REQUEST,
1551 Json(json!({"error": format!("invalid agent_id filter: {e}")})),
1552 )
1553 .into_response();
1554 }
1555 if let Some(ref a) = p.as_agent
1557 && let Err(e) = validate::validate_namespace(a)
1558 {
1559 return (
1560 StatusCode::BAD_REQUEST,
1561 Json(json!({"error": format!("invalid as_agent: {e}")})),
1562 )
1563 .into_response();
1564 }
1565 let lock = state.lock().await;
1566 let limit = p.limit.unwrap_or(20).min(MAX_BULK_SIZE);
1569 match db::search(
1570 &lock.0,
1571 &p.q,
1572 p.namespace.as_deref(),
1573 p.tier.as_ref(),
1574 limit,
1575 p.min_priority,
1576 p.since.as_deref(),
1577 p.until.as_deref(),
1578 p.tags.as_deref(),
1579 p.agent_id.as_deref(),
1580 p.as_agent.as_deref(),
1581 ) {
1582 Ok(r) => Json(json!({"results": r, "count": r.len(), "query": p.q})).into_response(),
1583 Err(e) => {
1584 tracing::error!("handler error: {e}");
1585 (
1586 StatusCode::INTERNAL_SERVER_ERROR,
1587 Json(json!({"error": "internal server error"})),
1588 )
1589 .into_response()
1590 }
1591 }
1592}
1593
1594pub async fn recall_memories_get(
1595 State(app): State<AppState>,
1596 Query(p): Query<RecallQuery>,
1597) -> impl IntoResponse {
1598 let ctx = p.context.unwrap_or_default();
1599 if ctx.trim().is_empty() {
1600 return (
1601 StatusCode::BAD_REQUEST,
1602 Json(json!({"error": "context is required"})),
1603 )
1604 .into_response();
1605 }
1606 if let Some(ref a) = p.as_agent
1612 && let Err(e) = validate::validate_namespace(a)
1613 {
1614 return (
1615 StatusCode::BAD_REQUEST,
1616 Json(json!({"error": format!("invalid as_agent: {e}")})),
1617 )
1618 .into_response();
1619 }
1620 let limit = p.limit.unwrap_or(10).min(50);
1621 recall_response(
1622 &app,
1623 &ctx,
1624 p.namespace.as_deref(),
1625 limit,
1626 p.tags.as_deref(),
1627 p.since.as_deref(),
1628 p.until.as_deref(),
1629 p.as_agent.as_deref(),
1630 p.budget_tokens,
1631 )
1632 .await
1633}
1634
1635pub async fn recall_memories_post(
1636 State(app): State<AppState>,
1637 Json(body): Json<RecallBody>,
1638) -> impl IntoResponse {
1639 if body.context.trim().is_empty() {
1640 return (
1641 StatusCode::BAD_REQUEST,
1642 Json(json!({"error": "context is required"})),
1643 )
1644 .into_response();
1645 }
1646 if let Some(ref a) = body.as_agent
1649 && let Err(e) = validate::validate_namespace(a)
1650 {
1651 return (
1652 StatusCode::BAD_REQUEST,
1653 Json(json!({"error": format!("invalid as_agent: {e}")})),
1654 )
1655 .into_response();
1656 }
1657 let limit = body.limit.unwrap_or(10).min(50);
1658 recall_response(
1659 &app,
1660 &body.context,
1661 body.namespace.as_deref(),
1662 limit,
1663 body.tags.as_deref(),
1664 body.since.as_deref(),
1665 body.until.as_deref(),
1666 body.as_agent.as_deref(),
1667 body.budget_tokens,
1668 )
1669 .await
1670}
1671
1672#[allow(clippy::too_many_arguments)]
1681async fn recall_response(
1682 app: &AppState,
1683 context: &str,
1684 namespace: Option<&str>,
1685 limit: usize,
1686 tags: Option<&str>,
1687 since: Option<&str>,
1688 until: Option<&str>,
1689 as_agent: Option<&str>,
1690 budget_tokens: Option<usize>,
1691) -> axum::response::Response {
1692 let query_emb: Option<Vec<f32>> = if let Some(emb) = app.embedder.as_ref().as_ref() {
1695 match emb.embed(context) {
1696 Ok(v) => Some(v),
1697 Err(e) => {
1698 tracing::warn!("recall: embedder query failed, falling back to keyword-only: {e}");
1699 None
1700 }
1701 }
1702 } else {
1703 None
1704 };
1705
1706 let lock = app.db.lock().await;
1707 let short_extend = lock.2.short_extend_secs;
1708 let mid_extend = lock.2.mid_extend_secs;
1709
1710 let (result, mode) = if let Some(ref qe) = query_emb {
1711 let vi_guard = app.vector_index.lock().await;
1712 let vi_ref = vi_guard.as_ref();
1713 let r = db::recall_hybrid(
1714 &lock.0,
1715 context,
1716 qe,
1717 namespace,
1718 limit,
1719 tags,
1720 since,
1721 until,
1722 vi_ref,
1723 short_extend,
1724 mid_extend,
1725 as_agent,
1726 budget_tokens,
1727 app.scoring.as_ref(),
1728 );
1729 drop(vi_guard);
1730 (r, "hybrid")
1731 } else {
1732 let r = db::recall(
1733 &lock.0,
1734 context,
1735 namespace,
1736 limit,
1737 tags,
1738 since,
1739 until,
1740 short_extend,
1741 mid_extend,
1742 as_agent,
1743 budget_tokens,
1744 );
1745 (r, "keyword")
1746 };
1747
1748 match result {
1749 Ok((r, outcome)) => {
1750 let scored: Vec<serde_json::Value> = r
1751 .iter()
1752 .map(|(m, s)| {
1753 let mut v = serde_json::to_value(m).unwrap_or_default();
1754 if let Some(obj) = v.as_object_mut() {
1755 obj.insert("score".to_string(), json!((*s * 1000.0).round() / 1000.0));
1756 }
1757 v
1758 })
1759 .collect();
1760 let mut resp = json!({
1761 "memories": scored,
1762 "count": scored.len(),
1763 "tokens_used": outcome.tokens_used,
1764 "mode": mode,
1765 });
1766 if let Some(b) = budget_tokens {
1767 resp["budget_tokens"] = json!(b);
1768 resp["meta"] = json!({
1770 "budget_tokens_used": outcome.tokens_used,
1771 "budget_tokens_remaining": outcome.tokens_remaining.unwrap_or(0),
1772 "memories_dropped": outcome.memories_dropped,
1773 "budget_overflow": outcome.budget_overflow,
1774 });
1775 }
1776 Json(resp).into_response()
1777 }
1778 Err(e) => {
1779 tracing::error!("handler error: {e}");
1780 (
1781 StatusCode::INTERNAL_SERVER_ERROR,
1782 Json(json!({"error": "internal server error"})),
1783 )
1784 .into_response()
1785 }
1786 }
1787}
1788
1789pub async fn forget_memories(
1790 State(state): State<Db>,
1791 Json(body): Json<ForgetQuery>,
1792) -> impl IntoResponse {
1793 let lock = state.lock().await;
1794 match db::forget(
1795 &lock.0,
1796 body.namespace.as_deref(),
1797 body.pattern.as_deref(),
1798 body.tier.as_ref(),
1799 lock.3, ) {
1801 Ok(n) => Json(json!({"deleted": n})).into_response(),
1802 Err(e) => (
1803 StatusCode::BAD_REQUEST,
1804 Json(json!({"error": e.to_string()})),
1805 )
1806 .into_response(),
1807 }
1808}
1809
1810#[derive(Deserialize)]
1811pub struct ContradictionsQuery {
1812 pub topic: Option<String>,
1816 pub namespace: Option<String>,
1818 pub limit: Option<usize>,
1820}
1821
1822#[allow(clippy::too_many_lines)]
1843pub async fn detect_contradictions(
1844 State(state): State<Db>,
1845 Query(q): Query<ContradictionsQuery>,
1846) -> impl IntoResponse {
1847 if q.topic.is_none() && q.namespace.is_none() {
1848 return (
1849 StatusCode::BAD_REQUEST,
1850 Json(json!({"error": "at least one of `topic` or `namespace` is required"})),
1851 )
1852 .into_response();
1853 }
1854 if let Some(ref ns) = q.namespace
1855 && let Err(e) = validate::validate_namespace(ns)
1856 {
1857 return (
1858 StatusCode::BAD_REQUEST,
1859 Json(json!({"error": e.to_string()})),
1860 )
1861 .into_response();
1862 }
1863 let limit = q.limit.unwrap_or(50).min(MAX_BULK_SIZE);
1866 let lock = state.lock().await;
1867 let all = match db::list(
1868 &lock.0,
1869 q.namespace.as_deref(),
1870 None,
1871 limit,
1872 0,
1873 None,
1874 None,
1875 None,
1876 None,
1877 None,
1878 ) {
1879 Ok(v) => v,
1880 Err(e) => {
1881 tracing::error!("detect_contradictions list error: {e}");
1882 return (
1883 StatusCode::INTERNAL_SERVER_ERROR,
1884 Json(json!({"error": "internal server error"})),
1885 )
1886 .into_response();
1887 }
1888 };
1889
1890 let candidates: Vec<Memory> = match q.topic.as_deref() {
1894 Some(t) => all
1895 .into_iter()
1896 .filter(|m| {
1897 m.metadata
1898 .get("topic")
1899 .and_then(|v| v.as_str())
1900 .is_some_and(|s| s == t)
1901 || m.title == t
1902 })
1903 .collect(),
1904 None => all,
1905 };
1906
1907 let candidate_ids: std::collections::HashSet<String> =
1909 candidates.iter().map(|m| m.id.clone()).collect();
1910 let mut existing_links: Vec<serde_json::Value> = Vec::new();
1911 for id in &candidate_ids {
1912 if let Ok(links) = db::get_links(&lock.0, id) {
1913 for link in links {
1914 if link.relation.contains("contradict")
1915 && candidate_ids.contains(&link.source_id)
1916 && candidate_ids.contains(&link.target_id)
1917 {
1918 existing_links.push(json!({
1919 "source_id": link.source_id,
1920 "target_id": link.target_id,
1921 "relation": link.relation,
1922 "synthesized": false,
1923 }));
1924 }
1925 }
1926 }
1927 }
1928 existing_links.sort_by_key(|v| {
1930 (
1931 v.get("source_id")
1932 .and_then(|s| s.as_str())
1933 .unwrap_or("")
1934 .to_string(),
1935 v.get("target_id")
1936 .and_then(|s| s.as_str())
1937 .unwrap_or("")
1938 .to_string(),
1939 v.get("relation")
1940 .and_then(|s| s.as_str())
1941 .unwrap_or("")
1942 .to_string(),
1943 )
1944 });
1945 existing_links.dedup_by_key(|v| {
1946 (
1947 v.get("source_id")
1948 .and_then(|s| s.as_str())
1949 .unwrap_or("")
1950 .to_string(),
1951 v.get("target_id")
1952 .and_then(|s| s.as_str())
1953 .unwrap_or("")
1954 .to_string(),
1955 v.get("relation")
1956 .and_then(|s| s.as_str())
1957 .unwrap_or("")
1958 .to_string(),
1959 )
1960 });
1961
1962 let mut synth_links: Vec<serde_json::Value> = Vec::new();
1967 for (i, a) in candidates.iter().enumerate() {
1968 for b in candidates.iter().skip(i + 1) {
1969 let same_topic = match q.topic.as_deref() {
1970 Some(_) => true,
1971 None => a.title == b.title,
1972 };
1973 if same_topic && a.content != b.content && a.id != b.id {
1974 synth_links.push(json!({
1975 "source_id": a.id,
1976 "target_id": b.id,
1977 "relation": "contradicts",
1978 "synthesized": true,
1979 }));
1980 }
1981 }
1982 }
1983
1984 let mut links = existing_links;
1985 links.extend(synth_links);
1986
1987 Json(json!({
1988 "memories": candidates,
1989 "links": links,
1990 }))
1991 .into_response()
1992}
1993
1994pub async fn list_namespaces(State(state): State<Db>) -> impl IntoResponse {
1995 let lock = state.lock().await;
1996 match db::list_namespaces(&lock.0) {
1997 Ok(ns) => Json(json!({"namespaces": ns})).into_response(),
1998 Err(e) => {
1999 tracing::error!("handler error: {e}");
2000 (
2001 StatusCode::INTERNAL_SERVER_ERROR,
2002 Json(json!({"error": "internal server error"})),
2003 )
2004 .into_response()
2005 }
2006 }
2007}
2008
2009#[derive(Debug, Deserialize)]
2011pub struct TaxonomyQuery {
2012 pub prefix: Option<String>,
2015 pub depth: Option<usize>,
2018 pub limit: Option<usize>,
2021}
2022
2023pub async fn get_taxonomy(
2028 State(state): State<Db>,
2029 Query(p): Query<TaxonomyQuery>,
2030) -> impl IntoResponse {
2031 let prefix_owned: Option<String> = p
2032 .prefix
2033 .as_deref()
2034 .map(str::trim)
2035 .filter(|s| !s.is_empty())
2036 .map(|s| s.trim_end_matches('/').to_string());
2037 if let Some(pref) = prefix_owned.as_deref()
2038 && let Err(e) = validate::validate_namespace(pref)
2039 {
2040 return (
2041 StatusCode::BAD_REQUEST,
2042 Json(json!({"error": format!("invalid namespace_prefix: {e}")})),
2043 )
2044 .into_response();
2045 }
2046 let depth = p
2047 .depth
2048 .unwrap_or(crate::models::MAX_NAMESPACE_DEPTH)
2049 .min(crate::models::MAX_NAMESPACE_DEPTH);
2050 let limit = p.limit.unwrap_or(1000).clamp(1, 10_000);
2051 let lock = state.lock().await;
2052 match db::get_taxonomy(&lock.0, prefix_owned.as_deref(), depth, limit) {
2053 Ok(tax) => Json(json!({
2054 "tree": tax.tree,
2055 "total_count": tax.total_count,
2056 "truncated": tax.truncated,
2057 }))
2058 .into_response(),
2059 Err(e) => {
2060 tracing::error!("handler error: {e}");
2061 (
2062 StatusCode::INTERNAL_SERVER_ERROR,
2063 Json(json!({"error": "internal server error"})),
2064 )
2065 .into_response()
2066 }
2067 }
2068}
2069
2070#[derive(Debug, Deserialize)]
2072pub struct CheckDuplicateBody {
2073 pub title: String,
2074 pub content: String,
2075 pub namespace: Option<String>,
2078 pub threshold: Option<f32>,
2082}
2083
2084pub async fn check_duplicate(
2090 State(app): State<AppState>,
2091 Json(body): Json<CheckDuplicateBody>,
2092) -> impl IntoResponse {
2093 if let Err(e) = validate::validate_title(&body.title) {
2094 return (
2095 StatusCode::BAD_REQUEST,
2096 Json(json!({"error": format!("invalid title: {e}")})),
2097 )
2098 .into_response();
2099 }
2100 if let Err(e) = validate::validate_content(&body.content) {
2101 return (
2102 StatusCode::BAD_REQUEST,
2103 Json(json!({"error": format!("invalid content: {e}")})),
2104 )
2105 .into_response();
2106 }
2107 let namespace = body
2108 .namespace
2109 .as_deref()
2110 .map(str::trim)
2111 .filter(|s| !s.is_empty());
2112 if let Some(ns) = namespace
2113 && let Err(e) = validate::validate_namespace(ns)
2114 {
2115 return (
2116 StatusCode::BAD_REQUEST,
2117 Json(json!({"error": format!("invalid namespace: {e}")})),
2118 )
2119 .into_response();
2120 }
2121 let threshold = body.threshold.unwrap_or(db::DUPLICATE_THRESHOLD_DEFAULT);
2122
2123 let embedding_text = format!("{} {}", body.title, body.content);
2127 let query_embedding = match app.embedder.as_ref().as_ref() {
2128 Some(emb) => match emb.embed(&embedding_text) {
2129 Ok(v) => v,
2130 Err(e) => {
2131 tracing::warn!("embedding generation failed: {e}");
2132 return (
2133 StatusCode::SERVICE_UNAVAILABLE,
2134 Json(json!({"error": "embedder failed to encode input"})),
2135 )
2136 .into_response();
2137 }
2138 },
2139 None => {
2140 return (
2141 StatusCode::SERVICE_UNAVAILABLE,
2142 Json(json!({
2143 "error": "memory_check_duplicate requires the embedder; daemon must be started with semantic tier or above"
2144 })),
2145 )
2146 .into_response();
2147 }
2148 };
2149
2150 let lock = app.db.lock().await;
2151 let check = match db::check_duplicate(&lock.0, &query_embedding, namespace, threshold) {
2152 Ok(c) => c,
2153 Err(e) => {
2154 tracing::error!("handler error: {e}");
2155 return (
2156 StatusCode::INTERNAL_SERVER_ERROR,
2157 Json(json!({"error": "internal server error"})),
2158 )
2159 .into_response();
2160 }
2161 };
2162
2163 let nearest_json = check.nearest.as_ref().map(|m| {
2164 json!({
2165 "id": m.id,
2166 "title": m.title,
2167 "namespace": m.namespace,
2168 "similarity": (m.similarity * 1000.0).round() / 1000.0,
2169 })
2170 });
2171 let suggested_merge = if check.is_duplicate {
2172 check.nearest.as_ref().map(|m| m.id.clone())
2173 } else {
2174 None
2175 };
2176
2177 Json(json!({
2178 "is_duplicate": check.is_duplicate,
2179 "threshold": check.threshold,
2180 "nearest": nearest_json,
2181 "suggested_merge": suggested_merge,
2182 "candidates_scanned": check.candidates_scanned,
2183 }))
2184 .into_response()
2185}
2186
2187#[derive(Debug, Deserialize)]
2189pub struct EntityRegisterBody {
2190 pub canonical_name: String,
2191 pub namespace: String,
2192 #[serde(default)]
2195 pub aliases: Vec<String>,
2196 #[serde(default)]
2199 pub metadata: serde_json::Value,
2200 pub agent_id: Option<String>,
2204}
2205
2206#[derive(Debug, Deserialize)]
2209pub struct EntityByAliasQuery {
2210 pub alias: String,
2211 pub namespace: Option<String>,
2212}
2213
2214pub async fn entity_register(
2218 State(state): State<Db>,
2219 headers: HeaderMap,
2220 Json(body): Json<EntityRegisterBody>,
2221) -> impl IntoResponse {
2222 if let Err(e) = validate::validate_title(&body.canonical_name) {
2223 return (
2224 StatusCode::BAD_REQUEST,
2225 Json(json!({"error": format!("invalid canonical_name: {e}")})),
2226 )
2227 .into_response();
2228 }
2229 if let Err(e) = validate::validate_namespace(&body.namespace) {
2230 return (
2231 StatusCode::BAD_REQUEST,
2232 Json(json!({"error": format!("invalid namespace: {e}")})),
2233 )
2234 .into_response();
2235 }
2236
2237 let agent_id = body
2238 .agent_id
2239 .as_deref()
2240 .or_else(|| headers.get("x-agent-id").and_then(|v| v.to_str().ok()))
2241 .map(str::trim)
2242 .filter(|s| !s.is_empty())
2243 .map(str::to_string);
2244 if let Some(aid) = agent_id.as_deref()
2245 && let Err(e) = validate::validate_agent_id(aid)
2246 {
2247 return (
2248 StatusCode::BAD_REQUEST,
2249 Json(json!({"error": format!("invalid agent_id: {e}")})),
2250 )
2251 .into_response();
2252 }
2253
2254 let extra_metadata = if body.metadata.is_object() {
2255 body.metadata.clone()
2256 } else {
2257 json!({})
2258 };
2259
2260 let lock = state.lock().await;
2261 match db::entity_register(
2262 &lock.0,
2263 &body.canonical_name,
2264 &body.namespace,
2265 &body.aliases,
2266 &extra_metadata,
2267 agent_id.as_deref(),
2268 ) {
2269 Ok(reg) => {
2270 let status = if reg.created {
2271 StatusCode::CREATED
2272 } else {
2273 StatusCode::OK
2274 };
2275 (
2276 status,
2277 Json(json!({
2278 "entity_id": reg.entity_id,
2279 "canonical_name": reg.canonical_name,
2280 "namespace": reg.namespace,
2281 "aliases": reg.aliases,
2282 "created": reg.created,
2283 })),
2284 )
2285 .into_response()
2286 }
2287 Err(e) => {
2288 let msg = e.to_string();
2292 if msg.contains("non-entity memory") {
2293 return (StatusCode::CONFLICT, Json(json!({"error": msg}))).into_response();
2294 }
2295 tracing::error!("handler error: {e}");
2296 (
2297 StatusCode::INTERNAL_SERVER_ERROR,
2298 Json(json!({"error": "internal server error"})),
2299 )
2300 .into_response()
2301 }
2302 }
2303}
2304
2305pub async fn entity_get_by_alias(
2311 State(state): State<Db>,
2312 Query(p): Query<EntityByAliasQuery>,
2313) -> impl IntoResponse {
2314 let alias = p.alias.trim();
2315 if alias.is_empty() {
2316 return (
2317 StatusCode::BAD_REQUEST,
2318 Json(json!({"error": "alias is required"})),
2319 )
2320 .into_response();
2321 }
2322 let namespace = p
2323 .namespace
2324 .as_deref()
2325 .map(str::trim)
2326 .filter(|s| !s.is_empty());
2327 if let Some(ns) = namespace
2328 && let Err(e) = validate::validate_namespace(ns)
2329 {
2330 return (
2331 StatusCode::BAD_REQUEST,
2332 Json(json!({"error": format!("invalid namespace: {e}")})),
2333 )
2334 .into_response();
2335 }
2336
2337 let lock = state.lock().await;
2338 match db::entity_get_by_alias(&lock.0, alias, namespace) {
2339 Ok(Some(rec)) => Json(json!({
2340 "found": true,
2341 "entity_id": rec.entity_id,
2342 "canonical_name": rec.canonical_name,
2343 "namespace": rec.namespace,
2344 "aliases": rec.aliases,
2345 }))
2346 .into_response(),
2347 Ok(None) => Json(json!({
2348 "found": false,
2349 "entity_id": null,
2350 "canonical_name": null,
2351 "namespace": null,
2352 "aliases": [],
2353 }))
2354 .into_response(),
2355 Err(e) => {
2356 tracing::error!("handler error: {e}");
2357 (
2358 StatusCode::INTERNAL_SERVER_ERROR,
2359 Json(json!({"error": "internal server error"})),
2360 )
2361 .into_response()
2362 }
2363 }
2364}
2365
2366#[derive(Debug, Deserialize)]
2368pub struct KgTimelineQuery {
2369 pub source_id: String,
2370 pub since: Option<String>,
2371 pub until: Option<String>,
2372 pub limit: Option<usize>,
2373}
2374
2375pub async fn kg_timeline(
2379 State(state): State<Db>,
2380 Query(p): Query<KgTimelineQuery>,
2381) -> impl IntoResponse {
2382 if let Err(e) = validate::validate_id(&p.source_id) {
2383 return (
2384 StatusCode::BAD_REQUEST,
2385 Json(json!({"error": format!("invalid source_id: {e}")})),
2386 )
2387 .into_response();
2388 }
2389 let since = p.since.as_deref().map(str::trim).filter(|s| !s.is_empty());
2390 let until = p.until.as_deref().map(str::trim).filter(|s| !s.is_empty());
2391 if let Some(s) = since
2392 && let Err(e) = validate::validate_expires_at_format(s)
2393 {
2394 return (
2395 StatusCode::BAD_REQUEST,
2396 Json(json!({"error": format!("invalid since: {e}")})),
2397 )
2398 .into_response();
2399 }
2400 if let Some(u) = until
2401 && let Err(e) = validate::validate_expires_at_format(u)
2402 {
2403 return (
2404 StatusCode::BAD_REQUEST,
2405 Json(json!({"error": format!("invalid until: {e}")})),
2406 )
2407 .into_response();
2408 }
2409
2410 let lock = state.lock().await;
2411 match db::kg_timeline(&lock.0, &p.source_id, since, until, p.limit) {
2412 Ok(events) => {
2413 let events_json: Vec<serde_json::Value> = events
2414 .iter()
2415 .map(|e| {
2416 json!({
2417 "target_id": e.target_id,
2418 "relation": e.relation,
2419 "valid_from": e.valid_from,
2420 "valid_until": e.valid_until,
2421 "observed_by": e.observed_by,
2422 "title": e.title,
2423 "target_namespace": e.target_namespace,
2424 })
2425 })
2426 .collect();
2427 Json(json!({
2428 "source_id": p.source_id,
2429 "events": events_json,
2430 "count": events.len(),
2431 }))
2432 .into_response()
2433 }
2434 Err(e) => {
2435 tracing::error!("handler error: {e}");
2436 (
2437 StatusCode::INTERNAL_SERVER_ERROR,
2438 Json(json!({"error": "internal server error"})),
2439 )
2440 .into_response()
2441 }
2442 }
2443}
2444
2445#[derive(Debug, Deserialize)]
2449pub struct KgInvalidateBody {
2450 pub source_id: String,
2451 pub target_id: String,
2452 pub relation: String,
2453 pub valid_until: Option<String>,
2454}
2455
2456pub async fn kg_invalidate(
2460 State(state): State<Db>,
2461 Json(body): Json<KgInvalidateBody>,
2462) -> impl IntoResponse {
2463 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2464 return (
2465 StatusCode::BAD_REQUEST,
2466 Json(json!({"error": e.to_string()})),
2467 )
2468 .into_response();
2469 }
2470 let valid_until = body
2471 .valid_until
2472 .as_deref()
2473 .map(str::trim)
2474 .filter(|s| !s.is_empty());
2475 if let Some(ts) = valid_until
2476 && let Err(e) = validate::validate_expires_at_format(ts)
2477 {
2478 return (
2479 StatusCode::BAD_REQUEST,
2480 Json(json!({"error": format!("invalid valid_until: {e}")})),
2481 )
2482 .into_response();
2483 }
2484
2485 let lock = state.lock().await;
2486 match db::invalidate_link(
2487 &lock.0,
2488 &body.source_id,
2489 &body.target_id,
2490 &body.relation,
2491 valid_until,
2492 ) {
2493 Ok(Some(res)) => (
2494 StatusCode::OK,
2495 Json(json!({
2496 "found": true,
2497 "source_id": body.source_id,
2498 "target_id": body.target_id,
2499 "relation": body.relation,
2500 "valid_until": res.valid_until,
2501 "previous_valid_until": res.previous_valid_until,
2502 })),
2503 )
2504 .into_response(),
2505 Ok(None) => (
2506 StatusCode::NOT_FOUND,
2507 Json(json!({
2508 "found": false,
2509 "source_id": body.source_id,
2510 "target_id": body.target_id,
2511 "relation": body.relation,
2512 })),
2513 )
2514 .into_response(),
2515 Err(e) => {
2516 tracing::error!("handler error: {e}");
2517 (
2518 StatusCode::INTERNAL_SERVER_ERROR,
2519 Json(json!({"error": "internal server error"})),
2520 )
2521 .into_response()
2522 }
2523 }
2524}
2525
2526#[derive(Debug, Deserialize)]
2532pub struct KgQueryBody {
2533 pub source_id: String,
2534 pub max_depth: Option<usize>,
2535 pub valid_at: Option<String>,
2536 pub allowed_agents: Option<Vec<String>>,
2537 pub limit: Option<usize>,
2538}
2539
2540pub async fn kg_query(State(state): State<Db>, Json(body): Json<KgQueryBody>) -> impl IntoResponse {
2547 if let Err(e) = validate::validate_id(&body.source_id) {
2548 return (
2549 StatusCode::BAD_REQUEST,
2550 Json(json!({"error": format!("invalid source_id: {e}")})),
2551 )
2552 .into_response();
2553 }
2554 let max_depth = body.max_depth.unwrap_or(1);
2555 let valid_at = body
2556 .valid_at
2557 .as_deref()
2558 .map(str::trim)
2559 .filter(|s| !s.is_empty());
2560 if let Some(t) = valid_at
2561 && let Err(e) = validate::validate_expires_at_format(t)
2562 {
2563 return (
2564 StatusCode::BAD_REQUEST,
2565 Json(json!({"error": format!("invalid valid_at: {e}")})),
2566 )
2567 .into_response();
2568 }
2569 let allowed_agents: Option<Vec<String>> = body.allowed_agents.as_ref().map(|v| {
2570 v.iter()
2571 .map(|s| s.trim().to_string())
2572 .filter(|s| !s.is_empty())
2573 .collect()
2574 });
2575 if let Some(agents) = allowed_agents.as_ref() {
2576 for a in agents {
2577 if let Err(e) = validate::validate_agent_id(a) {
2578 return (
2579 StatusCode::BAD_REQUEST,
2580 Json(json!({"error": format!("invalid allowed_agents entry: {e}")})),
2581 )
2582 .into_response();
2583 }
2584 }
2585 }
2586
2587 let lock = state.lock().await;
2588 match db::kg_query(
2589 &lock.0,
2590 &body.source_id,
2591 max_depth,
2592 valid_at,
2593 allowed_agents.as_deref(),
2594 body.limit,
2595 ) {
2596 Ok(nodes) => {
2597 let memories_json: Vec<serde_json::Value> = nodes
2598 .iter()
2599 .map(|n| {
2600 json!({
2601 "target_id": n.target_id,
2602 "relation": n.relation,
2603 "valid_from": n.valid_from,
2604 "valid_until": n.valid_until,
2605 "observed_by": n.observed_by,
2606 "title": n.title,
2607 "target_namespace": n.target_namespace,
2608 "depth": n.depth,
2609 "path": n.path,
2610 })
2611 })
2612 .collect();
2613 let paths_json: Vec<&str> = nodes.iter().map(|n| n.path.as_str()).collect();
2614 Json(json!({
2615 "source_id": body.source_id,
2616 "max_depth": max_depth,
2617 "memories": memories_json,
2618 "paths": paths_json,
2619 "count": nodes.len(),
2620 }))
2621 .into_response()
2622 }
2623 Err(e) => {
2624 let msg = e.to_string();
2628 if msg.contains("max_depth") {
2629 return (
2630 StatusCode::UNPROCESSABLE_ENTITY,
2631 Json(json!({"error": msg})),
2632 )
2633 .into_response();
2634 }
2635 tracing::error!("handler 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 create_link(
2646 State(app): State<AppState>,
2647 Json(body): Json<LinkBody>,
2648) -> impl IntoResponse {
2649 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2650 return (
2651 StatusCode::BAD_REQUEST,
2652 Json(json!({"error": e.to_string()})),
2653 )
2654 .into_response();
2655 }
2656 let lock = app.db.lock().await;
2657 let create_result = db::create_link(&lock.0, &body.source_id, &body.target_id, &body.relation);
2658 if create_result.is_ok() {
2664 let (link_namespace, link_owner) = db::get(&lock.0, &body.source_id)
2665 .ok()
2666 .flatten()
2667 .map_or_else(
2668 || ("global".to_string(), None),
2669 |m| {
2670 let owner = m
2671 .metadata
2672 .get("agent_id")
2673 .and_then(|v| v.as_str())
2674 .map(str::to_string);
2675 (m.namespace, owner)
2676 },
2677 );
2678 let details = serde_json::to_value(crate::subscriptions::LinkCreatedEventDetails {
2679 target_id: body.target_id.clone(),
2680 relation: body.relation.clone(),
2681 })
2682 .ok();
2683 crate::subscriptions::dispatch_event_with_details(
2684 &lock.0,
2685 "memory_link_created",
2686 &body.source_id,
2687 &link_namespace,
2688 link_owner.as_deref(),
2689 &lock.1,
2690 details,
2691 );
2692 }
2693 drop(lock);
2696 match create_result {
2697 Ok(()) => {
2698 if let Some(fed) = app.federation.as_ref() {
2700 let link = crate::models::MemoryLink {
2701 source_id: body.source_id.clone(),
2702 target_id: body.target_id.clone(),
2703 relation: body.relation.clone(),
2704 created_at: chrono::Utc::now().to_rfc3339(),
2705 };
2706 match crate::federation::broadcast_link_quorum(fed, &link).await {
2707 Ok(tracker) => {
2708 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2709 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2710 return (
2711 StatusCode::SERVICE_UNAVAILABLE,
2712 [("Retry-After", "2")],
2713 Json(serde_json::to_value(&payload).unwrap_or_default()),
2714 )
2715 .into_response();
2716 }
2717 }
2718 Err(e) => {
2719 tracing::warn!("link fanout error (local committed): {e:?}");
2720 }
2721 }
2722 }
2723 (StatusCode::CREATED, Json(json!({"linked": true}))).into_response()
2724 }
2725 Err(e) => {
2726 tracing::error!("handler error: {e}");
2727 (
2728 StatusCode::INTERNAL_SERVER_ERROR,
2729 Json(json!({"error": "internal server error"})),
2730 )
2731 .into_response()
2732 }
2733 }
2734}
2735
2736pub async fn delete_link(
2743 State(app): State<AppState>,
2744 Json(body): Json<LinkBody>,
2745) -> impl IntoResponse {
2746 if let Err(e) = validate::validate_link(&body.source_id, &body.target_id, &body.relation) {
2747 return (
2748 StatusCode::BAD_REQUEST,
2749 Json(json!({"error": e.to_string()})),
2750 )
2751 .into_response();
2752 }
2753 let lock = app.db.lock().await;
2754 let delete_result = db::delete_link(&lock.0, &body.source_id, &body.target_id);
2755 drop(lock);
2756 match delete_result {
2757 Ok(removed) => Json(json!({"deleted": removed})).into_response(),
2758 Err(e) => {
2759 tracing::error!("handler error: {e}");
2760 (
2761 StatusCode::INTERNAL_SERVER_ERROR,
2762 Json(json!({"error": "internal server error"})),
2763 )
2764 .into_response()
2765 }
2766 }
2767}
2768
2769pub async fn get_links(State(state): State<Db>, Path(id): Path<String>) -> impl IntoResponse {
2770 if let Err(e) = validate::validate_id(&id) {
2771 return (
2772 StatusCode::BAD_REQUEST,
2773 Json(json!({"error": e.to_string()})),
2774 )
2775 .into_response();
2776 }
2777 let lock = state.lock().await;
2778 match db::get_links(&lock.0, &id) {
2779 Ok(links) => Json(json!({"links": links})).into_response(),
2780 Err(e) => {
2781 tracing::error!("handler error: {e}");
2782 (
2783 StatusCode::INTERNAL_SERVER_ERROR,
2784 Json(json!({"error": "internal server error"})),
2785 )
2786 .into_response()
2787 }
2788 }
2789}
2790
2791pub async fn get_stats(State(state): State<Db>) -> impl IntoResponse {
2792 let lock = state.lock().await;
2793 match db::stats(&lock.0, &lock.1) {
2794 Ok(s) => Json(json!(s)).into_response(),
2795 Err(e) => {
2796 tracing::error!("handler error: {e}");
2797 (
2798 StatusCode::INTERNAL_SERVER_ERROR,
2799 Json(json!({"error": "internal server error"})),
2800 )
2801 .into_response()
2802 }
2803 }
2804}
2805
2806pub async fn run_gc(State(state): State<Db>) -> impl IntoResponse {
2807 let lock = state.lock().await;
2808 match db::gc(&lock.0, lock.3) {
2809 Ok(n) => Json(json!({"expired_deleted": n})).into_response(),
2810 Err(e) => {
2811 tracing::error!("handler error: {e}");
2812 (
2813 StatusCode::INTERNAL_SERVER_ERROR,
2814 Json(json!({"error": "internal server error"})),
2815 )
2816 .into_response()
2817 }
2818 }
2819}
2820
2821pub async fn export_memories(State(state): State<Db>) -> impl IntoResponse {
2822 let lock = state.lock().await;
2823 match (db::export_all(&lock.0), db::export_links(&lock.0)) {
2824 (Ok(memories), Ok(links)) => {
2825 let count = memories.len();
2826 Json(json!({"memories": memories, "links": links, "count": count, "exported_at": Utc::now().to_rfc3339()})).into_response()
2827 }
2828 (Err(e), _) | (_, Err(e)) => {
2829 tracing::error!("export error: {e}");
2830 (
2831 StatusCode::INTERNAL_SERVER_ERROR,
2832 Json(json!({"error": "internal server error"})),
2833 )
2834 .into_response()
2835 }
2836 }
2837}
2838
2839pub async fn import_memories(
2840 State(state): State<Db>,
2841 Json(body): Json<ImportBody>,
2842) -> impl IntoResponse {
2843 if body.memories.len() > MAX_BULK_SIZE {
2844 return (
2845 StatusCode::BAD_REQUEST,
2846 Json(json!({"error": format!("import limited to {} memories", MAX_BULK_SIZE)})),
2847 )
2848 .into_response();
2849 }
2850 let lock = state.lock().await;
2851 let mut imported = 0usize;
2852 let mut errors = Vec::new();
2853 for mem in body.memories {
2854 if let Err(e) = validate::validate_memory(&mem) {
2855 errors.push(format!("{}: {}", mem.id, e));
2856 continue;
2857 }
2858 match db::insert(&lock.0, &mem) {
2859 Ok(_) => imported += 1,
2860 Err(e) => errors.push(format!("{}: {}", mem.id, e)),
2861 }
2862 }
2863 for link in body.links.unwrap_or_default() {
2864 if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
2865 continue;
2866 }
2867 let _ = db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation);
2868 }
2869 Json(json!({"imported": imported, "errors": errors})).into_response()
2870}
2871
2872#[derive(serde::Deserialize)]
2873pub struct ImportBody {
2874 pub memories: Vec<Memory>,
2875 #[serde(default)]
2876 pub links: Option<Vec<MemoryLink>>,
2877}
2878
2879#[derive(serde::Deserialize)]
2880pub struct ConsolidateBody {
2881 pub ids: Vec<String>,
2882 pub title: String,
2883 pub summary: String,
2884 #[serde(default = "default_ns")]
2885 pub namespace: String,
2886 #[serde(default)]
2887 pub tier: Option<Tier>,
2888 #[serde(default)]
2891 pub agent_id: Option<String>,
2892}
2893fn default_ns() -> String {
2894 "global".to_string()
2895}
2896
2897pub async fn consolidate_memories(
2898 State(app): State<AppState>,
2899 headers: HeaderMap,
2900 Json(body): Json<ConsolidateBody>,
2901) -> impl IntoResponse {
2902 if let Err(e) =
2903 validate::validate_consolidate(&body.ids, &body.title, &body.summary, &body.namespace)
2904 {
2905 return (
2906 StatusCode::BAD_REQUEST,
2907 Json(json!({"error": e.to_string()})),
2908 )
2909 .into_response();
2910 }
2911 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
2912 let consolidator_agent_id =
2913 match crate::identity::resolve_http_agent_id(body.agent_id.as_deref(), header_agent_id) {
2914 Ok(id) => id,
2915 Err(e) => {
2916 return (
2917 StatusCode::BAD_REQUEST,
2918 Json(json!({"error": format!("invalid agent_id: {e}")})),
2919 )
2920 .into_response();
2921 }
2922 };
2923 let lock = app.db.lock().await;
2924 let tier = body.tier.unwrap_or(Tier::Long);
2925 let source_ids = body.ids.clone();
2926 let consolidate_result = db::consolidate(
2927 &lock.0,
2928 &body.ids,
2929 &body.title,
2930 &body.summary,
2931 &body.namespace,
2932 &tier,
2933 "consolidation",
2934 &consolidator_agent_id,
2935 );
2936 let new_mem = match &consolidate_result {
2940 Ok(new_id) => db::get(&lock.0, new_id).ok().flatten(),
2941 Err(_) => None,
2942 };
2943 if let Ok(new_id) = &consolidate_result {
2947 let details = serde_json::to_value(crate::subscriptions::ConsolidatedEventDetails {
2948 source_ids: source_ids.clone(),
2949 source_count: source_ids.len(),
2950 })
2951 .ok();
2952 crate::subscriptions::dispatch_event_with_details(
2953 &lock.0,
2954 "memory_consolidated",
2955 new_id,
2956 &body.namespace,
2957 Some(&consolidator_agent_id),
2958 &lock.1,
2959 details,
2960 );
2961 }
2962 drop(lock);
2965 match consolidate_result {
2966 Ok(new_id) => {
2967 if let (Some(fed), Some(mem)) = (app.federation.as_ref(), new_mem) {
2971 match crate::federation::broadcast_consolidate_quorum(fed, &mem, &source_ids).await
2972 {
2973 Ok(tracker) => {
2974 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
2975 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
2976 return (
2977 StatusCode::SERVICE_UNAVAILABLE,
2978 [("Retry-After", "2")],
2979 Json(serde_json::to_value(&payload).unwrap_or_default()),
2980 )
2981 .into_response();
2982 }
2983 }
2984 Err(e) => {
2985 tracing::warn!("consolidate fanout error (local committed): {e:?}");
2986 }
2987 }
2988 }
2989 (
2990 StatusCode::CREATED,
2991 Json(json!({"id": new_id, "consolidated": body.ids.len()})),
2992 )
2993 .into_response()
2994 }
2995 Err(e) => {
2996 tracing::error!("handler error: {e}");
2997 (
2998 StatusCode::INTERNAL_SERVER_ERROR,
2999 Json(json!({"error": "internal server error"})),
3000 )
3001 .into_response()
3002 }
3003 }
3004}
3005
3006pub async fn bulk_create(
3007 State(app): State<AppState>,
3008 Json(bodies): Json<Vec<CreateMemory>>,
3009) -> impl IntoResponse {
3010 if bodies.len() > MAX_BULK_SIZE {
3011 return (
3012 StatusCode::BAD_REQUEST,
3013 Json(json!({"error": format!("bulk operations limited to {} items", MAX_BULK_SIZE)})),
3014 )
3015 .into_response();
3016 }
3017 let now = Utc::now();
3018 let mut created_mems: Vec<Memory> = Vec::new();
3023 let mut errors: Vec<String> = Vec::new();
3024 {
3025 let lock = app.db.lock().await;
3026 for body in bodies {
3027 if let Err(e) = validate::validate_create(&body) {
3028 errors.push(format!("{}: {}", body.title, e));
3029 continue;
3030 }
3031 let expires_at = body.expires_at.or_else(|| {
3032 body.ttl_secs
3033 .or(lock.2.ttl_for_tier(&body.tier))
3034 .map(|s| (now + Duration::seconds(s)).to_rfc3339())
3035 });
3036 let mem = Memory {
3037 id: Uuid::new_v4().to_string(),
3038 tier: body.tier,
3039 namespace: body.namespace,
3040 title: body.title,
3041 content: body.content,
3042 tags: body.tags,
3043 priority: body.priority.clamp(1, 10),
3044 confidence: body.confidence.clamp(0.0, 1.0),
3045 source: body.source,
3046 access_count: 0,
3047 created_at: now.to_rfc3339(),
3048 updated_at: now.to_rfc3339(),
3049 last_accessed_at: None,
3050 expires_at,
3051 metadata: body.metadata,
3052 };
3053 match db::insert(&lock.0, &mem) {
3054 Ok(_) => created_mems.push(mem),
3055 Err(e) => errors.push(e.to_string()),
3056 }
3057 }
3058 }
3059 if let Some(fed) = app.federation.as_ref() {
3097 let sem = Arc::new(tokio::sync::Semaphore::new(BULK_FANOUT_CONCURRENCY));
3098 let mut joins: tokio::task::JoinSet<(String, Result<(), String>)> =
3099 tokio::task::JoinSet::new();
3100 for mem in &created_mems {
3101 let fed = fed.clone();
3102 let mem = mem.clone();
3103 let sem = sem.clone();
3104 joins.spawn(async move {
3105 let Ok(_permit) = sem.acquire_owned().await else {
3111 return (mem.id.clone(), Err("fanout semaphore closed".to_string()));
3112 };
3113 let id = mem.id.clone();
3114 let outcome = match crate::federation::broadcast_store_quorum(&fed, &mem).await {
3115 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
3116 Ok(_) => Ok(()),
3117 Err(err) => Err(err.to_string()),
3118 },
3119 Err(e) => {
3120 tracing::warn!(
3121 "bulk_create: fanout for {id} failed (local committed): {e:?}"
3122 );
3123 Ok(())
3124 }
3125 };
3126 (id, outcome)
3127 });
3128 }
3129 while let Some(res) = joins.join_next().await {
3130 match res {
3131 Ok((id, Err(err))) => errors.push(format!("{id}: {err}")),
3132 Ok((_, Ok(()))) => {}
3133 Err(e) => tracing::warn!("bulk_create: fanout task join error: {e:?}"),
3134 }
3135 }
3136
3137 if !created_mems.is_empty() {
3152 let catchup_errors = crate::federation::bulk_catchup_push(fed, &created_mems).await;
3153 for (peer_id, err) in catchup_errors {
3154 errors.push(format!("catchup to {peer_id}: {err}"));
3155 }
3156 }
3157 }
3158 Json(json!({"created": created_mems.len(), "errors": errors})).into_response()
3159}
3160
3161#[derive(Debug, Deserialize)]
3166pub struct ArchiveListQuery {
3167 pub namespace: Option<String>,
3168 #[serde(default = "default_archive_limit")]
3169 pub limit: Option<usize>,
3170 #[serde(default)]
3171 pub offset: Option<usize>,
3172}
3173
3174#[allow(clippy::unnecessary_wraps)]
3175fn default_archive_limit() -> Option<usize> {
3176 Some(50)
3177}
3178
3179pub async fn list_archive(
3180 State(state): State<Db>,
3181 Query(q): Query<ArchiveListQuery>,
3182) -> impl IntoResponse {
3183 if matches!(q.limit, Some(0)) {
3188 return (
3189 StatusCode::BAD_REQUEST,
3190 Json(json!({"error": "limit must be >= 1"})),
3191 )
3192 .into_response();
3193 }
3194 let lock = state.lock().await;
3195 let limit = q.limit.unwrap_or(50).clamp(1, 1000);
3196 let offset = q.offset.unwrap_or(0);
3197 match db::list_archived(&lock.0, q.namespace.as_deref(), limit, offset) {
3198 Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
3199 Err(e) => {
3200 tracing::error!("handler error: {e}");
3201 (
3202 StatusCode::INTERNAL_SERVER_ERROR,
3203 Json(json!({"error": "internal server error"})),
3204 )
3205 .into_response()
3206 }
3207 }
3208}
3209
3210pub async fn restore_archive(
3211 State(app): State<AppState>,
3212 Path(id): Path<String>,
3213) -> impl IntoResponse {
3214 if let Err(e) = validate::validate_id(&id) {
3215 return (
3216 StatusCode::BAD_REQUEST,
3217 Json(json!({"error": e.to_string()})),
3218 )
3219 .into_response();
3220 }
3221 let restored = {
3222 let lock = app.db.lock().await;
3223 match db::restore_archived(&lock.0, &id) {
3224 Ok(v) => v,
3225 Err(e) => {
3226 tracing::error!("handler error: {e}");
3227 return (
3228 StatusCode::INTERNAL_SERVER_ERROR,
3229 Json(json!({"error": "internal server error"})),
3230 )
3231 .into_response();
3232 }
3233 }
3234 };
3235 if !restored {
3236 return (
3237 StatusCode::NOT_FOUND,
3238 Json(json!({"error": "not found in archive"})),
3239 )
3240 .into_response();
3241 }
3242
3243 if let Some(fed) = app.federation.as_ref() {
3251 match crate::federation::broadcast_restore_quorum(fed, &id).await {
3252 Ok(tracker) => {
3253 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3254 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3255 return (
3256 StatusCode::SERVICE_UNAVAILABLE,
3257 [("Retry-After", "2")],
3258 Json(serde_json::to_value(&payload).unwrap_or_default()),
3259 )
3260 .into_response();
3261 }
3262 }
3263 Err(e) => {
3264 tracing::warn!("restore fanout error (local committed): {e:?}");
3267 }
3268 }
3269 }
3270
3271 Json(json!({"restored": true, "id": id})).into_response()
3272}
3273
3274#[derive(Debug, Deserialize)]
3275pub struct PurgeQuery {
3276 pub older_than_days: Option<i64>,
3277}
3278
3279pub async fn purge_archive(
3280 State(state): State<Db>,
3281 Query(q): Query<PurgeQuery>,
3282) -> impl IntoResponse {
3283 let lock = state.lock().await;
3284 match db::purge_archive(&lock.0, q.older_than_days) {
3285 Ok(n) => Json(json!({"purged": n})).into_response(),
3286 Err(e) => {
3287 tracing::error!("handler error: {e}");
3288 (
3289 StatusCode::INTERNAL_SERVER_ERROR,
3290 Json(json!({"error": "internal server error"})),
3291 )
3292 .into_response()
3293 }
3294 }
3295}
3296
3297pub async fn archive_stats(State(state): State<Db>) -> impl IntoResponse {
3298 let lock = state.lock().await;
3299 match db::archive_stats(&lock.0) {
3300 Ok(archive_stats) => Json(archive_stats).into_response(),
3301 Err(e) => {
3302 tracing::error!("handler error: {e}");
3303 (
3304 StatusCode::INTERNAL_SERVER_ERROR,
3305 Json(json!({"error": "internal server error"})),
3306 )
3307 .into_response()
3308 }
3309 }
3310}
3311
3312#[derive(Debug, Deserialize)]
3314pub struct ArchiveByIdsBody {
3315 pub ids: Vec<String>,
3316 #[serde(default)]
3317 pub reason: Option<String>,
3318}
3319
3320pub async fn archive_by_ids(
3339 State(app): State<AppState>,
3340 Json(body): Json<ArchiveByIdsBody>,
3341) -> impl IntoResponse {
3342 if body.ids.len() > MAX_BULK_SIZE {
3344 return (
3345 StatusCode::BAD_REQUEST,
3346 Json(json!({"error": format!("archive limited to {} ids per request", MAX_BULK_SIZE)})),
3347 )
3348 .into_response();
3349 }
3350 for id in &body.ids {
3352 if let Err(e) = validate::validate_id(id) {
3353 return (
3354 StatusCode::BAD_REQUEST,
3355 Json(json!({"error": format!("invalid id {id}: {e}")})),
3356 )
3357 .into_response();
3358 }
3359 }
3360 let reason = body.reason.as_deref().unwrap_or("archive").to_string();
3361 let mut archived: Vec<String> = Vec::new();
3362 let mut missing: Vec<String> = Vec::new();
3363
3364 for id in &body.ids {
3365 let moved = {
3368 let lock = app.db.lock().await;
3369 match db::archive_memory(&lock.0, id, Some(&reason)) {
3370 Ok(v) => v,
3371 Err(e) => {
3372 tracing::error!("archive_by_ids: archive_memory({id}) failed: {e}");
3373 return (
3374 StatusCode::INTERNAL_SERVER_ERROR,
3375 Json(json!({"error": "internal server error"})),
3376 )
3377 .into_response();
3378 }
3379 }
3380 };
3381 if !moved {
3382 missing.push(id.clone());
3387 continue;
3388 }
3389
3390 if let Some(fed) = app.federation.as_ref() {
3394 match crate::federation::broadcast_archive_quorum(fed, id).await {
3395 Ok(tracker) => {
3396 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
3397 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
3398 return (
3399 StatusCode::SERVICE_UNAVAILABLE,
3400 [("Retry-After", "2")],
3401 Json(serde_json::to_value(&payload).unwrap_or_default()),
3402 )
3403 .into_response();
3404 }
3405 }
3406 Err(e) => {
3407 tracing::warn!("archive fanout error (local committed): {e:?}");
3410 }
3411 }
3412 }
3413 archived.push(id.clone());
3414 }
3415
3416 (
3417 StatusCode::OK,
3418 Json(json!({
3419 "archived": archived,
3420 "missing": missing,
3421 "count": archived.len(),
3422 "reason": reason,
3423 })),
3424 )
3425 .into_response()
3426}
3427
3428#[derive(Deserialize)]
3438pub struct SyncPushBody {
3439 pub sender_agent_id: String,
3443 #[serde(default)]
3447 #[allow(dead_code)] pub sender_clock: crate::models::VectorClock,
3449 pub memories: Vec<Memory>,
3452 #[serde(default)]
3459 pub deletions: Vec<String>,
3460 #[serde(default)]
3465 pub archives: Vec<String>,
3466 #[serde(default)]
3472 pub restores: Vec<String>,
3473 #[serde(default)]
3478 pub links: Vec<MemoryLink>,
3479 #[serde(default)]
3485 pub pendings: Vec<crate::models::PendingAction>,
3486 #[serde(default)]
3490 pub pending_decisions: Vec<crate::models::PendingDecision>,
3491 #[serde(default)]
3497 pub namespace_meta: Vec<crate::models::NamespaceMetaEntry>,
3498 #[serde(default)]
3504 pub namespace_meta_clears: Vec<String>,
3505 #[serde(default)]
3507 pub dry_run: bool,
3508}
3509
3510#[derive(Deserialize)]
3511pub struct SyncSinceQuery {
3512 pub since: Option<String>,
3514 pub limit: Option<usize>,
3516 pub peer: Option<String>,
3519}
3520
3521#[allow(clippy::too_many_lines)]
3522pub async fn sync_push(
3523 State(app): State<AppState>,
3524 headers: HeaderMap,
3525 Json(body): Json<SyncPushBody>,
3526) -> impl IntoResponse {
3527 let state = app.db.clone();
3528 if let Err(e) = validate::validate_agent_id(&body.sender_agent_id) {
3529 return (
3530 StatusCode::BAD_REQUEST,
3531 Json(json!({"error": format!("invalid sender_agent_id: {e}")})),
3532 )
3533 .into_response();
3534 }
3535 if body.memories.len() > MAX_BULK_SIZE {
3539 return (
3540 StatusCode::BAD_REQUEST,
3541 Json(json!({
3542 "error": format!("sync_push limited to {} memories per request", MAX_BULK_SIZE)
3543 })),
3544 )
3545 .into_response();
3546 }
3547 if body.deletions.len() > MAX_BULK_SIZE {
3548 return (
3549 StatusCode::BAD_REQUEST,
3550 Json(json!({
3551 "error": format!("sync_push limited to {} deletions per request", MAX_BULK_SIZE)
3552 })),
3553 )
3554 .into_response();
3555 }
3556 if body.archives.len() > MAX_BULK_SIZE {
3557 return (
3558 StatusCode::BAD_REQUEST,
3559 Json(json!({
3560 "error": format!("sync_push limited to {} archives per request", MAX_BULK_SIZE)
3561 })),
3562 )
3563 .into_response();
3564 }
3565 if body.restores.len() > MAX_BULK_SIZE {
3566 return (
3567 StatusCode::BAD_REQUEST,
3568 Json(json!({
3569 "error": format!("sync_push limited to {} restores per request", MAX_BULK_SIZE)
3570 })),
3571 )
3572 .into_response();
3573 }
3574 if body.pendings.len() > MAX_BULK_SIZE {
3575 return (
3576 StatusCode::BAD_REQUEST,
3577 Json(json!({
3578 "error": format!("sync_push limited to {} pendings per request", MAX_BULK_SIZE)
3579 })),
3580 )
3581 .into_response();
3582 }
3583 if body.pending_decisions.len() > MAX_BULK_SIZE {
3584 return (
3585 StatusCode::BAD_REQUEST,
3586 Json(json!({
3587 "error": format!(
3588 "sync_push limited to {} pending_decisions per request",
3589 MAX_BULK_SIZE
3590 )
3591 })),
3592 )
3593 .into_response();
3594 }
3595 if body.namespace_meta.len() > MAX_BULK_SIZE {
3596 return (
3597 StatusCode::BAD_REQUEST,
3598 Json(json!({
3599 "error": format!(
3600 "sync_push limited to {} namespace_meta per request",
3601 MAX_BULK_SIZE
3602 )
3603 })),
3604 )
3605 .into_response();
3606 }
3607 if body.namespace_meta_clears.len() > MAX_BULK_SIZE {
3608 return (
3609 StatusCode::BAD_REQUEST,
3610 Json(json!({
3611 "error": format!(
3612 "sync_push limited to {} namespace_meta_clears per request",
3613 MAX_BULK_SIZE
3614 )
3615 })),
3616 )
3617 .into_response();
3618 }
3619 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
3622 let local_agent_id = match crate::identity::resolve_http_agent_id(None, header_agent_id) {
3623 Ok(id) => id,
3624 Err(e) => {
3625 return (
3626 StatusCode::BAD_REQUEST,
3627 Json(json!({"error": format!("invalid x-agent-id: {e}")})),
3628 )
3629 .into_response();
3630 }
3631 };
3632
3633 let lock = state.lock().await;
3634 let mut applied = 0usize;
3635 let mut noop = 0usize;
3636 let mut skipped = 0usize;
3637 let mut deleted = 0usize;
3638 let mut archived = 0usize;
3639 let mut restored = 0usize;
3640 let mut latest_seen: Option<String> = None;
3641
3642 let mut embedding_refresh: Vec<(String, String)> = Vec::new();
3652 for mem in &body.memories {
3653 if let Err(e) = validate::validate_memory(mem) {
3654 tracing::warn!("sync_push: skipping memory {} ({}): {e}", mem.id, mem.title);
3655 skipped += 1;
3656 continue;
3657 }
3658 if latest_seen
3659 .as_deref()
3660 .is_none_or(|current| mem.updated_at.as_str() > current)
3661 {
3662 latest_seen = Some(mem.updated_at.clone());
3663 }
3664 if body.dry_run {
3665 noop += 1;
3666 continue;
3667 }
3668 match db::insert_if_newer(&lock.0, mem) {
3669 Ok(actual_id) => {
3670 applied += 1;
3671 embedding_refresh.push((actual_id, format!("{} {}", mem.title, mem.content)));
3672 }
3673 Err(e) => {
3674 tracing::warn!("sync_push: insert_if_newer failed for {}: {e}", mem.id);
3675 skipped += 1;
3676 }
3677 }
3678 }
3679
3680 for del_id in &body.deletions {
3684 if validate::validate_id(del_id).is_err() {
3685 skipped += 1;
3686 continue;
3687 }
3688 if body.dry_run {
3689 noop += 1;
3690 continue;
3691 }
3692 match db::delete(&lock.0, del_id) {
3693 Ok(true) => deleted += 1,
3694 Ok(false) => noop += 1,
3695 Err(e) => {
3696 tracing::warn!("sync_push: delete failed for {del_id}: {e}");
3697 skipped += 1;
3698 }
3699 }
3700 }
3701
3702 for arch_id in &body.archives {
3707 if validate::validate_id(arch_id).is_err() {
3708 skipped += 1;
3709 continue;
3710 }
3711 if body.dry_run {
3712 noop += 1;
3713 continue;
3714 }
3715 match db::archive_memory(&lock.0, arch_id, Some("sync_push")) {
3716 Ok(true) => archived += 1,
3717 Ok(false) => noop += 1,
3718 Err(e) => {
3719 tracing::warn!("sync_push: archive_memory failed for {arch_id}: {e}");
3720 skipped += 1;
3721 }
3722 }
3723 }
3724
3725 for res_id in &body.restores {
3731 if validate::validate_id(res_id).is_err() {
3732 skipped += 1;
3733 continue;
3734 }
3735 if body.dry_run {
3736 noop += 1;
3737 continue;
3738 }
3739 match db::restore_archived(&lock.0, res_id) {
3740 Ok(true) => restored += 1,
3741 Ok(false) => noop += 1,
3742 Err(e) => {
3743 tracing::warn!("sync_push: restore_archived failed for {res_id}: {e}");
3744 skipped += 1;
3745 }
3746 }
3747 }
3748
3749 let mut links_applied = 0usize;
3754 for link in &body.links {
3755 if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
3756 skipped += 1;
3757 continue;
3758 }
3759 if body.dry_run {
3760 noop += 1;
3761 continue;
3762 }
3763 match db::create_link(&lock.0, &link.source_id, &link.target_id, &link.relation) {
3764 Ok(()) => links_applied += 1,
3765 Err(e) => {
3766 tracing::warn!(
3767 "sync_push: create_link failed ({} -> {} / {}): {e}",
3768 link.source_id,
3769 link.target_id,
3770 link.relation
3771 );
3772 skipped += 1;
3773 }
3774 }
3775 }
3776
3777 let mut pendings_applied = 0usize;
3781 for pa in &body.pendings {
3782 if validate::validate_id(&pa.id).is_err() {
3783 skipped += 1;
3784 continue;
3785 }
3786 if body.dry_run {
3787 noop += 1;
3788 continue;
3789 }
3790 match db::upsert_pending_action(&lock.0, pa) {
3791 Ok(()) => pendings_applied += 1,
3792 Err(e) => {
3793 tracing::warn!("sync_push: upsert_pending_action failed for {}: {e}", pa.id);
3794 skipped += 1;
3795 }
3796 }
3797 }
3798
3799 let mut pending_decisions_applied = 0usize;
3804 for dec in &body.pending_decisions {
3805 if validate::validate_id(&dec.id).is_err() {
3806 skipped += 1;
3807 continue;
3808 }
3809 if body.dry_run {
3810 noop += 1;
3811 continue;
3812 }
3813 match db::decide_pending_action(&lock.0, &dec.id, dec.approved, &dec.decider) {
3814 Ok(true) => {
3815 pending_decisions_applied += 1;
3816 if dec.approved {
3820 match db::execute_pending_action(&lock.0, &dec.id) {
3821 Ok(_) => {}
3822 Err(e) => {
3823 tracing::warn!(
3824 "sync_push: execute_pending_action failed for {}: {e}",
3825 dec.id
3826 );
3827 }
3828 }
3829 }
3830 }
3831 Ok(false) => noop += 1, Err(e) => {
3833 tracing::warn!(
3834 "sync_push: decide_pending_action failed for {}: {e}",
3835 dec.id
3836 );
3837 skipped += 1;
3838 }
3839 }
3840 }
3841
3842 let mut namespace_meta_applied = 0usize;
3848 for entry in &body.namespace_meta {
3849 if validate::validate_namespace(&entry.namespace).is_err()
3850 || validate::validate_id(&entry.standard_id).is_err()
3851 {
3852 skipped += 1;
3853 continue;
3854 }
3855 if body.dry_run {
3856 noop += 1;
3857 continue;
3858 }
3859 match db::set_namespace_standard(
3860 &lock.0,
3861 &entry.namespace,
3862 &entry.standard_id,
3863 entry.parent_namespace.as_deref(),
3864 ) {
3865 Ok(()) => namespace_meta_applied += 1,
3866 Err(e) => {
3867 tracing::warn!(
3868 "sync_push: set_namespace_standard failed for {}: {e}",
3869 entry.namespace
3870 );
3871 skipped += 1;
3872 }
3873 }
3874 }
3875
3876 let mut namespace_meta_cleared = 0usize;
3881 for ns in &body.namespace_meta_clears {
3882 if validate::validate_namespace(ns).is_err() {
3883 skipped += 1;
3884 continue;
3885 }
3886 if body.dry_run {
3887 noop += 1;
3888 continue;
3889 }
3890 match db::clear_namespace_standard(&lock.0, ns) {
3891 Ok(true) => namespace_meta_cleared += 1,
3892 Ok(false) => noop += 1,
3893 Err(e) => {
3894 tracing::warn!("sync_push: clear_namespace_standard failed for {ns}: {e}");
3895 skipped += 1;
3896 }
3897 }
3898 }
3899
3900 if !body.dry_run
3903 && let Some(at) = latest_seen.as_deref()
3904 && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, &body.sender_agent_id, at)
3905 {
3906 tracing::warn!("sync_push: sync_state_observe failed: {e}");
3907 }
3908
3909 let mut hnsw_updates: Vec<(String, Vec<f32>)> = Vec::new();
3917 if !body.dry_run
3918 && !embedding_refresh.is_empty()
3919 && let Some(emb) = app.embedder.as_ref().as_ref()
3920 {
3921 for (id, text) in &embedding_refresh {
3922 match emb.embed(text) {
3923 Ok(vec) => {
3924 if let Err(e) = db::set_embedding(&lock.0, id, &vec) {
3925 tracing::warn!("sync_push: set_embedding failed for {id}: {e}");
3926 continue;
3927 }
3928 hnsw_updates.push((id.clone(), vec));
3929 }
3930 Err(e) => {
3931 tracing::warn!("sync_push: embed failed for {id}: {e}");
3932 }
3933 }
3934 }
3935 }
3936
3937 let receiver_clock = db::sync_state_load(&lock.0, &local_agent_id)
3941 .unwrap_or_else(|_| crate::models::VectorClock::default());
3942
3943 drop(lock);
3946 if !hnsw_updates.is_empty() {
3947 let mut idx_lock = app.vector_index.lock().await;
3948 if let Some(idx) = idx_lock.as_mut() {
3949 for (id, vec) in hnsw_updates {
3950 idx.remove(&id);
3951 idx.insert(id, vec);
3952 }
3953 }
3954 }
3955
3956 (
3957 StatusCode::OK,
3958 Json(json!({
3959 "applied": applied,
3960 "deleted": deleted,
3961 "archived": archived,
3962 "restored": restored,
3963 "links_applied": links_applied,
3964 "pendings_applied": pendings_applied,
3965 "pending_decisions_applied": pending_decisions_applied,
3966 "namespace_meta_applied": namespace_meta_applied,
3967 "namespace_meta_cleared": namespace_meta_cleared,
3968 "noop": noop,
3969 "skipped": skipped,
3970 "dry_run": body.dry_run,
3971 "receiver_agent_id": local_agent_id,
3972 "receiver_clock": receiver_clock,
3973 })),
3974 )
3975 .into_response()
3976}
3977
3978pub async fn sync_since(
3979 State(state): State<Db>,
3980 headers: HeaderMap,
3981 Query(q): Query<SyncSinceQuery>,
3982) -> impl IntoResponse {
3983 if let Some(ref s) = q.since
3987 && !s.is_empty()
3988 && chrono::DateTime::parse_from_rfc3339(s).is_err()
3989 {
3990 return (
3991 StatusCode::BAD_REQUEST,
3992 Json(json!({
3993 "error": "invalid `since` parameter — expected RFC 3339 timestamp"
3994 })),
3995 )
3996 .into_response();
3997 }
3998 let limit = q.limit.unwrap_or(500).min(10_000);
3999 let lock = state.lock().await;
4000 let mems = match db::memories_updated_since(&lock.0, q.since.as_deref(), limit) {
4001 Ok(v) => v,
4002 Err(e) => {
4003 tracing::error!("sync_since: {e}");
4004 return (
4005 StatusCode::INTERNAL_SERVER_ERROR,
4006 Json(json!({"error": "internal server error"})),
4007 )
4008 .into_response();
4009 }
4010 };
4011
4012 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4016 if let (Some(peer), Ok(local_agent_id)) = (
4017 q.peer.as_deref(),
4018 crate::identity::resolve_http_agent_id(None, header_agent_id),
4019 ) && validate::validate_agent_id(peer).is_ok()
4020 && let Some(last) = mems.last()
4021 && let Err(e) = db::sync_state_observe(&lock.0, &local_agent_id, peer, &last.updated_at)
4022 {
4023 tracing::debug!("sync_since: sync_state_observe failed: {e}");
4024 }
4025
4026 let earliest_updated_at = mems.first().map(|m| m.updated_at.clone());
4038 let latest_updated_at = mems.last().map(|m| m.updated_at.clone());
4039
4040 (
4041 StatusCode::OK,
4042 Json(json!({
4043 "count": mems.len(),
4044 "limit": limit,
4045 "updated_since": q.since,
4046 "earliest_updated_at": earliest_updated_at,
4047 "latest_updated_at": latest_updated_at,
4048 "memories": mems,
4049 })),
4050 )
4051 .into_response()
4052}
4053
4054async fn fanout_or_503(app: &AppState, mem: &Memory) -> Option<axum::response::Response> {
4063 let fed = app.federation.as_ref().as_ref()?;
4064 match crate::federation::broadcast_store_quorum(fed, mem).await {
4065 Ok(tracker) => match crate::federation::finalise_quorum(&tracker) {
4066 Ok(_) => None,
4067 Err(err) => {
4068 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4069 Some(
4070 (
4071 StatusCode::SERVICE_UNAVAILABLE,
4072 [("Retry-After", "2")],
4073 Json(serde_json::to_value(&payload).unwrap_or_default()),
4074 )
4075 .into_response(),
4076 )
4077 }
4078 },
4079 Err(e) => {
4080 tracing::warn!("fanout error (local committed): {e:?}");
4081 None
4082 }
4083 }
4084}
4085
4086fn resolve_caller_agent_id(
4103 body: Option<&str>,
4104 headers: &HeaderMap,
4105 query: Option<&str>,
4106) -> Result<String, String> {
4107 if let Some(id) = body
4112 && !id.is_empty()
4113 {
4114 validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
4115 return Ok(id.to_string());
4116 }
4117 if let Some(id) = query
4118 && !id.is_empty()
4119 {
4120 validate::validate_agent_id(id).map_err(|e| format!("invalid agent_id: {e}"))?;
4121 return Ok(id.to_string());
4122 }
4123 let header_val = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4124 crate::identity::resolve_http_agent_id(None, header_val)
4125 .map_err(|e| format!("invalid agent_id: {e}"))
4126}
4127
4128pub async fn get_capabilities(
4131 State(app): State<AppState>,
4132 headers: HeaderMap,
4133) -> impl IntoResponse {
4134 let accept = headers
4156 .get("accept-capabilities")
4157 .and_then(|v| v.to_str().ok())
4158 .map_or(crate::mcp::CapabilitiesAccept::V2, |raw| {
4159 crate::mcp::CapabilitiesAccept::parse(raw)
4160 });
4161 let embedder_loaded = app.embedder.as_ref().is_some();
4162 let lock = app.db.lock().await;
4163 let conn = &lock.0;
4164 let result = crate::mcp::handle_capabilities_with_conn(
4165 app.tier_config.as_ref(),
4166 None,
4167 embedder_loaded,
4168 Some(conn),
4169 accept,
4170 );
4171 drop(lock);
4172 match result {
4173 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4174 Err(e) => {
4175 tracing::error!("capabilities: {e}");
4176 (
4177 StatusCode::INTERNAL_SERVER_ERROR,
4178 Json(json!({"error": "internal server error"})),
4179 )
4180 .into_response()
4181 }
4182 }
4183}
4184
4185#[derive(Deserialize)]
4188pub struct NotifyBody {
4189 pub target_agent_id: String,
4190 pub title: String,
4191 #[serde(default)]
4193 pub payload: Option<String>,
4194 #[serde(default)]
4195 pub content: Option<String>,
4196 #[serde(default)]
4197 pub priority: Option<i64>,
4198 #[serde(default)]
4199 pub tier: Option<String>,
4200 #[serde(default)]
4202 pub agent_id: Option<String>,
4203}
4204
4205pub async fn notify(
4206 State(app): State<AppState>,
4207 headers: HeaderMap,
4208 Json(body): Json<NotifyBody>,
4209) -> impl IntoResponse {
4210 let Some(payload) = body.payload.or(body.content) else {
4211 return (
4212 StatusCode::BAD_REQUEST,
4213 Json(json!({"error": "payload or content is required"})),
4214 )
4215 .into_response();
4216 };
4217 let sender = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
4218 Ok(id) => id,
4219 Err(e) => {
4220 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4221 }
4222 };
4223
4224 let mut params = json!({
4225 "target_agent_id": body.target_agent_id,
4226 "title": body.title,
4227 "payload": payload,
4228 });
4229 if let Some(p) = body.priority {
4230 params["priority"] = json!(p);
4231 }
4232 if let Some(t) = body.tier {
4233 params["tier"] = json!(t);
4234 }
4235
4236 let lock = app.db.lock().await;
4237 let resolved_ttl = lock.2.clone();
4238 let mcp_client = sender.clone();
4242 let result = crate::mcp::handle_notify(&lock.0, ¶ms, &resolved_ttl, Some(&mcp_client));
4243
4244 let fanout_mem = match &result {
4252 Ok(v) => v
4253 .get("id")
4254 .and_then(|x| x.as_str())
4255 .and_then(|id| db::get(&lock.0, id).ok().flatten()),
4256 Err(_) => None,
4257 };
4258 drop(lock);
4259
4260 match result {
4261 Ok(v) => {
4262 if let Some(mem) = fanout_mem
4263 && let Some(resp) = fanout_or_503(&app, &mem).await
4264 {
4265 return resp;
4266 }
4267 (StatusCode::CREATED, Json(v)).into_response()
4268 }
4269 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4270 }
4271}
4272
4273#[derive(Deserialize)]
4274pub struct InboxQuery {
4275 #[serde(default)]
4276 pub agent_id: Option<String>,
4277 #[serde(default)]
4278 pub unread_only: Option<bool>,
4279 #[serde(default)]
4280 pub limit: Option<u64>,
4281}
4282
4283pub async fn get_inbox(
4284 State(app): State<AppState>,
4285 headers: HeaderMap,
4286 Query(q): Query<InboxQuery>,
4287) -> impl IntoResponse {
4288 let owner = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4289 Ok(id) => id,
4290 Err(e) => {
4291 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4292 }
4293 };
4294
4295 let mut params = json!({"agent_id": owner});
4296 if let Some(u) = q.unread_only {
4297 params["unread_only"] = json!(u);
4298 }
4299 if let Some(l) = q.limit {
4300 params["limit"] = json!(l);
4301 }
4302 let lock = app.db.lock().await;
4303 let result = crate::mcp::handle_inbox(&lock.0, ¶ms, None);
4307 drop(lock);
4308 match result {
4309 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4310 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4311 }
4312}
4313
4314#[derive(Deserialize)]
4326pub struct SubscribeBody {
4327 #[serde(default)]
4330 pub url: Option<String>,
4331 #[serde(default)]
4332 pub events: Option<String>,
4333 #[serde(default)]
4334 pub secret: Option<String>,
4335 #[serde(default)]
4336 pub namespace_filter: Option<String>,
4337 #[serde(default)]
4338 pub agent_filter: Option<String>,
4339 #[serde(default)]
4341 pub namespace: Option<String>,
4342 #[serde(default)]
4344 pub agent_id: Option<String>,
4345}
4346
4347pub async fn subscribe(
4348 State(app): State<AppState>,
4349 headers: HeaderMap,
4350 Json(body): Json<SubscribeBody>,
4351) -> impl IntoResponse {
4352 let caller = match resolve_caller_agent_id(body.agent_id.as_deref(), &headers, None) {
4353 Ok(id) => id,
4354 Err(e) => {
4355 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4356 }
4357 };
4358
4359 let (url, namespace_filter, agent_filter) = if let Some(u) = body.url {
4361 (u, body.namespace_filter, body.agent_filter)
4362 } else {
4363 let Some(ns) = body.namespace.clone() else {
4364 return (
4365 StatusCode::BAD_REQUEST,
4366 Json(json!({"error": "url or namespace is required"})),
4367 )
4368 .into_response();
4369 };
4370 let synthetic = format!("http://localhost/_ns/{caller}/{ns}");
4374 (
4375 synthetic,
4376 Some(ns),
4377 body.agent_filter.or_else(|| Some(caller.clone())),
4378 )
4379 };
4380
4381 let events = body.events.unwrap_or_else(|| "*".to_string());
4382
4383 let lock = app.db.lock().await;
4388 let already = db::list_agents(&lock.0)
4389 .ok()
4390 .is_some_and(|a| a.iter().any(|x| x.agent_id == caller));
4391 if !already {
4392 let _ = db::register_agent(&lock.0, &caller, "ai:generic", &[]);
4393 }
4394 let sub_result: Result<serde_json::Value, String> = (|| {
4401 crate::subscriptions::validate_url(&url).map_err(|e| e.to_string())?;
4402 let id = crate::subscriptions::insert(
4403 &lock.0,
4404 &crate::subscriptions::NewSubscription {
4405 url: &url,
4406 events: &events,
4407 secret: body.secret.as_deref(),
4408 namespace_filter: namespace_filter.as_deref(),
4409 agent_filter: agent_filter.as_deref(),
4410 created_by: Some(&caller),
4411 event_types: None,
4412 },
4413 )
4414 .map_err(|e| e.to_string())?;
4415 Ok(json!({
4416 "id": id,
4417 "url": url,
4418 "events": events,
4419 "namespace_filter": namespace_filter,
4420 "agent_filter": agent_filter,
4421 "created_by": caller,
4422 }))
4423 })();
4424 let registered_mem = if already {
4428 None
4429 } else {
4430 db::list(
4431 &lock.0,
4432 Some("_agents"),
4433 None,
4434 1000,
4435 0,
4436 None,
4437 None,
4438 None,
4439 None,
4440 None,
4441 )
4442 .ok()
4443 .and_then(|rows| {
4444 rows.into_iter()
4445 .find(|m| m.title == format!("agent:{caller}"))
4446 })
4447 };
4448 drop(lock);
4449
4450 if let Some(ref mem) = registered_mem
4451 && let Some(resp) = fanout_or_503(&app, mem).await
4452 {
4453 return resp;
4454 }
4455
4456 match sub_result {
4457 Ok(mut v) => {
4458 if let Some(obj) = v.as_object_mut() {
4462 if let Some(ref ns) = namespace_filter {
4463 obj.insert("namespace".into(), json!(ns));
4464 }
4465 obj.insert("agent_id".into(), json!(caller));
4466 }
4467 (StatusCode::CREATED, Json(v)).into_response()
4468 }
4469 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4470 }
4471}
4472
4473#[derive(Deserialize)]
4474pub struct UnsubscribeQuery {
4475 #[serde(default)]
4476 pub id: Option<String>,
4477 #[serde(default)]
4479 pub agent_id: Option<String>,
4480 #[serde(default)]
4481 pub namespace: Option<String>,
4482}
4483
4484pub async fn unsubscribe(
4485 State(app): State<AppState>,
4486 headers: HeaderMap,
4487 Query(q): Query<UnsubscribeQuery>,
4488) -> impl IntoResponse {
4489 if let Some(id) = q.id.clone() {
4492 let mut params = json!({"id": id});
4493 let _ = params.as_object_mut();
4495 let lock = app.db.lock().await;
4496 let result = crate::mcp::handle_unsubscribe(&lock.0, ¶ms);
4497 drop(lock);
4498 return match result {
4499 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4500 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4501 };
4502 }
4503
4504 let caller = match resolve_caller_agent_id(None, &headers, q.agent_id.as_deref()) {
4505 Ok(id) => id,
4506 Err(e) => {
4507 return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response();
4508 }
4509 };
4510 let Some(ns) = q.namespace else {
4511 return (
4512 StatusCode::BAD_REQUEST,
4513 Json(json!({"error": "id or (agent_id, namespace) required"})),
4514 )
4515 .into_response();
4516 };
4517
4518 let lock = app.db.lock().await;
4519 let subs = crate::subscriptions::list(&lock.0).unwrap_or_default();
4520 let target = subs.into_iter().find(|s| {
4521 s.namespace_filter.as_deref() == Some(ns.as_str())
4522 && (s.agent_filter.as_deref() == Some(caller.as_str())
4523 || s.created_by.as_deref() == Some(caller.as_str()))
4524 });
4525 let outcome = match target {
4526 Some(s) => crate::subscriptions::delete(&lock.0, &s.id).map(|r| (s.id, r)),
4527 None => Ok((String::new(), false)),
4528 };
4529 drop(lock);
4530 match outcome {
4531 Ok((id, removed)) => {
4532 (StatusCode::OK, Json(json!({"id": id, "removed": removed}))).into_response()
4533 }
4534 Err(e) => {
4535 tracing::error!("unsubscribe: {e}");
4536 (
4537 StatusCode::INTERNAL_SERVER_ERROR,
4538 Json(json!({"error": "internal server error"})),
4539 )
4540 .into_response()
4541 }
4542 }
4543}
4544
4545#[derive(Deserialize)]
4546pub struct ListSubscriptionsQuery {
4547 #[serde(default)]
4548 pub agent_id: Option<String>,
4549}
4550
4551pub async fn list_subscriptions(
4552 State(state): State<Db>,
4553 Query(q): Query<ListSubscriptionsQuery>,
4554) -> impl IntoResponse {
4555 let lock = state.lock().await;
4556 let subs = match crate::subscriptions::list(&lock.0) {
4557 Ok(s) => s,
4558 Err(e) => {
4559 tracing::error!("list_subscriptions: {e}");
4560 return (
4561 StatusCode::INTERNAL_SERVER_ERROR,
4562 Json(json!({"error": "internal server error"})),
4563 )
4564 .into_response();
4565 }
4566 };
4567 drop(lock);
4568 let filtered: Vec<_> = match q.agent_id.as_deref() {
4570 Some(aid) => subs
4571 .into_iter()
4572 .filter(|s| {
4573 s.agent_filter.as_deref() == Some(aid) || s.created_by.as_deref() == Some(aid)
4574 })
4575 .collect(),
4576 None => subs,
4577 };
4578 let rows: Vec<serde_json::Value> = filtered
4581 .iter()
4582 .map(|s| {
4583 json!({
4584 "id": s.id,
4585 "url": s.url,
4586 "events": s.events,
4587 "namespace": s.namespace_filter,
4588 "namespace_filter": s.namespace_filter,
4589 "agent_filter": s.agent_filter,
4590 "agent_id": s.agent_filter.clone().or(s.created_by.clone()),
4591 "created_by": s.created_by,
4592 "created_at": s.created_at,
4593 "dispatch_count": s.dispatch_count,
4594 "failure_count": s.failure_count,
4595 })
4596 })
4597 .collect();
4598 let count = rows.len();
4599 (
4600 StatusCode::OK,
4601 Json(json!({"count": count, "subscriptions": rows})),
4602 )
4603 .into_response()
4604}
4605
4606#[derive(Deserialize)]
4614pub struct NamespaceStandardBody {
4615 #[serde(default)]
4617 pub id: Option<String>,
4618 #[serde(default)]
4620 pub parent: Option<String>,
4621 #[serde(default)]
4623 pub governance: Option<serde_json::Value>,
4624 #[serde(default)]
4627 pub namespace: Option<String>,
4628 #[serde(default)]
4630 pub standard: Option<Box<NamespaceStandardBody>>,
4631}
4632
4633fn flatten_standard_body(body: NamespaceStandardBody) -> NamespaceStandardBody {
4634 if let Some(inner) = body.standard {
4638 let mut merged = *inner;
4639 if merged.namespace.is_none() {
4640 merged.namespace = body.namespace;
4641 }
4642 if merged.id.is_none() {
4643 merged.id = body.id;
4644 }
4645 if merged.parent.is_none() {
4646 merged.parent = body.parent;
4647 }
4648 if merged.governance.is_none() {
4649 merged.governance = body.governance;
4650 }
4651 merged
4652 } else {
4653 body
4654 }
4655}
4656
4657fn namespace_standard_params(ns: &str, body: &NamespaceStandardBody) -> serde_json::Value {
4658 let mut params = json!({"namespace": ns});
4659 if let Some(ref id) = body.id {
4660 params["id"] = json!(id);
4661 }
4662 if let Some(ref p) = body.parent {
4663 params["parent"] = json!(p);
4664 }
4665 if let Some(ref g) = body.governance {
4666 params["governance"] = g.clone();
4667 }
4668 params
4669}
4670
4671async fn set_namespace_standard_inner(
4672 app: &AppState,
4673 ns: &str,
4674 body: NamespaceStandardBody,
4675) -> axum::response::Response {
4676 let body = flatten_standard_body(body);
4677 let lock = app.db.lock().await;
4681 let resolved_id = if let Some(id) = body.id.clone() {
4682 id
4683 } else {
4684 let existing = db::list(
4687 &lock.0,
4688 Some(ns),
4689 None,
4690 1,
4691 0,
4692 None,
4693 None,
4694 None,
4695 Some("_namespace_standard"),
4696 None,
4697 )
4698 .ok()
4699 .and_then(|v| v.into_iter().next());
4700 if let Some(m) = existing {
4701 m.id
4702 } else {
4703 let now = Utc::now().to_rfc3339();
4704 let placeholder = Memory {
4705 id: Uuid::new_v4().to_string(),
4706 tier: Tier::Long,
4707 namespace: ns.to_string(),
4708 title: format!("_standard:{ns}"),
4709 content: format!("namespace standard for {ns}"),
4710 tags: vec!["_namespace_standard".to_string()],
4711 priority: 5,
4712 confidence: 1.0,
4713 source: "api".into(),
4714 access_count: 0,
4715 created_at: now.clone(),
4716 updated_at: now,
4717 last_accessed_at: None,
4718 expires_at: None,
4719 metadata: serde_json::json!({"agent_id": "system"}),
4720 };
4721 match db::insert(&lock.0, &placeholder) {
4722 Ok(id) => id,
4723 Err(e) => {
4724 tracing::error!("namespace_standard: placeholder insert failed: {e}");
4725 return (
4726 StatusCode::INTERNAL_SERVER_ERROR,
4727 Json(json!({"error": "internal server error"})),
4728 )
4729 .into_response();
4730 }
4731 }
4732 }
4733 };
4734 let mut effective = body;
4735 effective.id = Some(resolved_id.clone());
4736 let params = namespace_standard_params(ns, &effective);
4737 let result = crate::mcp::handle_namespace_set_standard(&lock.0, ¶ms);
4738 let standard_mem = db::get(&lock.0, &resolved_id).ok().flatten();
4741 let meta_entry = db::get_namespace_meta_entry(&lock.0, ns).ok().flatten();
4746 drop(lock);
4747
4748 match result {
4749 Ok(v) => {
4750 if let Some(ref mem) = standard_mem
4751 && let Some(resp) = fanout_or_503(app, mem).await
4752 {
4753 return resp;
4754 }
4755 if let (Some(entry), Some(fed)) = (meta_entry.as_ref(), app.federation.as_ref()) {
4756 match crate::federation::broadcast_namespace_meta_quorum(fed, entry).await {
4757 Ok(tracker) => {
4758 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4759 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4760 return (
4761 StatusCode::SERVICE_UNAVAILABLE,
4762 [("Retry-After", "2")],
4763 Json(serde_json::to_value(&payload).unwrap_or_default()),
4764 )
4765 .into_response();
4766 }
4767 }
4768 Err(err) => {
4769 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4770 return (
4771 StatusCode::SERVICE_UNAVAILABLE,
4772 [("Retry-After", "2")],
4773 Json(serde_json::to_value(&payload).unwrap_or_default()),
4774 )
4775 .into_response();
4776 }
4777 }
4778 }
4779 (StatusCode::CREATED, Json(v)).into_response()
4780 }
4781 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4782 }
4783}
4784
4785pub async fn set_namespace_standard(
4786 State(app): State<AppState>,
4787 Path(ns): Path<String>,
4788 Json(body): Json<NamespaceStandardBody>,
4789) -> impl IntoResponse {
4790 set_namespace_standard_inner(&app, &ns, body).await
4791}
4792
4793#[derive(Deserialize)]
4794pub struct NamespaceStandardQuery {
4795 #[serde(default)]
4796 pub namespace: Option<String>,
4797 #[serde(default)]
4798 pub inherit: Option<bool>,
4799}
4800
4801pub async fn get_namespace_standard(
4802 State(state): State<Db>,
4803 Path(ns): Path<String>,
4804 Query(q): Query<NamespaceStandardQuery>,
4805) -> impl IntoResponse {
4806 let mut params = json!({"namespace": ns});
4807 if let Some(inh) = q.inherit {
4808 params["inherit"] = json!(inh);
4809 }
4810 let lock = state.lock().await;
4811 let result = crate::mcp::handle_namespace_get_standard(&lock.0, ¶ms);
4812 drop(lock);
4813 match result {
4814 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4815 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4816 }
4817}
4818
4819pub async fn clear_namespace_standard(
4820 State(app): State<AppState>,
4821 Path(ns): Path<String>,
4822) -> impl IntoResponse {
4823 clear_namespace_standard_inner(&app, &ns).await
4824}
4825
4826pub async fn set_namespace_standard_qs(
4828 State(app): State<AppState>,
4829 Json(body): Json<NamespaceStandardBody>,
4830) -> impl IntoResponse {
4831 let Some(ns) = body
4832 .namespace
4833 .clone()
4834 .or_else(|| body.standard.as_ref().and_then(|s| s.namespace.clone()))
4835 else {
4836 return (
4837 StatusCode::BAD_REQUEST,
4838 Json(json!({"error": "namespace is required"})),
4839 )
4840 .into_response();
4841 };
4842 set_namespace_standard_inner(&app, &ns, body).await
4843}
4844
4845pub async fn get_namespace_standard_qs(
4846 State(state): State<Db>,
4847 Query(q): Query<NamespaceStandardQuery>,
4848) -> impl IntoResponse {
4849 let Some(ns) = q.namespace.clone() else {
4853 return list_namespaces(State(state)).await.into_response();
4854 };
4855 let mut params = json!({"namespace": ns});
4856 if let Some(inh) = q.inherit {
4857 params["inherit"] = json!(inh);
4858 }
4859 let lock = state.lock().await;
4860 let result = crate::mcp::handle_namespace_get_standard(&lock.0, ¶ms);
4861 drop(lock);
4862 match result {
4863 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
4864 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4865 }
4866}
4867
4868pub async fn clear_namespace_standard_qs(
4869 State(app): State<AppState>,
4870 Query(q): Query<NamespaceStandardQuery>,
4871) -> impl IntoResponse {
4872 let Some(ns) = q.namespace else {
4873 return (
4874 StatusCode::BAD_REQUEST,
4875 Json(json!({"error": "namespace is required"})),
4876 )
4877 .into_response();
4878 };
4879 clear_namespace_standard_inner(&app, &ns).await
4880}
4881
4882async fn clear_namespace_standard_inner(app: &AppState, ns: &str) -> axum::response::Response {
4889 let params = json!({"namespace": ns});
4890 let lock = app.db.lock().await;
4891 let result = crate::mcp::handle_namespace_clear_standard(&lock.0, ¶ms);
4892 drop(lock);
4893 match result {
4894 Ok(v) => {
4895 if let Some(fed) = app.federation.as_ref() {
4896 let namespaces = vec![ns.to_string()];
4897 match crate::federation::broadcast_namespace_meta_clear_quorum(fed, &namespaces)
4898 .await
4899 {
4900 Ok(tracker) => {
4901 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
4902 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4903 return (
4904 StatusCode::SERVICE_UNAVAILABLE,
4905 [("Retry-After", "2")],
4906 Json(serde_json::to_value(&payload).unwrap_or_default()),
4907 )
4908 .into_response();
4909 }
4910 }
4911 Err(err) => {
4912 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
4913 return (
4914 StatusCode::SERVICE_UNAVAILABLE,
4915 [("Retry-After", "2")],
4916 Json(serde_json::to_value(&payload).unwrap_or_default()),
4917 )
4918 .into_response();
4919 }
4920 }
4921 }
4922 (StatusCode::OK, Json(v)).into_response()
4923 }
4924 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4925 }
4926}
4927
4928#[derive(Deserialize)]
4931pub struct SessionStartBody {
4932 #[serde(default)]
4933 pub namespace: Option<String>,
4934 #[serde(default)]
4935 pub limit: Option<u64>,
4936 #[serde(default)]
4937 pub agent_id: Option<String>,
4938}
4939
4940pub async fn session_start(
4941 State(state): State<Db>,
4942 headers: HeaderMap,
4943 Json(body): Json<SessionStartBody>,
4944) -> impl IntoResponse {
4945 if let Some(ref id) = body.agent_id
4947 && let Err(e) = validate::validate_agent_id(id)
4948 {
4949 return (
4950 StatusCode::BAD_REQUEST,
4951 Json(json!({"error": format!("invalid agent_id: {e}")})),
4952 )
4953 .into_response();
4954 }
4955 let header_agent_id = headers.get("x-agent-id").and_then(|v| v.to_str().ok());
4956 let _ = header_agent_id; let mut params = json!({});
4958 if let Some(ref n) = body.namespace {
4959 params["namespace"] = json!(n);
4960 }
4961 if let Some(l) = body.limit {
4962 params["limit"] = json!(l);
4963 }
4964 let lock = state.lock().await;
4965 let result = crate::mcp::handle_session_start(&lock.0, ¶ms, None);
4966 drop(lock);
4967 match result {
4968 Ok(mut v) => {
4969 if let Some(obj) = v.as_object_mut() {
4973 obj.entry("session_id")
4974 .or_insert_with(|| json!(Uuid::new_v4().to_string()));
4975 if let Some(ref a) = body.agent_id {
4976 obj.insert("agent_id".into(), json!(a));
4977 }
4978 }
4979 (StatusCode::OK, Json(v)).into_response()
4980 }
4981 Err(e) => (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(),
4982 }
4983}
4984
4985#[cfg(test)]
4986mod tests {
4987 use super::*;
4988
4989 fn test_state() -> Db {
4990 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
4991 let path = std::path::PathBuf::from(":memory:");
4992 Arc::new(Mutex::new((conn, path, ResolvedTtl::default(), true)))
4993 }
4994
4995 #[tokio::test]
4996 async fn health_returns_ok() {
4997 let state = test_state();
4998 let lock = state.lock().await;
4999 let ok = db::health_check(&lock.0).unwrap_or(false);
5000 assert!(ok);
5001 }
5002
5003 #[tokio::test]
5004 async fn store_and_retrieve_via_state() {
5005 let state = test_state();
5006 let lock = state.lock().await;
5007 let now = Utc::now();
5008 let mem = Memory {
5009 id: Uuid::new_v4().to_string(),
5010 tier: Tier::Long,
5011 namespace: "test".into(),
5012 title: "Handler test".into(),
5013 content: "Testing handlers.".into(),
5014 tags: vec!["test".into()],
5015 priority: 7,
5016 confidence: 1.0,
5017 source: "test".into(),
5018 access_count: 0,
5019 created_at: now.to_rfc3339(),
5020 updated_at: now.to_rfc3339(),
5021 last_accessed_at: None,
5022 expires_at: None,
5023 metadata: serde_json::json!({}),
5024 };
5025 let id = db::insert(&lock.0, &mem).unwrap();
5026 let got = db::get(&lock.0, &id).unwrap().unwrap();
5027 assert_eq!(got.title, "Handler test");
5028 }
5029
5030 #[tokio::test]
5031 async fn recall_via_state() {
5032 let state = test_state();
5033 let lock = state.lock().await;
5034 let now = Utc::now();
5035 let mem = Memory {
5036 id: Uuid::new_v4().to_string(),
5037 tier: Tier::Long,
5038 namespace: "test".into(),
5039 title: "Recall handler test".into(),
5040 content: "Content for recall.".into(),
5041 tags: vec![],
5042 priority: 8,
5043 confidence: 1.0,
5044 source: "test".into(),
5045 access_count: 0,
5046 created_at: now.to_rfc3339(),
5047 updated_at: now.to_rfc3339(),
5048 last_accessed_at: None,
5049 expires_at: None,
5050 metadata: serde_json::json!({}),
5051 };
5052 db::insert(&lock.0, &mem).unwrap();
5053 let (results, _outcome) = db::recall(
5054 &lock.0,
5055 "recall handler",
5056 Some("test"),
5057 10,
5058 None,
5059 None,
5060 None,
5061 crate::models::SHORT_TTL_EXTEND_SECS,
5062 crate::models::MID_TTL_EXTEND_SECS,
5063 None,
5064 None,
5065 )
5066 .unwrap();
5067 assert!(!results.is_empty());
5068 assert!(results[0].1 > 0.0); }
5070
5071 #[tokio::test]
5072 async fn stats_via_state() {
5073 let state = test_state();
5074 let lock = state.lock().await;
5075 let path = std::path::Path::new(":memory:");
5076 let s = db::stats(&lock.0, path).unwrap();
5077 assert_eq!(s.total, 0);
5078 }
5079
5080 #[tokio::test]
5081 async fn bulk_size_limit() {
5082 assert_eq!(MAX_BULK_SIZE, 1000);
5083 }
5084
5085 #[tokio::test]
5086 async fn list_empty_namespace() {
5087 let state = test_state();
5088 let lock = state.lock().await;
5089 let results = db::list(
5090 &lock.0,
5091 Some("nonexistent"),
5092 None,
5093 10,
5094 0,
5095 None,
5096 None,
5097 None,
5098 None,
5099 None,
5100 )
5101 .unwrap();
5102 assert!(results.is_empty());
5103 }
5104
5105 #[tokio::test]
5106 async fn create_and_update_with_metadata() {
5107 let state = test_state();
5108 let lock = state.lock().await;
5109 let now = Utc::now();
5110
5111 let mem = Memory {
5113 id: Uuid::new_v4().to_string(),
5114 tier: Tier::Long,
5115 namespace: "test".into(),
5116 title: "HTTP metadata test".into(),
5117 content: "Testing metadata through handler layer.".into(),
5118 tags: vec![],
5119 priority: 5,
5120 confidence: 1.0,
5121 source: "api".into(),
5122 access_count: 0,
5123 created_at: now.to_rfc3339(),
5124 updated_at: now.to_rfc3339(),
5125 last_accessed_at: None,
5126 expires_at: None,
5127 metadata: serde_json::json!({"http_test": true, "version": 1}),
5128 };
5129 let id = db::insert(&lock.0, &mem).unwrap();
5130
5131 let got = db::get(&lock.0, &id).unwrap().unwrap();
5133 assert_eq!(got.metadata["http_test"], true);
5134 assert_eq!(got.metadata["version"], 1);
5135
5136 let new_meta =
5138 serde_json::json!({"http_test": true, "version": 2, "updated_by": "handler"});
5139 let (found, _) = db::update(
5140 &lock.0,
5141 &id,
5142 None,
5143 None,
5144 None,
5145 None,
5146 None,
5147 None,
5148 None,
5149 None,
5150 Some(&new_meta),
5151 )
5152 .unwrap();
5153 assert!(found);
5154
5155 let got = db::get(&lock.0, &id).unwrap().unwrap();
5157 assert_eq!(got.metadata["version"], 2);
5158 assert_eq!(got.metadata["updated_by"], "handler");
5159 }
5160
5161 use axum::{Router, body::Body, routing::get as axum_get, routing::post as axum_post};
5164 use tower::ServiceExt as _;
5165
5166 fn test_app_state(db: Db) -> AppState {
5167 AppState {
5168 db,
5169 embedder: Arc::new(None),
5170 vector_index: Arc::new(Mutex::new(None)),
5171 federation: Arc::new(None),
5172 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
5173 scoring: Arc::new(crate::config::ResolvedScoring::default()),
5174 }
5175 }
5176
5177 #[tokio::test]
5178 async fn http_create_memory_uses_appstate_and_persists() {
5179 let state = test_state();
5183 let app = Router::new()
5184 .route("/api/v1/memories", axum_post(create_memory))
5185 .with_state(test_app_state(state.clone()));
5186
5187 let body = serde_json::json!({
5188 "tier": "long",
5189 "namespace": "http-embed-test",
5190 "title": "Semantic-ready via HTTP",
5191 "content": "HTTP-authored memories must now participate in semantic recall.",
5192 "tags": ["issue-219"],
5193 "priority": 7,
5194 "confidence": 1.0,
5195 "source": "api",
5196 "metadata": {}
5197 });
5198 let resp = app
5199 .oneshot(
5200 axum::http::Request::builder()
5201 .uri("/api/v1/memories")
5202 .method("POST")
5203 .header("content-type", "application/json")
5204 .header("x-agent-id", "alice")
5205 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5206 .unwrap(),
5207 )
5208 .await
5209 .unwrap();
5210 assert_eq!(resp.status(), StatusCode::CREATED);
5211
5212 let lock = state.lock().await;
5214 let rows = db::list(
5215 &lock.0,
5216 Some("http-embed-test"),
5217 None,
5218 10,
5219 0,
5220 None,
5221 None,
5222 None,
5223 None,
5224 None,
5225 )
5226 .unwrap();
5227 assert!(!rows.is_empty(), "HTTP-authored memory must be persisted");
5228 assert_eq!(rows[0].title, "Semantic-ready via HTTP");
5229 }
5230
5231 #[tokio::test]
5232 async fn http_update_memory_uses_appstate() {
5233 let state = test_state();
5236 let now = Utc::now();
5237 let id = {
5238 let lock = state.lock().await;
5239 let mem = Memory {
5240 id: Uuid::new_v4().to_string(),
5241 tier: Tier::Long,
5242 namespace: "http-embed-test".into(),
5243 title: "Before update".into(),
5244 content: "Original content.".into(),
5245 tags: vec![],
5246 priority: 5,
5247 confidence: 1.0,
5248 source: "test".into(),
5249 access_count: 0,
5250 created_at: now.to_rfc3339(),
5251 updated_at: now.to_rfc3339(),
5252 last_accessed_at: None,
5253 expires_at: None,
5254 metadata: serde_json::json!({}),
5255 };
5256 db::insert(&lock.0, &mem).unwrap()
5257 };
5258
5259 let app = Router::new()
5260 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
5261 .with_state(test_app_state(state.clone()));
5262
5263 let patch = serde_json::json!({"content": "Updated content for semantic refresh."});
5264 let resp = app
5265 .oneshot(
5266 axum::http::Request::builder()
5267 .uri(format!("/api/v1/memories/{id}"))
5268 .method("PUT")
5269 .header("content-type", "application/json")
5270 .body(Body::from(serde_json::to_vec(&patch).unwrap()))
5271 .unwrap(),
5272 )
5273 .await
5274 .unwrap();
5275 assert_eq!(resp.status(), StatusCode::OK);
5276 }
5277
5278 #[tokio::test]
5281 async fn http_sync_push_applies_and_advances_clock() {
5282 let state = test_state();
5286 let app = Router::new()
5287 .route("/api/v1/sync/push", axum_post(sync_push))
5288 .with_state(test_app_state(state.clone()));
5289
5290 let now = Utc::now().to_rfc3339();
5291 let body = serde_json::json!({
5292 "sender_agent_id": "peer-alice",
5293 "sender_clock": {"entries": {}},
5294 "memories": [{
5295 "id": Uuid::new_v4().to_string(),
5296 "tier": "long",
5297 "namespace": "sync-smoke",
5298 "title": "From peer",
5299 "content": "Pushed via HTTP sync endpoint.",
5300 "tags": [],
5301 "priority": 5,
5302 "confidence": 1.0,
5303 "source": "api",
5304 "access_count": 0,
5305 "created_at": now,
5306 "updated_at": now,
5307 "last_accessed_at": null,
5308 "expires_at": null,
5309 "metadata": {"agent_id": "peer-alice"}
5310 }],
5311 "dry_run": false
5312 });
5313 let resp = app
5314 .oneshot(
5315 axum::http::Request::builder()
5316 .uri("/api/v1/sync/push")
5317 .method("POST")
5318 .header("content-type", "application/json")
5319 .header("x-agent-id", "local-receiver")
5320 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5321 .unwrap(),
5322 )
5323 .await
5324 .unwrap();
5325 assert_eq!(resp.status(), StatusCode::OK);
5326
5327 let lock = state.lock().await;
5329 let rows = db::list(
5330 &lock.0,
5331 Some("sync-smoke"),
5332 None,
5333 10,
5334 0,
5335 None,
5336 None,
5337 None,
5338 None,
5339 None,
5340 )
5341 .unwrap();
5342 assert_eq!(rows.len(), 1);
5343 let clock = db::sync_state_load(&lock.0, "local-receiver").unwrap();
5345 assert!(
5346 clock.latest_from("peer-alice").is_some(),
5347 "push must record sender in sync_state; got: {:?}",
5348 clock.entries
5349 );
5350 }
5351
5352 #[tokio::test]
5353 async fn http_sync_push_applies_archives() {
5354 let state = test_state();
5359 let id = {
5362 let lock = state.lock().await;
5363 let now = Utc::now().to_rfc3339();
5364 let mem = Memory {
5365 id: Uuid::new_v4().to_string(),
5366 tier: Tier::Long,
5367 namespace: "s29".into(),
5368 title: "Archive M1".into(),
5369 content: "body".into(),
5370 tags: vec![],
5371 priority: 5,
5372 confidence: 1.0,
5373 source: "api".into(),
5374 access_count: 0,
5375 created_at: now.clone(),
5376 updated_at: now,
5377 last_accessed_at: None,
5378 expires_at: None,
5379 metadata: serde_json::json!({}),
5380 };
5381 db::insert(&lock.0, &mem).unwrap()
5382 };
5383
5384 let app = Router::new()
5385 .route("/api/v1/sync/push", axum_post(sync_push))
5386 .with_state(test_app_state(state.clone()));
5387
5388 let body = serde_json::json!({
5389 "sender_agent_id": "peer-a",
5390 "sender_clock": {"entries": {}},
5391 "memories": [],
5392 "archives": [id, "missing-on-peer"],
5393 "dry_run": false
5394 });
5395 let resp = app
5396 .oneshot(
5397 axum::http::Request::builder()
5398 .uri("/api/v1/sync/push")
5399 .method("POST")
5400 .header("content-type", "application/json")
5401 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5402 .unwrap(),
5403 )
5404 .await
5405 .unwrap();
5406 assert_eq!(resp.status(), StatusCode::OK);
5407 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5408 .await
5409 .unwrap();
5410 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5411 assert_eq!(v["archived"], 1, "live row must be archived");
5412 assert_eq!(v["noop"], 1, "missing id must no-op");
5413
5414 let lock = state.lock().await;
5417 assert!(db::get(&lock.0, &id).unwrap().is_none());
5418 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5419 assert_eq!(archived.len(), 1);
5420 assert_eq!(archived[0]["id"], id);
5421 assert_eq!(archived[0]["archive_reason"], "sync_push");
5422 }
5423
5424 #[tokio::test]
5425 async fn http_archive_by_ids_happy_path() {
5426 let state = test_state();
5430 let live_id = {
5431 let lock = state.lock().await;
5432 let now = Utc::now().to_rfc3339();
5433 let mem = Memory {
5434 id: Uuid::new_v4().to_string(),
5435 tier: Tier::Long,
5436 namespace: "s29".into(),
5437 title: "Live for archive".into(),
5438 content: "will be archived".into(),
5439 tags: vec![],
5440 priority: 5,
5441 confidence: 1.0,
5442 source: "api".into(),
5443 access_count: 0,
5444 created_at: now.clone(),
5445 updated_at: now,
5446 last_accessed_at: None,
5447 expires_at: None,
5448 metadata: serde_json::json!({}),
5449 };
5450 db::insert(&lock.0, &mem).unwrap()
5451 };
5452
5453 let app = Router::new()
5454 .route("/api/v1/archive", axum_post(archive_by_ids))
5455 .with_state(test_app_state(state.clone()));
5456
5457 let body = serde_json::json!({
5458 "ids": [live_id, "does-not-exist"],
5459 "reason": "scenario_s29"
5460 });
5461 let resp = app
5462 .oneshot(
5463 axum::http::Request::builder()
5464 .uri("/api/v1/archive")
5465 .method("POST")
5466 .header("content-type", "application/json")
5467 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5468 .unwrap(),
5469 )
5470 .await
5471 .unwrap();
5472 assert_eq!(resp.status(), StatusCode::OK);
5473 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5474 .await
5475 .unwrap();
5476 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5477 assert_eq!(v["count"], 1);
5478 assert_eq!(v["archived"].as_array().unwrap().len(), 1);
5479 assert_eq!(v["missing"].as_array().unwrap().len(), 1);
5480 assert_eq!(v["reason"], "scenario_s29");
5481
5482 let lock = state.lock().await;
5484 assert!(db::get(&lock.0, &live_id).unwrap().is_none());
5485 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5486 assert_eq!(archived.len(), 1);
5487 assert_eq!(archived[0]["id"], live_id);
5488 assert_eq!(archived[0]["archive_reason"], "scenario_s29");
5489 }
5490
5491 #[tokio::test]
5492 async fn http_archive_by_ids_default_reason() {
5493 let state = test_state();
5496 let live_id = {
5497 let lock = state.lock().await;
5498 let now = Utc::now().to_rfc3339();
5499 let mem = Memory {
5500 id: Uuid::new_v4().to_string(),
5501 tier: Tier::Long,
5502 namespace: "s29-default".into(),
5503 title: "Default reason".into(),
5504 content: "c".into(),
5505 tags: vec![],
5506 priority: 5,
5507 confidence: 1.0,
5508 source: "api".into(),
5509 access_count: 0,
5510 created_at: now.clone(),
5511 updated_at: now,
5512 last_accessed_at: None,
5513 expires_at: None,
5514 metadata: serde_json::json!({}),
5515 };
5516 db::insert(&lock.0, &mem).unwrap()
5517 };
5518
5519 let app = Router::new()
5520 .route("/api/v1/archive", axum_post(archive_by_ids))
5521 .with_state(test_app_state(state.clone()));
5522 let body = serde_json::json!({"ids": [live_id]});
5523 let resp = app
5524 .oneshot(
5525 axum::http::Request::builder()
5526 .uri("/api/v1/archive")
5527 .method("POST")
5528 .header("content-type", "application/json")
5529 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5530 .unwrap(),
5531 )
5532 .await
5533 .unwrap();
5534 assert_eq!(resp.status(), StatusCode::OK);
5535 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5536 .await
5537 .unwrap();
5538 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5539 assert_eq!(v["reason"], "archive");
5540 let lock = state.lock().await;
5541 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
5542 assert_eq!(archived[0]["archive_reason"], "archive");
5543 }
5544
5545 #[tokio::test]
5546 async fn http_bulk_create_uses_appstate_and_persists() {
5547 let state = test_state();
5551 let app = Router::new()
5552 .route("/api/v1/memories/bulk", axum_post(bulk_create))
5553 .with_state(test_app_state(state.clone()));
5554
5555 let bodies: Vec<serde_json::Value> = (0..5)
5556 .map(|i| {
5557 serde_json::json!({
5558 "tier": "long",
5559 "namespace": "bulk-appstate",
5560 "title": format!("bulk-{i}"),
5561 "content": format!("body-{i}"),
5562 "tags": [],
5563 "priority": 5,
5564 "confidence": 1.0,
5565 "source": "api",
5566 "metadata": {}
5567 })
5568 })
5569 .collect();
5570 let resp = app
5571 .oneshot(
5572 axum::http::Request::builder()
5573 .uri("/api/v1/memories/bulk")
5574 .method("POST")
5575 .header("content-type", "application/json")
5576 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5577 .unwrap(),
5578 )
5579 .await
5580 .unwrap();
5581 assert_eq!(resp.status(), StatusCode::OK);
5582 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5583 .await
5584 .unwrap();
5585 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5586 assert_eq!(v["created"], 5);
5587 assert!(v["errors"].as_array().unwrap().is_empty());
5588
5589 let lock = state.lock().await;
5592 let rows = db::list(
5593 &lock.0,
5594 Some("bulk-appstate"),
5595 None,
5596 100,
5597 0,
5598 None,
5599 None,
5600 None,
5601 None,
5602 None,
5603 )
5604 .unwrap();
5605 assert_eq!(rows.len(), 5, "bulk rows must persist via AppState");
5606 }
5607
5608 #[tokio::test]
5609 async fn http_bulk_create_fans_out_with_federation() {
5610 use std::sync::atomic::{AtomicUsize, Ordering};
5615 use tokio::net::TcpListener;
5616
5617 let state = test_state();
5618
5619 let count = Arc::new(AtomicUsize::new(0));
5621 let count_for_peer = count.clone();
5622 #[derive(Clone)]
5623 struct MockState {
5624 count: Arc<AtomicUsize>,
5625 }
5626 async fn mock_sync_push(
5627 axum::extract::State(s): axum::extract::State<MockState>,
5628 Json(_body): Json<serde_json::Value>,
5629 ) -> (StatusCode, Json<serde_json::Value>) {
5630 s.count.fetch_add(1, Ordering::Relaxed);
5631 (
5632 StatusCode::OK,
5633 Json(json!({"applied":1,"noop":0,"skipped":0})),
5634 )
5635 }
5636 let peer_app = Router::new()
5637 .route("/api/v1/sync/push", axum_post(mock_sync_push))
5638 .with_state(MockState {
5639 count: count_for_peer,
5640 });
5641 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5642 let addr = listener.local_addr().unwrap();
5643 tokio::spawn(async move {
5644 axum::serve(listener, peer_app).await.ok();
5645 });
5646
5647 let peer_url = format!("http://{addr}");
5649 let fed = crate::federation::FederationConfig::build(
5650 2, &[peer_url],
5652 std::time::Duration::from_secs(2),
5653 None,
5654 None,
5655 None,
5656 "ai:bulk-test".to_string(),
5657 )
5658 .unwrap()
5659 .expect("federation must be built");
5660
5661 let app_state = AppState {
5662 db: state.clone(),
5663 embedder: Arc::new(None),
5664 vector_index: Arc::new(Mutex::new(None)),
5665 federation: Arc::new(Some(fed)),
5666 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
5667 scoring: Arc::new(crate::config::ResolvedScoring::default()),
5668 };
5669 let router = Router::new()
5670 .route("/api/v1/memories/bulk", axum_post(bulk_create))
5671 .with_state(app_state);
5672
5673 let n = 4;
5675 let bodies: Vec<serde_json::Value> = (0..n)
5676 .map(|i| {
5677 serde_json::json!({
5678 "tier": "long",
5679 "namespace": "bulk-fanout",
5680 "title": format!("bulk-fanout-{i}"),
5681 "content": "c",
5682 "tags": [],
5683 "priority": 5,
5684 "confidence": 1.0,
5685 "source": "api",
5686 "metadata": {}
5687 })
5688 })
5689 .collect();
5690 let resp = router
5691 .oneshot(
5692 axum::http::Request::builder()
5693 .uri("/api/v1/memories/bulk")
5694 .method("POST")
5695 .header("content-type", "application/json")
5696 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
5697 .unwrap(),
5698 )
5699 .await
5700 .unwrap();
5701 assert_eq!(resp.status(), StatusCode::OK);
5702 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5703 .await
5704 .unwrap();
5705 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5706 assert_eq!(v["created"], n);
5707
5708 let expected = n + 1;
5714 for _ in 0..20 {
5715 if count.load(Ordering::Relaxed) >= expected {
5716 break;
5717 }
5718 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
5719 }
5720 assert_eq!(
5721 count.load(Ordering::Relaxed),
5722 expected,
5723 "mock peer must receive one sync_push POST per bulk row plus one terminal catchup batch"
5724 );
5725 }
5726
5727 #[tokio::test]
5728 async fn http_sync_push_rejects_oversized_batch_redteam_242() {
5729 let state = test_state();
5733 let app = Router::new()
5734 .route("/api/v1/sync/push", axum_post(sync_push))
5735 .with_state(test_app_state(state));
5736 let now = Utc::now().to_rfc3339();
5737 let mems: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
5739 .map(|i| {
5740 serde_json::json!({
5741 "id": Uuid::new_v4().to_string(),
5742 "tier": "long",
5743 "namespace": "oversize",
5744 "title": format!("m{i}"),
5745 "content": "x",
5746 "tags": [],
5747 "priority": 5,
5748 "confidence": 1.0,
5749 "source": "api",
5750 "access_count": 0,
5751 "created_at": now,
5752 "updated_at": now,
5753 "last_accessed_at": null,
5754 "expires_at": null,
5755 "metadata": {}
5756 })
5757 })
5758 .collect();
5759 let body = serde_json::json!({
5760 "sender_agent_id": "peer-flood",
5761 "sender_clock": {"entries": {}},
5762 "memories": mems,
5763 "dry_run": false,
5764 });
5765 let resp = app
5766 .oneshot(
5767 axum::http::Request::builder()
5768 .uri("/api/v1/sync/push")
5769 .method("POST")
5770 .header("content-type", "application/json")
5771 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5772 .unwrap(),
5773 )
5774 .await
5775 .unwrap();
5776 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5777 }
5778
5779 #[tokio::test]
5780 async fn http_sync_push_dry_run_applies_nothing() {
5781 let state = test_state();
5783 let app = Router::new()
5784 .route("/api/v1/sync/push", axum_post(sync_push))
5785 .with_state(test_app_state(state.clone()));
5786
5787 let now = Utc::now().to_rfc3339();
5788 let body = serde_json::json!({
5789 "sender_agent_id": "peer-bob",
5790 "sender_clock": {"entries": {}},
5791 "memories": [{
5792 "id": Uuid::new_v4().to_string(),
5793 "tier": "long",
5794 "namespace": "sync-dryrun",
5795 "title": "Must not land",
5796 "content": "Preview only.",
5797 "tags": [],
5798 "priority": 5,
5799 "confidence": 1.0,
5800 "source": "api",
5801 "access_count": 0,
5802 "created_at": now,
5803 "updated_at": now,
5804 "last_accessed_at": null,
5805 "expires_at": null,
5806 "metadata": {}
5807 }],
5808 "dry_run": true
5809 });
5810 let resp = app
5811 .oneshot(
5812 axum::http::Request::builder()
5813 .uri("/api/v1/sync/push")
5814 .method("POST")
5815 .header("content-type", "application/json")
5816 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5817 .unwrap(),
5818 )
5819 .await
5820 .unwrap();
5821 assert_eq!(resp.status(), StatusCode::OK);
5822
5823 let lock = state.lock().await;
5824 let rows = db::list(
5825 &lock.0,
5826 Some("sync-dryrun"),
5827 None,
5828 10,
5829 0,
5830 None,
5831 None,
5832 None,
5833 None,
5834 None,
5835 )
5836 .unwrap();
5837 assert!(rows.is_empty(), "dry_run must not write rows");
5838 }
5839
5840 #[tokio::test]
5841 async fn http_contradictions_surfaces_same_topic_candidates_and_synth_link() {
5842 let state = test_state();
5846 let now = Utc::now().to_rfc3339();
5847
5848 {
5852 let lock = state.lock().await;
5853 let topic = "sky-color-test";
5854 for (title, agent, content) in [
5855 ("sky-color-test-alice", "ai:alice", "sky-color-test is blue"),
5856 ("sky-color-test-bob", "ai:bob", "sky-color-test is red"),
5857 ] {
5858 let mem = Memory {
5859 id: Uuid::new_v4().to_string(),
5860 tier: Tier::Mid,
5861 namespace: "contradictions-test".into(),
5862 title: title.into(),
5863 content: content.into(),
5864 tags: vec![],
5865 priority: 5,
5866 confidence: 1.0,
5867 source: "api".into(),
5868 access_count: 0,
5869 created_at: now.clone(),
5870 updated_at: now.clone(),
5871 last_accessed_at: None,
5872 expires_at: None,
5873 metadata: serde_json::json!({
5874 "agent_id": agent,
5875 "topic": topic,
5876 }),
5877 };
5878 db::insert(&lock.0, &mem).unwrap();
5879 }
5880 }
5881
5882 let app = Router::new()
5883 .route("/api/v1/contradictions", axum_get(detect_contradictions))
5884 .with_state(state);
5885
5886 let resp = app
5887 .oneshot(
5888 axum::http::Request::builder()
5889 .uri(
5890 "/api/v1/contradictions?topic=sky-color-test&namespace=contradictions-test",
5891 )
5892 .body(Body::empty())
5893 .unwrap(),
5894 )
5895 .await
5896 .unwrap();
5897 assert_eq!(resp.status(), StatusCode::OK);
5898 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5899 .await
5900 .unwrap();
5901 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5902
5903 let memories = v["memories"].as_array().unwrap();
5904 assert_eq!(memories.len(), 2, "both candidates should be returned");
5905
5906 let links = v["links"].as_array().unwrap();
5907 let synth_contradict = links.iter().find(|l| {
5908 l["relation"].as_str() == Some("contradicts")
5909 && l["synthesized"].as_bool() == Some(true)
5910 });
5911 assert!(
5912 synth_contradict.is_some(),
5913 "expected a synthesized contradicts link between alice and bob"
5914 );
5915 }
5916
5917 #[tokio::test]
5918 async fn http_contradictions_requires_topic_or_namespace() {
5919 let state = test_state();
5922 let app = Router::new()
5923 .route("/api/v1/contradictions", axum_get(detect_contradictions))
5924 .with_state(state);
5925 let resp = app
5926 .oneshot(
5927 axum::http::Request::builder()
5928 .uri("/api/v1/contradictions")
5929 .body(Body::empty())
5930 .unwrap(),
5931 )
5932 .await
5933 .unwrap();
5934 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
5935 }
5936
5937 #[tokio::test]
5938 async fn http_sync_push_applies_deletions() {
5939 let state = test_state();
5943 let now = Utc::now().to_rfc3339();
5944
5945 let seeded_id = {
5946 let lock = state.lock().await;
5947 let mem = Memory {
5948 id: Uuid::new_v4().to_string(),
5949 tier: Tier::Mid,
5950 namespace: "delete-fanout".into(),
5951 title: "to-be-deleted".into(),
5952 content: "body".into(),
5953 tags: vec![],
5954 priority: 5,
5955 confidence: 1.0,
5956 source: "api".into(),
5957 access_count: 0,
5958 created_at: now.clone(),
5959 updated_at: now.clone(),
5960 last_accessed_at: None,
5961 expires_at: None,
5962 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
5963 };
5964 db::insert(&lock.0, &mem).unwrap()
5965 };
5966
5967 let app = Router::new()
5968 .route("/api/v1/sync/push", axum_post(sync_push))
5969 .with_state(test_app_state(state.clone()));
5970
5971 let body = serde_json::json!({
5972 "sender_agent_id": "peer-alice",
5973 "sender_clock": {"entries": {}},
5974 "memories": [],
5975 "deletions": [seeded_id.clone()],
5976 "dry_run": false
5977 });
5978 let resp = app
5979 .oneshot(
5980 axum::http::Request::builder()
5981 .uri("/api/v1/sync/push")
5982 .method("POST")
5983 .header("content-type", "application/json")
5984 .header("x-agent-id", "local-receiver")
5985 .body(Body::from(serde_json::to_vec(&body).unwrap()))
5986 .unwrap(),
5987 )
5988 .await
5989 .unwrap();
5990 assert_eq!(resp.status(), StatusCode::OK);
5991 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
5992 .await
5993 .unwrap();
5994 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
5995 assert_eq!(v["deleted"], 1);
5996
5997 let lock = state.lock().await;
5998 let gone = db::get(&lock.0, &seeded_id).unwrap();
5999 assert!(
6000 gone.is_none(),
6001 "row should have been tombstoned by sync_push"
6002 );
6003 }
6004
6005 #[tokio::test]
6006 async fn http_sync_push_applies_incoming_links() {
6007 let state = test_state();
6012 let now = Utc::now().to_rfc3339();
6013
6014 let (m1, m2) = {
6016 let lock = state.lock().await;
6017 let m1 = Memory {
6018 id: Uuid::new_v4().to_string(),
6019 tier: Tier::Mid,
6020 namespace: "link-fanout".into(),
6021 title: "source".into(),
6022 content: "a".into(),
6023 tags: vec![],
6024 priority: 5,
6025 confidence: 1.0,
6026 source: "api".into(),
6027 access_count: 0,
6028 created_at: now.clone(),
6029 updated_at: now.clone(),
6030 last_accessed_at: None,
6031 expires_at: None,
6032 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
6033 };
6034 let m1_id = db::insert(&lock.0, &m1).unwrap();
6035 let m2 = Memory {
6036 id: Uuid::new_v4().to_string(),
6037 tier: Tier::Mid,
6038 namespace: "link-fanout".into(),
6039 title: "target".into(),
6040 content: "b".into(),
6041 tags: vec![],
6042 priority: 5,
6043 confidence: 1.0,
6044 source: "api".into(),
6045 access_count: 0,
6046 created_at: now.clone(),
6047 updated_at: now.clone(),
6048 last_accessed_at: None,
6049 expires_at: None,
6050 metadata: serde_json::json!({"agent_id": "ai:seeder"}),
6051 };
6052 let m2_id = db::insert(&lock.0, &m2).unwrap();
6053 (m1_id, m2_id)
6054 };
6055
6056 let app = Router::new()
6057 .route("/api/v1/sync/push", axum_post(sync_push))
6058 .with_state(test_app_state(state.clone()));
6059
6060 let body = serde_json::json!({
6061 "sender_agent_id": "peer-alice",
6062 "sender_clock": {"entries": {}},
6063 "memories": [],
6064 "links": [{
6065 "source_id": m1,
6066 "target_id": m2,
6067 "relation": "related_to",
6068 "created_at": now,
6069 }],
6070 "dry_run": false
6071 });
6072 let resp = app
6073 .oneshot(
6074 axum::http::Request::builder()
6075 .uri("/api/v1/sync/push")
6076 .method("POST")
6077 .header("content-type", "application/json")
6078 .header("x-agent-id", "local-receiver")
6079 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6080 .unwrap(),
6081 )
6082 .await
6083 .unwrap();
6084 assert_eq!(resp.status(), StatusCode::OK);
6085 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6086 .await
6087 .unwrap();
6088 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6089 assert_eq!(v["links_applied"], 1);
6090
6091 let lock = state.lock().await;
6092 let links = db::get_links(&lock.0, &m1).unwrap();
6093 assert_eq!(links.len(), 1);
6094 assert_eq!(links[0].target_id, m2);
6095 assert_eq!(links[0].relation, "related_to");
6096 }
6097
6098 #[tokio::test]
6099 async fn http_sync_since_streams_new_memories_only() {
6100 let state = test_state();
6103 let old_ts = "2020-01-01T00:00:00+00:00";
6105 let new_ts = Utc::now().to_rfc3339();
6106 {
6107 let lock = state.lock().await;
6108 for (title, ts) in [("old-mem", old_ts), ("new-mem", new_ts.as_str())] {
6109 let mem = Memory {
6110 id: Uuid::new_v4().to_string(),
6111 tier: Tier::Long,
6112 namespace: "since-test".into(),
6113 title: title.into(),
6114 content: "body".into(),
6115 tags: vec![],
6116 priority: 5,
6117 confidence: 1.0,
6118 source: "api".into(),
6119 access_count: 0,
6120 created_at: ts.to_string(),
6121 updated_at: ts.to_string(),
6122 last_accessed_at: None,
6123 expires_at: None,
6124 metadata: serde_json::json!({}),
6125 };
6126 db::insert(&lock.0, &mem).unwrap();
6127 }
6128 }
6129
6130 let app = Router::new()
6131 .route("/api/v1/sync/since", axum_get(sync_since))
6132 .with_state(state);
6133
6134 let resp = app
6135 .oneshot(
6136 axum::http::Request::builder()
6137 .uri("/api/v1/sync/since?since=2020-06-01T00:00:00%2B00:00")
6138 .body(Body::empty())
6139 .unwrap(),
6140 )
6141 .await
6142 .unwrap();
6143 assert_eq!(resp.status(), StatusCode::OK);
6144 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6145 .await
6146 .unwrap();
6147 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6148 let titles: Vec<String> = v["memories"]
6149 .as_array()
6150 .unwrap()
6151 .iter()
6152 .filter_map(|m| m["title"].as_str().map(str::to_string))
6153 .collect();
6154 assert_eq!(titles, vec!["new-mem".to_string()]);
6155 }
6156
6157 #[tokio::test]
6158 async fn http_sync_since_includes_s39_diagnostic_fields() {
6159 let state = test_state();
6164 let mid_ts = "2024-06-01T00:00:00+00:00";
6166 let newer_ts = "2025-06-01T00:00:00+00:00";
6167 let newest_ts = "2026-01-01T00:00:00+00:00";
6168 {
6169 let lock = state.lock().await;
6170 for (title, ts) in [("mid", mid_ts), ("newer", newer_ts), ("newest", newest_ts)] {
6171 let mem = Memory {
6172 id: Uuid::new_v4().to_string(),
6173 tier: Tier::Long,
6174 namespace: "s39-diag".into(),
6175 title: title.into(),
6176 content: "c".into(),
6177 tags: vec![],
6178 priority: 5,
6179 confidence: 1.0,
6180 source: "api".into(),
6181 access_count: 0,
6182 created_at: ts.to_string(),
6183 updated_at: ts.to_string(),
6184 last_accessed_at: None,
6185 expires_at: None,
6186 metadata: serde_json::json!({}),
6187 };
6188 db::insert(&lock.0, &mem).unwrap();
6189 }
6190 }
6191
6192 let app = Router::new()
6193 .route("/api/v1/sync/since", axum_get(sync_since))
6194 .with_state(state.clone());
6195
6196 let since = "2024-01-01T00:00:00%2B00:00";
6198 let resp = app
6199 .oneshot(
6200 axum::http::Request::builder()
6201 .uri(format!("/api/v1/sync/since?since={since}"))
6202 .body(Body::empty())
6203 .unwrap(),
6204 )
6205 .await
6206 .unwrap();
6207 assert_eq!(resp.status(), StatusCode::OK);
6208 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6209 .await
6210 .unwrap();
6211 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6212 assert_eq!(v["count"], 3);
6213 assert_eq!(v["updated_since"], "2024-01-01T00:00:00+00:00");
6215 assert_eq!(v["earliest_updated_at"], mid_ts);
6216 assert_eq!(v["latest_updated_at"], newest_ts);
6217
6218 let empty_app = Router::new()
6221 .route("/api/v1/sync/since", axum_get(sync_since))
6222 .with_state(state);
6223 let resp = empty_app
6224 .oneshot(
6225 axum::http::Request::builder()
6226 .uri("/api/v1/sync/since?since=2099-01-01T00:00:00%2B00:00")
6227 .body(Body::empty())
6228 .unwrap(),
6229 )
6230 .await
6231 .unwrap();
6232 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6233 .await
6234 .unwrap();
6235 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6236 assert_eq!(v["count"], 0);
6237 assert!(v["earliest_updated_at"].is_null());
6238 assert!(v["latest_updated_at"].is_null());
6239 assert_eq!(v["updated_since"], "2099-01-01T00:00:00+00:00");
6240 }
6241
6242 #[tokio::test]
6243 async fn sync_since_rejects_garbage_timestamp_with_400() {
6244 let state = test_state();
6247 let app = Router::new()
6248 .route("/api/v1/sync/since", axum_get(sync_since))
6249 .with_state(state);
6250
6251 let resp = app
6252 .oneshot(
6253 axum::http::Request::builder()
6254 .uri("/api/v1/sync/since?since=not-a-date")
6255 .body(Body::empty())
6256 .unwrap(),
6257 )
6258 .await
6259 .unwrap();
6260 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6261 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6262 .await
6263 .unwrap();
6264 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6265 assert!(v["error"].as_str().unwrap().contains("RFC 3339"));
6266 }
6267
6268 #[tokio::test]
6269 async fn sync_state_observe_is_monotonic() {
6270 let conn = db::open(std::path::Path::new(":memory:")).unwrap();
6272 let older = "2020-01-01T00:00:00+00:00";
6273 let newer = "2026-04-17T00:00:00+00:00";
6274
6275 db::sync_state_observe(&conn, "local", "peer-a", newer).unwrap();
6276 db::sync_state_observe(&conn, "local", "peer-a", older).unwrap();
6278 let clock = db::sync_state_load(&conn, "local").unwrap();
6279 assert_eq!(clock.latest_from("peer-a"), Some(newer));
6280 }
6281
6282 async fn dummy_handler() -> impl IntoResponse {
6285 (StatusCode::OK, "ok")
6286 }
6287
6288 fn auth_app(api_key: Option<&str>) -> Router {
6289 let auth_state = ApiKeyState {
6290 key: api_key.map(String::from),
6291 };
6292 Router::new()
6293 .route("/api/v1/health", axum_get(dummy_handler))
6294 .route("/api/v1/memories", axum_get(dummy_handler))
6295 .layer(axum::middleware::from_fn_with_state(
6296 auth_state,
6297 api_key_auth,
6298 ))
6299 }
6300
6301 #[tokio::test]
6302 async fn api_key_no_key_configured_allows_all() {
6303 let app = auth_app(None);
6304 let resp = app
6305 .oneshot(
6306 axum::http::Request::builder()
6307 .uri("/api/v1/memories")
6308 .body(Body::empty())
6309 .unwrap(),
6310 )
6311 .await
6312 .unwrap();
6313 assert_eq!(resp.status(), StatusCode::OK);
6314 }
6315
6316 #[tokio::test]
6317 async fn api_key_valid_header_allows() {
6318 let app = auth_app(Some("secret123"));
6319 let resp = app
6320 .oneshot(
6321 axum::http::Request::builder()
6322 .uri("/api/v1/memories")
6323 .header("x-api-key", "secret123")
6324 .body(Body::empty())
6325 .unwrap(),
6326 )
6327 .await
6328 .unwrap();
6329 assert_eq!(resp.status(), StatusCode::OK);
6330 }
6331
6332 #[tokio::test]
6333 async fn api_key_invalid_header_rejected() {
6334 let app = auth_app(Some("secret123"));
6335 let resp = app
6336 .oneshot(
6337 axum::http::Request::builder()
6338 .uri("/api/v1/memories")
6339 .header("x-api-key", "wrong")
6340 .body(Body::empty())
6341 .unwrap(),
6342 )
6343 .await
6344 .unwrap();
6345 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6346 }
6347
6348 #[tokio::test]
6349 async fn api_key_missing_header_rejected() {
6350 let app = auth_app(Some("secret123"));
6351 let resp = app
6352 .oneshot(
6353 axum::http::Request::builder()
6354 .uri("/api/v1/memories")
6355 .body(Body::empty())
6356 .unwrap(),
6357 )
6358 .await
6359 .unwrap();
6360 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
6361 }
6362
6363 #[tokio::test]
6364 async fn api_key_valid_query_param_allows() {
6365 let app = auth_app(Some("secret123"));
6366 let resp = app
6367 .oneshot(
6368 axum::http::Request::builder()
6369 .uri("/api/v1/memories?api_key=secret123")
6370 .body(Body::empty())
6371 .unwrap(),
6372 )
6373 .await
6374 .unwrap();
6375 assert_eq!(resp.status(), StatusCode::OK);
6376 }
6377
6378 #[tokio::test]
6379 async fn api_key_health_exempt() {
6380 let app = auth_app(Some("secret123"));
6381 let resp = app
6382 .oneshot(
6383 axum::http::Request::builder()
6384 .uri("/api/v1/health")
6385 .body(Body::empty())
6386 .unwrap(),
6387 )
6388 .await
6389 .unwrap();
6390 assert_eq!(resp.status(), StatusCode::OK);
6391 }
6392 #[tokio::test]
6400 async fn create_memory_rejects_invalid_json() {
6401 let state = test_state();
6402 let app = Router::new()
6403 .route("/api/v1/memories", axum_post(create_memory))
6404 .with_state(test_app_state(state));
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(b"not valid json".to_vec()))
6413 .unwrap(),
6414 )
6415 .await
6416 .unwrap();
6417 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6418 }
6419
6420 #[tokio::test]
6421 async fn create_memory_rejects_missing_required_fields() {
6422 let state = test_state();
6423 let app = Router::new()
6424 .route("/api/v1/memories", axum_post(create_memory))
6425 .with_state(test_app_state(state));
6426
6427 let body = serde_json::json!({
6429 "tier": "long",
6430 "namespace": "test",
6431 "content": "body text",
6432 "tags": [],
6433 "priority": 5,
6434 "confidence": 1.0,
6435 "source": "api",
6436 "metadata": {}
6437 });
6438 let resp = app
6439 .clone()
6440 .oneshot(
6441 axum::http::Request::builder()
6442 .uri("/api/v1/memories")
6443 .method("POST")
6444 .header("content-type", "application/json")
6445 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6446 .unwrap(),
6447 )
6448 .await
6449 .unwrap();
6450 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6451 }
6452
6453 #[tokio::test]
6454 async fn create_memory_rejects_empty_title() {
6455 let state = test_state();
6456 let app = Router::new()
6457 .route("/api/v1/memories", axum_post(create_memory))
6458 .with_state(test_app_state(state));
6459
6460 let body = serde_json::json!({
6461 "tier": "long",
6462 "namespace": "test",
6463 "title": "",
6464 "content": "body text",
6465 "tags": [],
6466 "priority": 5,
6467 "confidence": 1.0,
6468 "source": "api",
6469 "metadata": {}
6470 });
6471 let resp = app
6472 .oneshot(
6473 axum::http::Request::builder()
6474 .uri("/api/v1/memories")
6475 .method("POST")
6476 .header("content-type", "application/json")
6477 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6478 .unwrap(),
6479 )
6480 .await
6481 .unwrap();
6482 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6483 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6484 .await
6485 .unwrap();
6486 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6487 assert!(v["error"].as_str().unwrap().contains("title"));
6488 }
6489
6490 #[tokio::test]
6491 async fn create_memory_rejects_oversized_content() {
6492 let state = test_state();
6493 let app = Router::new()
6494 .route("/api/v1/memories", axum_post(create_memory))
6495 .with_state(test_app_state(state));
6496
6497 let oversized = "x".repeat(65537);
6499 let body = serde_json::json!({
6500 "tier": "long",
6501 "namespace": "test",
6502 "title": "Test",
6503 "content": oversized,
6504 "tags": [],
6505 "priority": 5,
6506 "confidence": 1.0,
6507 "source": "api",
6508 "metadata": {}
6509 });
6510 let resp = app
6511 .oneshot(
6512 axum::http::Request::builder()
6513 .uri("/api/v1/memories")
6514 .method("POST")
6515 .header("content-type", "application/json")
6516 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6517 .unwrap(),
6518 )
6519 .await
6520 .unwrap();
6521 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6522 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6523 .await
6524 .unwrap();
6525 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6526 assert!(v["error"].as_str().unwrap().contains("exceeds max size"));
6527 }
6528
6529 #[tokio::test]
6530 async fn create_memory_rejects_invalid_tier() {
6531 let state = test_state();
6532 let app = Router::new()
6533 .route("/api/v1/memories", axum_post(create_memory))
6534 .with_state(test_app_state(state));
6535
6536 let body_str = r#"{"tier":"invalid_tier","namespace":"test","title":"Test","content":"body","tags":[],"priority":5,"confidence":1.0,"source":"api","metadata":{}}"#;
6538 let resp = app
6539 .oneshot(
6540 axum::http::Request::builder()
6541 .uri("/api/v1/memories")
6542 .method("POST")
6543 .header("content-type", "application/json")
6544 .body(Body::from(body_str.as_bytes().to_vec()))
6545 .unwrap(),
6546 )
6547 .await
6548 .unwrap();
6549 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
6550 }
6551
6552 #[tokio::test]
6553 async fn create_memory_rejects_invalid_priority() {
6554 let state = test_state();
6555 let app = Router::new()
6556 .route("/api/v1/memories", axum_post(create_memory))
6557 .with_state(test_app_state(state));
6558
6559 let body = serde_json::json!({
6560 "tier": "long",
6561 "namespace": "test",
6562 "title": "Test",
6563 "content": "body",
6564 "tags": [],
6565 "priority": 0, "confidence": 1.0,
6567 "source": "api",
6568 "metadata": {}
6569 });
6570 let resp = app
6571 .oneshot(
6572 axum::http::Request::builder()
6573 .uri("/api/v1/memories")
6574 .method("POST")
6575 .header("content-type", "application/json")
6576 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6577 .unwrap(),
6578 )
6579 .await
6580 .unwrap();
6581 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6582 }
6583
6584 #[tokio::test]
6585 async fn create_memory_rejects_invalid_confidence() {
6586 let state = test_state();
6587 let app = Router::new()
6588 .route("/api/v1/memories", axum_post(create_memory))
6589 .with_state(test_app_state(state));
6590
6591 let body = serde_json::json!({
6592 "tier": "long",
6593 "namespace": "test",
6594 "title": "Test",
6595 "content": "body",
6596 "tags": [],
6597 "priority": 5,
6598 "confidence": 1.5, "source": "api",
6600 "metadata": {}
6601 });
6602 let resp = app
6603 .oneshot(
6604 axum::http::Request::builder()
6605 .uri("/api/v1/memories")
6606 .method("POST")
6607 .header("content-type", "application/json")
6608 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6609 .unwrap(),
6610 )
6611 .await
6612 .unwrap();
6613 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6614 }
6615
6616 #[tokio::test]
6617 async fn create_memory_rejects_invalid_source() {
6618 let state = test_state();
6619 let app = Router::new()
6620 .route("/api/v1/memories", axum_post(create_memory))
6621 .with_state(test_app_state(state));
6622
6623 let body = serde_json::json!({
6624 "tier": "long",
6625 "namespace": "test",
6626 "title": "Test",
6627 "content": "body",
6628 "tags": [],
6629 "priority": 5,
6630 "confidence": 1.0,
6631 "source": "invalid_source",
6632 "metadata": {}
6633 });
6634 let resp = app
6635 .oneshot(
6636 axum::http::Request::builder()
6637 .uri("/api/v1/memories")
6638 .method("POST")
6639 .header("content-type", "application/json")
6640 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6641 .unwrap(),
6642 )
6643 .await
6644 .unwrap();
6645 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6646 }
6647
6648 #[tokio::test]
6651 async fn update_memory_rejects_invalid_id() {
6652 let state = test_state();
6653 let app = Router::new()
6654 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6655 .with_state(test_app_state(state));
6656
6657 let body = serde_json::json!({"content": "new content"});
6658 let resp = app
6662 .oneshot(
6663 axum::http::Request::builder()
6664 .uri("/api/v1/memories/@@@@@@@@@@@@") .method("PUT")
6666 .header("content-type", "application/json")
6667 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6668 .unwrap(),
6669 )
6670 .await
6671 .unwrap();
6672 assert!(resp.status() == StatusCode::BAD_REQUEST || resp.status() == StatusCode::NOT_FOUND);
6674 }
6675
6676 #[tokio::test]
6677 async fn update_memory_rejects_oversized_content() {
6678 let state = test_state();
6679 let now = Utc::now();
6680 let id = {
6681 let lock = state.lock().await;
6682 let mem = Memory {
6683 id: Uuid::new_v4().to_string(),
6684 tier: Tier::Long,
6685 namespace: "test".into(),
6686 title: "To Update".into(),
6687 content: "Original".into(),
6688 tags: vec![],
6689 priority: 5,
6690 confidence: 1.0,
6691 source: "test".into(),
6692 access_count: 0,
6693 created_at: now.to_rfc3339(),
6694 updated_at: now.to_rfc3339(),
6695 last_accessed_at: None,
6696 expires_at: None,
6697 metadata: serde_json::json!({}),
6698 };
6699 db::insert(&lock.0, &mem).unwrap()
6700 };
6701
6702 let app = Router::new()
6703 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6704 .with_state(test_app_state(state));
6705
6706 let oversized = "x".repeat(65537);
6707 let body = serde_json::json!({"content": oversized});
6708 let resp = app
6709 .oneshot(
6710 axum::http::Request::builder()
6711 .uri(format!("/api/v1/memories/{id}"))
6712 .method("PUT")
6713 .header("content-type", "application/json")
6714 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6715 .unwrap(),
6716 )
6717 .await
6718 .unwrap();
6719 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6720 }
6721
6722 #[tokio::test]
6723 async fn update_memory_rejects_invalid_confidence() {
6724 let state = test_state();
6725 let now = Utc::now();
6726 let id = {
6727 let lock = state.lock().await;
6728 let mem = Memory {
6729 id: Uuid::new_v4().to_string(),
6730 tier: Tier::Long,
6731 namespace: "test".into(),
6732 title: "To Update".into(),
6733 content: "Original".into(),
6734 tags: vec![],
6735 priority: 5,
6736 confidence: 1.0,
6737 source: "test".into(),
6738 access_count: 0,
6739 created_at: now.to_rfc3339(),
6740 updated_at: now.to_rfc3339(),
6741 last_accessed_at: None,
6742 expires_at: None,
6743 metadata: serde_json::json!({}),
6744 };
6745 db::insert(&lock.0, &mem).unwrap()
6746 };
6747
6748 let app = Router::new()
6749 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
6750 .with_state(test_app_state(state));
6751
6752 let body = serde_json::json!({"confidence": -0.5});
6753 let resp = app
6754 .oneshot(
6755 axum::http::Request::builder()
6756 .uri(format!("/api/v1/memories/{id}"))
6757 .method("PUT")
6758 .header("content-type", "application/json")
6759 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6760 .unwrap(),
6761 )
6762 .await
6763 .unwrap();
6764 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6765 }
6766
6767 #[tokio::test]
6770 async fn link_rejects_self_link() {
6771 let state = test_state();
6772 let app = Router::new()
6773 .route("/api/v1/links", axum_post(create_link))
6774 .with_state(test_app_state(state));
6775
6776 let same_id = Uuid::new_v4().to_string();
6777 let body = serde_json::json!({
6778 "source_id": same_id,
6779 "target_id": same_id,
6780 "relation": "related_to"
6781 });
6782 let resp = app
6783 .oneshot(
6784 axum::http::Request::builder()
6785 .uri("/api/v1/links")
6786 .method("POST")
6787 .header("content-type", "application/json")
6788 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6789 .unwrap(),
6790 )
6791 .await
6792 .unwrap();
6793 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6794 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6795 .await
6796 .unwrap();
6797 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6798 assert!(
6799 v["error"]
6800 .as_str()
6801 .unwrap()
6802 .contains("cannot link a memory to itself")
6803 );
6804 }
6805
6806 #[tokio::test]
6807 async fn link_rejects_unknown_relation() {
6808 let state = test_state();
6809 let app = Router::new()
6810 .route("/api/v1/links", axum_post(create_link))
6811 .with_state(test_app_state(state));
6812
6813 let body = serde_json::json!({
6814 "source_id": Uuid::new_v4().to_string(),
6815 "target_id": Uuid::new_v4().to_string(),
6816 "relation": "invalid_relation"
6817 });
6818 let resp = app
6819 .oneshot(
6820 axum::http::Request::builder()
6821 .uri("/api/v1/links")
6822 .method("POST")
6823 .header("content-type", "application/json")
6824 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6825 .unwrap(),
6826 )
6827 .await
6828 .unwrap();
6829 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6830 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6831 .await
6832 .unwrap();
6833 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6834 assert!(v["error"].as_str().unwrap().contains("relation"));
6835 }
6836
6837 #[tokio::test]
6840 async fn recall_post_rejects_empty_context() {
6841 let state = test_state();
6842 let app = Router::new()
6843 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6844 .with_state(test_app_state(state));
6845
6846 let body = serde_json::json!({
6847 "context": "",
6848 "limit": 10
6849 });
6850 let resp = app
6851 .oneshot(
6852 axum::http::Request::builder()
6853 .uri("/api/v1/memories/recall")
6854 .method("POST")
6855 .header("content-type", "application/json")
6856 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6857 .unwrap(),
6858 )
6859 .await
6860 .unwrap();
6861 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6862 }
6863
6864 #[tokio::test]
6865 async fn recall_post_zero_budget_tokens_returns_empty() {
6866 let state = test_state();
6871 let app = Router::new()
6872 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
6873 .with_state(test_app_state(state));
6874
6875 let body = serde_json::json!({
6876 "context": "search term",
6877 "limit": 10,
6878 "budget_tokens": 0
6879 });
6880 let resp = app
6881 .oneshot(
6882 axum::http::Request::builder()
6883 .uri("/api/v1/memories/recall")
6884 .method("POST")
6885 .header("content-type", "application/json")
6886 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6887 .unwrap(),
6888 )
6889 .await
6890 .unwrap();
6891 assert_eq!(resp.status(), StatusCode::OK);
6892 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
6893 .await
6894 .unwrap();
6895 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6896 assert_eq!(v["count"], 0, "budget_tokens=0 returns zero memories");
6897 assert_eq!(v["budget_tokens"], 0);
6898 assert_eq!(v["meta"]["budget_overflow"], false);
6899 }
6900
6901 #[tokio::test]
6902 async fn recall_get_rejects_empty_context() {
6903 let state = test_state();
6904 let app = Router::new()
6905 .route(
6906 "/api/v1/memories/recall",
6907 axum::routing::get(recall_memories_get),
6908 )
6909 .with_state(test_app_state(state));
6910
6911 let resp = app
6912 .oneshot(
6913 axum::http::Request::builder()
6914 .uri("/api/v1/memories/recall?context=")
6915 .method("GET")
6916 .body(Body::empty())
6917 .unwrap(),
6918 )
6919 .await
6920 .unwrap();
6921 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6922 }
6923
6924 #[tokio::test]
6927 async fn register_agent_rejects_invalid_agent_id() {
6928 let state = test_state();
6929 let app = Router::new()
6930 .route("/api/v1/agents", axum_post(register_agent))
6931 .with_state(test_app_state(state));
6932
6933 let body = serde_json::json!({
6934 "agent_id": "x".repeat(129), "agent_type": "human",
6936 "capabilities": []
6937 });
6938 let resp = app
6939 .oneshot(
6940 axum::http::Request::builder()
6941 .uri("/api/v1/agents")
6942 .method("POST")
6943 .header("content-type", "application/json")
6944 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6945 .unwrap(),
6946 )
6947 .await
6948 .unwrap();
6949 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6950 }
6951
6952 #[tokio::test]
6953 async fn register_agent_rejects_invalid_agent_type() {
6954 let state = test_state();
6955 let app = Router::new()
6956 .route("/api/v1/agents", axum_post(register_agent))
6957 .with_state(test_app_state(state));
6958
6959 let body = serde_json::json!({
6960 "agent_id": "test-agent",
6961 "agent_type": "invalid_type",
6962 "capabilities": []
6963 });
6964 let resp = app
6965 .oneshot(
6966 axum::http::Request::builder()
6967 .uri("/api/v1/agents")
6968 .method("POST")
6969 .header("content-type", "application/json")
6970 .body(Body::from(serde_json::to_vec(&body).unwrap()))
6971 .unwrap(),
6972 )
6973 .await
6974 .unwrap();
6975 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
6976 }
6977
6978 #[tokio::test]
6981 async fn subscribe_rejects_private_ip() {
6982 let state = test_state();
6983 let app = Router::new()
6984 .route("/api/v1/subscriptions", axum_post(subscribe))
6985 .with_state(test_app_state(state));
6986
6987 let body = serde_json::json!({
6989 "url": "http://10.0.0.1/webhook",
6990 "events": "*"
6991 });
6992 let resp = app
6993 .oneshot(
6994 axum::http::Request::builder()
6995 .uri("/api/v1/subscriptions")
6996 .method("POST")
6997 .header("content-type", "application/json")
6998 .header("x-agent-id", "alice")
6999 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7000 .unwrap(),
7001 )
7002 .await
7003 .unwrap();
7004 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7005 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7007 .await
7008 .unwrap();
7009 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7010 let error_msg = v["error"].as_str().unwrap();
7011 assert!(
7012 error_msg.contains("private")
7013 || error_msg.contains("link-local")
7014 || error_msg.contains("https")
7015 || error_msg.contains("non-loopback")
7016 );
7017 }
7018
7019 #[tokio::test]
7020 async fn subscribe_rejects_file_url() {
7021 let state = test_state();
7022 let app = Router::new()
7023 .route("/api/v1/subscriptions", axum_post(subscribe))
7024 .with_state(test_app_state(state));
7025
7026 let body = serde_json::json!({
7027 "url": "file:///etc/passwd",
7028 "events": "*"
7029 });
7030 let resp = app
7031 .oneshot(
7032 axum::http::Request::builder()
7033 .uri("/api/v1/subscriptions")
7034 .method("POST")
7035 .header("content-type", "application/json")
7036 .header("x-agent-id", "alice")
7037 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7038 .unwrap(),
7039 )
7040 .await
7041 .unwrap();
7042 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7043 }
7044
7045 #[tokio::test]
7046 async fn subscribe_accepts_localhost_loopback() {
7047 let state = test_state();
7049 let app = Router::new()
7050 .route("/api/v1/subscriptions", axum_post(subscribe))
7051 .with_state(test_app_state(state));
7052
7053 let body = serde_json::json!({
7054 "url": "http://localhost/webhook",
7055 "events": "*"
7056 });
7057 let resp = app
7058 .oneshot(
7059 axum::http::Request::builder()
7060 .uri("/api/v1/subscriptions")
7061 .method("POST")
7062 .header("content-type", "application/json")
7063 .header("x-agent-id", "alice")
7064 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7065 .unwrap(),
7066 )
7067 .await
7068 .unwrap();
7069 assert!(resp.status() == StatusCode::CREATED || resp.status() == StatusCode::OK);
7072 }
7073
7074 #[tokio::test]
7077 async fn notify_rejects_missing_payload() {
7078 let state = test_state();
7079 let app = Router::new()
7080 .route("/api/v1/notify", axum_post(notify))
7081 .with_state(test_app_state(state));
7082
7083 let body = serde_json::json!({
7084 "target_agent_id": "bob",
7085 "title": "A message"
7086 });
7087 let resp = app
7088 .oneshot(
7089 axum::http::Request::builder()
7090 .uri("/api/v1/notify")
7091 .method("POST")
7092 .header("content-type", "application/json")
7093 .header("x-agent-id", "alice")
7094 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7095 .unwrap(),
7096 )
7097 .await
7098 .unwrap();
7099 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7100 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7101 .await
7102 .unwrap();
7103 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7104 assert!(
7105 v["error"].as_str().unwrap().contains("payload")
7106 || v["error"].as_str().unwrap().contains("content")
7107 );
7108 }
7109
7110 #[tokio::test]
7118 async fn create_memory_handles_missing_content_type() {
7119 let state = test_state();
7120 let app = Router::new()
7121 .route("/api/v1/memories", axum_post(create_memory))
7122 .with_state(test_app_state(state));
7123
7124 let body = serde_json::json!({
7125 "tier": "long",
7126 "namespace": "test",
7127 "title": "Test",
7128 "content": "body",
7129 "tags": [],
7130 "priority": 5,
7131 "confidence": 1.0,
7132 "source": "api",
7133 "metadata": {}
7134 });
7135 let resp = app
7137 .oneshot(
7138 axum::http::Request::builder()
7139 .uri("/api/v1/memories")
7140 .method("POST")
7141 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7142 .unwrap(),
7143 )
7144 .await
7145 .unwrap();
7146 assert!(resp.status() != StatusCode::CREATED);
7148 }
7149
7150 #[tokio::test]
7153 async fn list_memories_handles_limit_zero() {
7154 let state = test_state();
7155 let app = Router::new()
7156 .route("/api/v1/memories", axum::routing::get(list_memories))
7157 .with_state(test_app_state(state));
7158
7159 let resp = app
7160 .oneshot(
7161 axum::http::Request::builder()
7162 .uri("/api/v1/memories?limit=0")
7163 .method("GET")
7164 .body(Body::empty())
7165 .unwrap(),
7166 )
7167 .await
7168 .unwrap();
7169 assert_eq!(resp.status(), StatusCode::OK);
7171 }
7172
7173 #[tokio::test]
7174 async fn list_memories_clamps_oversized_limit() {
7175 let state = test_state();
7176 let app = Router::new()
7177 .route("/api/v1/memories", axum::routing::get(list_memories))
7178 .with_state(test_app_state(state));
7179
7180 let resp = app
7181 .oneshot(
7182 axum::http::Request::builder()
7183 .uri("/api/v1/memories?limit=10000") .method("GET")
7185 .body(Body::empty())
7186 .unwrap(),
7187 )
7188 .await
7189 .unwrap();
7190 assert_eq!(resp.status(), StatusCode::OK);
7192 }
7193
7194 #[tokio::test]
7195 async fn search_memories_handles_negative_limit() {
7196 let state = test_state();
7197 let app = Router::new()
7198 .route(
7199 "/api/v1/memories/search",
7200 axum::routing::get(search_memories),
7201 )
7202 .with_state(test_app_state(state));
7203
7204 let resp = app
7205 .oneshot(
7206 axum::http::Request::builder()
7207 .uri("/api/v1/memories/search?query=test&limit=-1")
7208 .method("GET")
7209 .body(Body::empty())
7210 .unwrap(),
7211 )
7212 .await
7213 .unwrap();
7214 assert!(resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST);
7216 }
7217
7218 #[tokio::test]
7221 async fn api_key_missing_when_required_rejects() {
7222 let app = auth_app(Some("secret123"));
7223 let resp = app
7224 .oneshot(
7225 axum::http::Request::builder()
7226 .uri("/api/v1/memories")
7227 .method("GET")
7228 .body(Body::empty())
7230 .unwrap(),
7231 )
7232 .await
7233 .unwrap();
7234 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7235 }
7236
7237 #[tokio::test]
7238 async fn api_key_wrong_value_rejects() {
7239 let app = auth_app(Some("secret123"));
7240 let resp = app
7241 .oneshot(
7242 axum::http::Request::builder()
7243 .uri("/api/v1/memories")
7244 .method("GET")
7245 .header("x-api-key", "wrong_secret")
7246 .body(Body::empty())
7247 .unwrap(),
7248 )
7249 .await
7250 .unwrap();
7251 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
7252 }
7253
7254 async fn insert_test_memory(state: &Db, namespace: &str, title: &str) -> String {
7263 let lock = state.lock().await;
7264 let now = Utc::now().to_rfc3339();
7265 let mem = Memory {
7266 id: Uuid::new_v4().to_string(),
7267 tier: Tier::Long,
7268 namespace: namespace.into(),
7269 title: title.into(),
7270 content: format!("content for {title}"),
7271 tags: vec![],
7272 priority: 5,
7273 confidence: 1.0,
7274 source: "test".into(),
7275 access_count: 0,
7276 created_at: now.clone(),
7277 updated_at: now,
7278 last_accessed_at: None,
7279 expires_at: None,
7280 metadata: serde_json::json!({}),
7281 };
7282 db::insert(&lock.0, &mem).unwrap()
7283 }
7284
7285 #[tokio::test]
7288 async fn http_list_archive_rejects_limit_zero() {
7289 let state = test_state();
7290 let app = Router::new()
7291 .route("/api/v1/archive", axum::routing::get(list_archive))
7292 .with_state(state);
7293 let resp = app
7294 .oneshot(
7295 axum::http::Request::builder()
7296 .uri("/api/v1/archive?limit=0")
7297 .body(Body::empty())
7298 .unwrap(),
7299 )
7300 .await
7301 .unwrap();
7302 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7303 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7304 .await
7305 .unwrap();
7306 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7307 assert!(v["error"].as_str().unwrap().contains("limit"));
7308 }
7309
7310 #[tokio::test]
7311 async fn http_list_archive_clamps_oversized_limit() {
7312 let state = test_state();
7313 let app = Router::new()
7314 .route("/api/v1/archive", axum::routing::get(list_archive))
7315 .with_state(state);
7316 let resp = app
7317 .oneshot(
7318 axum::http::Request::builder()
7319 .uri("/api/v1/archive?limit=99999")
7320 .body(Body::empty())
7321 .unwrap(),
7322 )
7323 .await
7324 .unwrap();
7325 assert_eq!(resp.status(), StatusCode::OK);
7326 }
7327
7328 #[tokio::test]
7329 async fn http_list_archive_filters_by_namespace() {
7330 let state = test_state();
7331 let id = insert_test_memory(&state, "arch-ns-a", "to-archive").await;
7333 {
7334 let lock = state.lock().await;
7335 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7336 }
7337 let app = Router::new()
7338 .route("/api/v1/archive", axum::routing::get(list_archive))
7339 .with_state(state);
7340 let resp = app
7341 .oneshot(
7342 axum::http::Request::builder()
7343 .uri("/api/v1/archive?namespace=arch-ns-a&limit=10")
7344 .body(Body::empty())
7345 .unwrap(),
7346 )
7347 .await
7348 .unwrap();
7349 assert_eq!(resp.status(), StatusCode::OK);
7350 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7351 .await
7352 .unwrap();
7353 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7354 assert_eq!(v["count"], 1);
7355 }
7356
7357 #[tokio::test]
7358 async fn http_restore_archive_404_for_unknown_id() {
7359 let state = test_state();
7360 let app = Router::new()
7361 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7362 .with_state(test_app_state(state));
7363 let resp = app
7364 .oneshot(
7365 axum::http::Request::builder()
7366 .uri("/api/v1/archive/00000000-0000-0000-0000-000000000000/restore")
7367 .method("POST")
7368 .body(Body::empty())
7369 .unwrap(),
7370 )
7371 .await
7372 .unwrap();
7373 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7374 }
7375
7376 #[tokio::test]
7377 async fn http_restore_archive_rejects_empty_id() {
7378 let state = test_state();
7383 let app = Router::new()
7384 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7385 .with_state(test_app_state(state));
7386 let resp = app
7387 .oneshot(
7388 axum::http::Request::builder()
7389 .uri("/api/v1/archive/%01/restore")
7390 .method("POST")
7391 .body(Body::empty())
7392 .unwrap(),
7393 )
7394 .await
7395 .unwrap();
7396 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7397 }
7398
7399 #[tokio::test]
7400 async fn http_restore_archive_double_restore_returns_404() {
7401 let state = test_state();
7404 let id = insert_test_memory(&state, "restore-twice", "row").await;
7405 {
7406 let lock = state.lock().await;
7407 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7408 }
7409 let app = Router::new()
7410 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
7411 .with_state(test_app_state(state.clone()));
7412
7413 let resp = app
7415 .clone()
7416 .oneshot(
7417 axum::http::Request::builder()
7418 .uri(format!("/api/v1/archive/{id}/restore"))
7419 .method("POST")
7420 .body(Body::empty())
7421 .unwrap(),
7422 )
7423 .await
7424 .unwrap();
7425 assert_eq!(resp.status(), StatusCode::OK);
7426
7427 let resp = app
7429 .oneshot(
7430 axum::http::Request::builder()
7431 .uri(format!("/api/v1/archive/{id}/restore"))
7432 .method("POST")
7433 .body(Body::empty())
7434 .unwrap(),
7435 )
7436 .await
7437 .unwrap();
7438 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7439 }
7440
7441 #[tokio::test]
7442 async fn http_purge_archive_zero_days_purges_all() {
7443 let state = test_state();
7446 let id = insert_test_memory(&state, "purge-zero", "x").await;
7447 {
7448 let lock = state.lock().await;
7449 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7450 }
7451 let app = Router::new()
7452 .route("/api/v1/archive/purge", axum_post(purge_archive))
7453 .with_state(state.clone());
7454 let resp = app
7455 .oneshot(
7456 axum::http::Request::builder()
7457 .uri("/api/v1/archive/purge?older_than_days=0")
7458 .method("POST")
7459 .body(Body::empty())
7460 .unwrap(),
7461 )
7462 .await
7463 .unwrap();
7464 assert_eq!(resp.status(), StatusCode::OK);
7465 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7466 .await
7467 .unwrap();
7468 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7469 assert!(v["purged"].as_u64().is_some());
7473 }
7474
7475 #[tokio::test]
7476 async fn http_purge_archive_negative_days_returns_500() {
7477 let state = test_state();
7479 let app = Router::new()
7480 .route("/api/v1/archive/purge", axum_post(purge_archive))
7481 .with_state(state);
7482 let resp = app
7483 .oneshot(
7484 axum::http::Request::builder()
7485 .uri("/api/v1/archive/purge?older_than_days=-1")
7486 .method("POST")
7487 .body(Body::empty())
7488 .unwrap(),
7489 )
7490 .await
7491 .unwrap();
7492 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
7493 }
7494
7495 #[tokio::test]
7496 async fn http_purge_archive_no_days_purges_unconditional() {
7497 let state = test_state();
7499 let id = insert_test_memory(&state, "purge-all", "x").await;
7500 {
7501 let lock = state.lock().await;
7502 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
7503 }
7504 let app = Router::new()
7505 .route("/api/v1/archive/purge", axum_post(purge_archive))
7506 .with_state(state.clone());
7507 let resp = app
7508 .oneshot(
7509 axum::http::Request::builder()
7510 .uri("/api/v1/archive/purge")
7511 .method("POST")
7512 .body(Body::empty())
7513 .unwrap(),
7514 )
7515 .await
7516 .unwrap();
7517 assert_eq!(resp.status(), StatusCode::OK);
7518 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7519 .await
7520 .unwrap();
7521 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7522 assert_eq!(v["purged"], 1);
7523 }
7524
7525 #[tokio::test]
7526 async fn http_archive_stats_reports_per_namespace_counts() {
7527 let state = test_state();
7528 let id_a = insert_test_memory(&state, "stats-a", "a").await;
7529 let id_b = insert_test_memory(&state, "stats-b", "b").await;
7530 {
7531 let lock = state.lock().await;
7532 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
7533 db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
7534 }
7535 let app = Router::new()
7536 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
7537 .with_state(state);
7538 let resp = app
7539 .oneshot(
7540 axum::http::Request::builder()
7541 .uri("/api/v1/archive/stats")
7542 .body(Body::empty())
7543 .unwrap(),
7544 )
7545 .await
7546 .unwrap();
7547 assert_eq!(resp.status(), StatusCode::OK);
7548 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7549 .await
7550 .unwrap();
7551 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7552 assert_eq!(v["archived_total"], 2);
7553 assert_eq!(v["by_namespace"].as_array().unwrap().len(), 2);
7554 }
7555
7556 #[tokio::test]
7557 async fn http_archive_by_ids_rejects_oversized_batch() {
7558 let state = test_state();
7560 let app = Router::new()
7561 .route("/api/v1/archive", axum_post(archive_by_ids))
7562 .with_state(test_app_state(state));
7563 let big_ids: Vec<String> = (0..=MAX_BULK_SIZE)
7564 .map(|_| Uuid::new_v4().to_string())
7565 .collect();
7566 let body = serde_json::json!({"ids": big_ids});
7567 let resp = app
7568 .oneshot(
7569 axum::http::Request::builder()
7570 .uri("/api/v1/archive")
7571 .method("POST")
7572 .header("content-type", "application/json")
7573 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7574 .unwrap(),
7575 )
7576 .await
7577 .unwrap();
7578 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7579 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7580 .await
7581 .unwrap();
7582 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7583 assert!(v["error"].as_str().unwrap().contains("archive limited"));
7584 }
7585
7586 #[tokio::test]
7587 async fn http_archive_by_ids_rejects_invalid_id_in_batch() {
7588 let state = test_state();
7589 let app = Router::new()
7590 .route("/api/v1/archive", axum_post(archive_by_ids))
7591 .with_state(test_app_state(state));
7592 let body = serde_json::json!({"ids": [" "]});
7594 let resp = app
7595 .oneshot(
7596 axum::http::Request::builder()
7597 .uri("/api/v1/archive")
7598 .method("POST")
7599 .header("content-type", "application/json")
7600 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7601 .unwrap(),
7602 )
7603 .await
7604 .unwrap();
7605 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7606 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7607 .await
7608 .unwrap();
7609 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7610 assert!(v["error"].as_str().unwrap().contains("invalid id"));
7611 }
7612
7613 #[tokio::test]
7614 async fn http_archive_by_ids_all_missing() {
7615 let state = test_state();
7619 let app = Router::new()
7620 .route("/api/v1/archive", axum_post(archive_by_ids))
7621 .with_state(test_app_state(state));
7622 let ids: Vec<String> = (0..3).map(|_| Uuid::new_v4().to_string()).collect();
7623 let body = serde_json::json!({"ids": ids});
7624 let resp = app
7625 .oneshot(
7626 axum::http::Request::builder()
7627 .uri("/api/v1/archive")
7628 .method("POST")
7629 .header("content-type", "application/json")
7630 .body(Body::from(serde_json::to_vec(&body).unwrap()))
7631 .unwrap(),
7632 )
7633 .await
7634 .unwrap();
7635 assert_eq!(resp.status(), StatusCode::OK);
7636 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7637 .await
7638 .unwrap();
7639 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7640 assert_eq!(v["count"], 0);
7641 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
7642 assert_eq!(v["missing"].as_array().unwrap().len(), 3);
7643 }
7644
7645 #[tokio::test]
7648 async fn http_bulk_create_oversized_batch_rejected() {
7649 let state = test_state();
7650 let app = Router::new()
7651 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7652 .with_state(test_app_state(state));
7653 let bodies: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
7654 .map(|i| {
7655 serde_json::json!({
7656 "tier": "long",
7657 "namespace": "bulk-overflow",
7658 "title": format!("t-{i}"),
7659 "content": "c",
7660 "tags": [],
7661 "priority": 5,
7662 "confidence": 1.0,
7663 "source": "api",
7664 "metadata": {}
7665 })
7666 })
7667 .collect();
7668 let resp = app
7669 .oneshot(
7670 axum::http::Request::builder()
7671 .uri("/api/v1/memories/bulk")
7672 .method("POST")
7673 .header("content-type", "application/json")
7674 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7675 .unwrap(),
7676 )
7677 .await
7678 .unwrap();
7679 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7680 }
7681
7682 #[tokio::test]
7683 async fn http_bulk_create_partial_success_collects_errors() {
7684 let state = test_state();
7688 let app = Router::new()
7689 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7690 .with_state(test_app_state(state.clone()));
7691 let bodies = serde_json::json!([
7692 {
7693 "tier": "long",
7694 "namespace": "bulk-mixed",
7695 "title": "good row",
7696 "content": "ok",
7697 "tags": [],
7698 "priority": 5,
7699 "confidence": 1.0,
7700 "source": "api",
7701 "metadata": {}
7702 },
7703 {
7704 "tier": "long",
7705 "namespace": "bulk-mixed",
7706 "title": "",
7707 "content": "bad: empty title",
7708 "tags": [],
7709 "priority": 5,
7710 "confidence": 1.0,
7711 "source": "api",
7712 "metadata": {}
7713 }
7714 ]);
7715 let resp = app
7716 .oneshot(
7717 axum::http::Request::builder()
7718 .uri("/api/v1/memories/bulk")
7719 .method("POST")
7720 .header("content-type", "application/json")
7721 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7722 .unwrap(),
7723 )
7724 .await
7725 .unwrap();
7726 assert_eq!(resp.status(), StatusCode::OK);
7727 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7728 .await
7729 .unwrap();
7730 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7731 assert_eq!(v["created"], 1);
7732 assert_eq!(v["errors"].as_array().unwrap().len(), 1);
7733
7734 let lock = state.lock().await;
7736 let rows = db::list(
7737 &lock.0,
7738 Some("bulk-mixed"),
7739 None,
7740 10,
7741 0,
7742 None,
7743 None,
7744 None,
7745 None,
7746 None,
7747 )
7748 .unwrap();
7749 assert_eq!(rows.len(), 1);
7750 assert_eq!(rows[0].title, "good row");
7751 }
7752
7753 #[tokio::test]
7754 async fn http_bulk_create_empty_body_succeeds_with_zero_created() {
7755 let state = test_state();
7756 let app = Router::new()
7757 .route("/api/v1/memories/bulk", axum_post(bulk_create))
7758 .with_state(test_app_state(state));
7759 let bodies: Vec<serde_json::Value> = vec![];
7760 let resp = app
7761 .oneshot(
7762 axum::http::Request::builder()
7763 .uri("/api/v1/memories/bulk")
7764 .method("POST")
7765 .header("content-type", "application/json")
7766 .body(Body::from(serde_json::to_vec(&bodies).unwrap()))
7767 .unwrap(),
7768 )
7769 .await
7770 .unwrap();
7771 assert_eq!(resp.status(), StatusCode::OK);
7772 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7773 .await
7774 .unwrap();
7775 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7776 assert_eq!(v["created"], 0);
7777 assert!(v["errors"].as_array().unwrap().is_empty());
7778 }
7779
7780 #[tokio::test]
7783 async fn http_list_pending_empty_returns_zero_count() {
7784 let state = test_state();
7785 let app = Router::new()
7786 .route("/api/v1/pending", axum::routing::get(list_pending))
7787 .with_state(state);
7788 let resp = app
7789 .oneshot(
7790 axum::http::Request::builder()
7791 .uri("/api/v1/pending")
7792 .body(Body::empty())
7793 .unwrap(),
7794 )
7795 .await
7796 .unwrap();
7797 assert_eq!(resp.status(), StatusCode::OK);
7798 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7799 .await
7800 .unwrap();
7801 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7802 assert_eq!(v["count"], 0);
7803 }
7804
7805 #[tokio::test]
7806 async fn http_list_pending_with_status_filter() {
7807 let state = test_state();
7808 let app = Router::new()
7809 .route("/api/v1/pending", axum::routing::get(list_pending))
7810 .with_state(state);
7811 let resp = app
7813 .oneshot(
7814 axum::http::Request::builder()
7815 .uri("/api/v1/pending?status=approved&limit=5")
7816 .body(Body::empty())
7817 .unwrap(),
7818 )
7819 .await
7820 .unwrap();
7821 assert_eq!(resp.status(), StatusCode::OK);
7822 }
7823
7824 #[tokio::test]
7825 async fn http_approve_pending_unknown_id_returns_403_or_500() {
7826 let state = test_state();
7831 let app = Router::new()
7832 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7833 .with_state(test_app_state(state));
7834 let unknown = Uuid::new_v4().to_string();
7835 let resp = app
7836 .oneshot(
7837 axum::http::Request::builder()
7838 .uri(format!("/api/v1/pending/{unknown}/approve"))
7839 .method("POST")
7840 .header("x-agent-id", "alice")
7841 .body(Body::empty())
7842 .unwrap(),
7843 )
7844 .await
7845 .unwrap();
7846 assert!(
7847 resp.status() == StatusCode::FORBIDDEN
7848 || resp.status() == StatusCode::INTERNAL_SERVER_ERROR
7849 || resp.status() == StatusCode::ACCEPTED,
7850 "unexpected status {}",
7851 resp.status()
7852 );
7853 }
7854
7855 #[tokio::test]
7856 async fn http_approve_pending_rejects_invalid_agent_id() {
7857 let state = test_state();
7860 let app = Router::new()
7861 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
7862 .with_state(test_app_state(state));
7863 let id = Uuid::new_v4().to_string();
7864 let resp = app
7865 .oneshot(
7866 axum::http::Request::builder()
7867 .uri(format!("/api/v1/pending/{id}/approve"))
7868 .method("POST")
7869 .header("x-agent-id", "bad agent")
7870 .body(Body::empty())
7871 .unwrap(),
7872 )
7873 .await
7874 .unwrap();
7875 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7876 }
7877
7878 #[tokio::test]
7879 async fn http_reject_pending_unknown_id_returns_404() {
7880 let state = test_state();
7881 let app = Router::new()
7882 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7883 .with_state(test_app_state(state));
7884 let unknown = Uuid::new_v4().to_string();
7885 let resp = app
7886 .oneshot(
7887 axum::http::Request::builder()
7888 .uri(format!("/api/v1/pending/{unknown}/reject"))
7889 .method("POST")
7890 .header("x-agent-id", "alice")
7891 .body(Body::empty())
7892 .unwrap(),
7893 )
7894 .await
7895 .unwrap();
7896 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7897 }
7898
7899 #[tokio::test]
7900 async fn http_reject_pending_rejects_invalid_agent_id() {
7901 let state = test_state();
7902 let app = Router::new()
7903 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
7904 .with_state(test_app_state(state));
7905 let id = Uuid::new_v4().to_string();
7906 let resp = app
7907 .oneshot(
7908 axum::http::Request::builder()
7909 .uri(format!("/api/v1/pending/{id}/reject"))
7910 .method("POST")
7911 .header("x-agent-id", "bad agent")
7912 .body(Body::empty())
7913 .unwrap(),
7914 )
7915 .await
7916 .unwrap();
7917 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7918 }
7919
7920 #[tokio::test]
7923 async fn http_search_rejects_blank_query() {
7924 let state = test_state();
7925 let app = Router::new()
7926 .route(
7927 "/api/v1/memories/search",
7928 axum::routing::get(search_memories),
7929 )
7930 .with_state(state);
7931 let resp = app
7932 .oneshot(
7933 axum::http::Request::builder()
7934 .uri("/api/v1/memories/search?q=%20%20%20") .body(Body::empty())
7936 .unwrap(),
7937 )
7938 .await
7939 .unwrap();
7940 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7941 }
7942
7943 #[tokio::test]
7944 async fn http_search_long_query_succeeds() {
7945 let state = test_state();
7948 let app = Router::new()
7949 .route(
7950 "/api/v1/memories/search",
7951 axum::routing::get(search_memories),
7952 )
7953 .with_state(state);
7954 let q = "a".repeat(2_000);
7955 let resp = app
7956 .oneshot(
7957 axum::http::Request::builder()
7958 .uri(format!("/api/v1/memories/search?q={q}"))
7959 .body(Body::empty())
7960 .unwrap(),
7961 )
7962 .await
7963 .unwrap();
7964 assert!(
7965 resp.status() == StatusCode::OK
7966 || resp.status() == StatusCode::BAD_REQUEST
7967 || resp.status() == StatusCode::INTERNAL_SERVER_ERROR,
7968 "unexpected status {}",
7969 resp.status()
7970 );
7971 }
7972
7973 #[tokio::test]
7974 async fn http_search_normal_query_returns_results_array() {
7975 let state = test_state();
7978 let app = Router::new()
7979 .route(
7980 "/api/v1/memories/search",
7981 axum::routing::get(search_memories),
7982 )
7983 .with_state(state);
7984 let resp = app
7985 .oneshot(
7986 axum::http::Request::builder()
7987 .uri("/api/v1/memories/search?q=hello")
7988 .body(Body::empty())
7989 .unwrap(),
7990 )
7991 .await
7992 .unwrap();
7993 assert_eq!(resp.status(), StatusCode::OK);
7994 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
7995 .await
7996 .unwrap();
7997 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
7998 assert!(v["results"].is_array());
7999 assert_eq!(v["query"], "hello");
8000 }
8001
8002 #[tokio::test]
8003 async fn http_search_invalid_agent_id_filter_rejected() {
8004 let state = test_state();
8005 let app = Router::new()
8006 .route(
8007 "/api/v1/memories/search",
8008 axum::routing::get(search_memories),
8009 )
8010 .with_state(state);
8011 let resp = app
8013 .oneshot(
8014 axum::http::Request::builder()
8015 .uri("/api/v1/memories/search?q=test&agent_id=bad%20agent")
8016 .body(Body::empty())
8017 .unwrap(),
8018 )
8019 .await
8020 .unwrap();
8021 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8022 }
8023
8024 #[tokio::test]
8027 async fn http_recall_get_rejects_blank_context() {
8028 let state = test_state();
8029 let app = Router::new()
8030 .route(
8031 "/api/v1/memories/recall",
8032 axum::routing::get(recall_memories_get),
8033 )
8034 .with_state(test_app_state(state));
8035 let resp = app
8036 .oneshot(
8037 axum::http::Request::builder()
8038 .uri("/api/v1/memories/recall?context=%20")
8039 .body(Body::empty())
8040 .unwrap(),
8041 )
8042 .await
8043 .unwrap();
8044 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8045 }
8046
8047 #[tokio::test]
8048 async fn http_recall_get_zero_budget_tokens_returns_empty() {
8049 let state = test_state();
8053 let app = Router::new()
8054 .route(
8055 "/api/v1/memories/recall",
8056 axum::routing::get(recall_memories_get),
8057 )
8058 .with_state(test_app_state(state));
8059 let resp = app
8060 .oneshot(
8061 axum::http::Request::builder()
8062 .uri("/api/v1/memories/recall?context=hi&budget_tokens=0")
8063 .body(Body::empty())
8064 .unwrap(),
8065 )
8066 .await
8067 .unwrap();
8068 assert_eq!(resp.status(), StatusCode::OK);
8069 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8070 .await
8071 .unwrap();
8072 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8073 assert_eq!(v["count"], 0);
8074 assert_eq!(v["budget_tokens"], 0);
8075 assert_eq!(v["meta"]["budget_overflow"], false);
8076 }
8077
8078 #[tokio::test]
8079 async fn http_recall_post_rejects_blank_context() {
8080 let state = test_state();
8081 let app = Router::new()
8082 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
8083 .with_state(test_app_state(state));
8084 let body = serde_json::json!({"context": " "});
8085 let resp = app
8086 .oneshot(
8087 axum::http::Request::builder()
8088 .uri("/api/v1/memories/recall")
8089 .method("POST")
8090 .header("content-type", "application/json")
8091 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8092 .unwrap(),
8093 )
8094 .await
8095 .unwrap();
8096 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8097 }
8098
8099 #[tokio::test]
8100 async fn http_recall_post_keyword_mode_returns_mode_field() {
8101 let state = test_state();
8104 let _id = insert_test_memory(&state, "recall-mode", "the title").await;
8105 let app = Router::new()
8106 .route("/api/v1/memories/recall", axum_post(recall_memories_post))
8107 .with_state(test_app_state(state));
8108 let body = serde_json::json!({"context": "title", "namespace": "recall-mode"});
8109 let resp = app
8110 .oneshot(
8111 axum::http::Request::builder()
8112 .uri("/api/v1/memories/recall")
8113 .method("POST")
8114 .header("content-type", "application/json")
8115 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8116 .unwrap(),
8117 )
8118 .await
8119 .unwrap();
8120 assert_eq!(resp.status(), StatusCode::OK);
8121 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8122 .await
8123 .unwrap();
8124 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8125 assert_eq!(v["mode"], "keyword");
8126 }
8127
8128 #[tokio::test]
8131 async fn http_sync_since_empty_db_returns_zero_count() {
8132 let state = test_state();
8133 let app = Router::new()
8134 .route("/api/v1/sync/since", axum::routing::get(sync_since))
8135 .with_state(state);
8136 let resp = app
8137 .oneshot(
8138 axum::http::Request::builder()
8139 .uri("/api/v1/sync/since?since=2000-01-01T00:00:00Z&limit=10")
8140 .body(Body::empty())
8141 .unwrap(),
8142 )
8143 .await
8144 .unwrap();
8145 assert_eq!(resp.status(), StatusCode::OK);
8146 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8147 .await
8148 .unwrap();
8149 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8150 assert_eq!(v["count"], 0);
8151 assert!(v["earliest_updated_at"].is_null());
8152 assert!(v["latest_updated_at"].is_null());
8153 }
8154
8155 #[tokio::test]
8156 async fn http_sync_since_clamps_oversized_limit() {
8157 let state = test_state();
8158 let app = Router::new()
8159 .route("/api/v1/sync/since", axum::routing::get(sync_since))
8160 .with_state(state);
8161 let resp = app
8162 .oneshot(
8163 axum::http::Request::builder()
8164 .uri("/api/v1/sync/since?limit=999999")
8165 .body(Body::empty())
8166 .unwrap(),
8167 )
8168 .await
8169 .unwrap();
8170 assert_eq!(resp.status(), StatusCode::OK);
8171 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8172 .await
8173 .unwrap();
8174 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8175 assert!(v["limit"].as_u64().unwrap() <= 10_000);
8177 }
8178
8179 #[tokio::test]
8180 async fn http_sync_since_empty_since_string_treated_as_full_snapshot() {
8181 let state = test_state();
8184 let _id = insert_test_memory(&state, "sync-empty", "row").await;
8185 let app = Router::new()
8186 .route("/api/v1/sync/since", axum::routing::get(sync_since))
8187 .with_state(state);
8188 let resp = app
8189 .oneshot(
8190 axum::http::Request::builder()
8191 .uri("/api/v1/sync/since?since=")
8192 .body(Body::empty())
8193 .unwrap(),
8194 )
8195 .await
8196 .unwrap();
8197 assert_eq!(resp.status(), StatusCode::OK);
8198 }
8199
8200 #[tokio::test]
8201 async fn http_sync_since_records_peer_via_observe() {
8202 let state = test_state();
8205 let _id = insert_test_memory(&state, "sync-peer", "row").await;
8206 let app = Router::new()
8207 .route("/api/v1/sync/since", axum::routing::get(sync_since))
8208 .with_state(state.clone());
8209 let resp = app
8210 .oneshot(
8211 axum::http::Request::builder()
8212 .uri("/api/v1/sync/since?peer=peer-x")
8213 .header("x-agent-id", "alice")
8214 .body(Body::empty())
8215 .unwrap(),
8216 )
8217 .await
8218 .unwrap();
8219 assert_eq!(resp.status(), StatusCode::OK);
8220 }
8221
8222 #[tokio::test]
8225 async fn http_capabilities_returns_features() {
8226 let state = test_state();
8227 let app = Router::new()
8228 .route("/api/v1/capabilities", axum::routing::get(get_capabilities))
8229 .with_state(test_app_state(state));
8230 let resp = app
8231 .oneshot(
8232 axum::http::Request::builder()
8233 .uri("/api/v1/capabilities")
8234 .body(Body::empty())
8235 .unwrap(),
8236 )
8237 .await
8238 .unwrap();
8239 assert_eq!(resp.status(), StatusCode::OK);
8240 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8241 .await
8242 .unwrap();
8243 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8244 assert_eq!(v["features"]["embedder_loaded"], false);
8247 }
8248
8249 #[tokio::test]
8250 async fn http_session_start_rejects_invalid_agent_id() {
8251 let state = test_state();
8252 let app = Router::new()
8253 .route("/api/v1/session/start", axum_post(session_start))
8254 .with_state(state);
8255 let body = serde_json::json!({"agent_id": "bad agent id with spaces"});
8256 let resp = app
8257 .oneshot(
8258 axum::http::Request::builder()
8259 .uri("/api/v1/session/start")
8260 .method("POST")
8261 .header("content-type", "application/json")
8262 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8263 .unwrap(),
8264 )
8265 .await
8266 .unwrap();
8267 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8268 }
8269
8270 #[tokio::test]
8271 async fn http_session_start_stamps_session_id() {
8272 let state = test_state();
8273 let app = Router::new()
8274 .route("/api/v1/session/start", axum_post(session_start))
8275 .with_state(state);
8276 let body = serde_json::json!({});
8277 let resp = app
8278 .oneshot(
8279 axum::http::Request::builder()
8280 .uri("/api/v1/session/start")
8281 .method("POST")
8282 .header("content-type", "application/json")
8283 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8284 .unwrap(),
8285 )
8286 .await
8287 .unwrap();
8288 assert_eq!(resp.status(), StatusCode::OK);
8289 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8290 .await
8291 .unwrap();
8292 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8293 assert!(v["session_id"].as_str().is_some());
8294 }
8295
8296 #[tokio::test]
8297 async fn http_get_taxonomy_rejects_invalid_prefix() {
8298 let state = test_state();
8301 let app = Router::new()
8302 .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8303 .with_state(state);
8304 let resp = app
8305 .oneshot(
8306 axum::http::Request::builder()
8307 .uri("/api/v1/taxonomy?prefix=bad%20prefix")
8308 .body(Body::empty())
8309 .unwrap(),
8310 )
8311 .await
8312 .unwrap();
8313 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8314 }
8315
8316 #[tokio::test]
8317 async fn http_get_taxonomy_clamps_depth_and_limit() {
8318 let state = test_state();
8319 let app = Router::new()
8320 .route("/api/v1/taxonomy", axum::routing::get(get_taxonomy))
8321 .with_state(state);
8322 let resp = app
8323 .oneshot(
8324 axum::http::Request::builder()
8325 .uri("/api/v1/taxonomy?depth=1000&limit=999999")
8326 .body(Body::empty())
8327 .unwrap(),
8328 )
8329 .await
8330 .unwrap();
8331 assert_eq!(resp.status(), StatusCode::OK);
8332 }
8333
8334 #[tokio::test]
8337 async fn http_list_subscriptions_empty_returns_zero() {
8338 let state = test_state();
8339 let app = Router::new()
8340 .route(
8341 "/api/v1/subscriptions",
8342 axum::routing::get(list_subscriptions),
8343 )
8344 .with_state(state);
8345 let resp = app
8346 .oneshot(
8347 axum::http::Request::builder()
8348 .uri("/api/v1/subscriptions")
8349 .body(Body::empty())
8350 .unwrap(),
8351 )
8352 .await
8353 .unwrap();
8354 assert_eq!(resp.status(), StatusCode::OK);
8355 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
8356 .await
8357 .unwrap();
8358 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8359 assert_eq!(v["count"], 0);
8360 assert!(v["subscriptions"].as_array().unwrap().is_empty());
8361 }
8362
8363 #[tokio::test]
8364 async fn http_list_subscriptions_filters_by_agent_id() {
8365 let state = test_state();
8368 let app = Router::new()
8369 .route(
8370 "/api/v1/subscriptions",
8371 axum::routing::get(list_subscriptions),
8372 )
8373 .with_state(state);
8374 let resp = app
8375 .oneshot(
8376 axum::http::Request::builder()
8377 .uri("/api/v1/subscriptions?agent_id=alice")
8378 .body(Body::empty())
8379 .unwrap(),
8380 )
8381 .await
8382 .unwrap();
8383 assert_eq!(resp.status(), StatusCode::OK);
8384 }
8385
8386 #[tokio::test]
8389 async fn http_get_inbox_with_x_agent_id_header() {
8390 let state = test_state();
8391 let app = Router::new()
8392 .route("/api/v1/inbox", axum::routing::get(get_inbox))
8393 .with_state(test_app_state(state));
8394 let resp = app
8395 .oneshot(
8396 axum::http::Request::builder()
8397 .uri("/api/v1/inbox?unread_only=true&limit=20")
8398 .header("x-agent-id", "alice")
8399 .body(Body::empty())
8400 .unwrap(),
8401 )
8402 .await
8403 .unwrap();
8404 assert_eq!(resp.status(), StatusCode::OK);
8405 }
8406
8407 #[tokio::test]
8420 async fn http_check_duplicate_rejects_invalid_title() {
8421 let state = test_state();
8422 let app = Router::new()
8423 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8424 .with_state(test_app_state(state));
8425 let body = serde_json::json!({"title": "", "content": "non-empty"});
8427 let resp = app
8428 .oneshot(
8429 axum::http::Request::builder()
8430 .uri("/api/v1/check_duplicate")
8431 .method("POST")
8432 .header("content-type", "application/json")
8433 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8434 .unwrap(),
8435 )
8436 .await
8437 .unwrap();
8438 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8439 }
8440
8441 #[tokio::test]
8442 async fn http_check_duplicate_rejects_invalid_content() {
8443 let state = test_state();
8444 let app = Router::new()
8445 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8446 .with_state(test_app_state(state));
8447 let body = serde_json::json!({"title": "ok", "content": ""});
8449 let resp = app
8450 .oneshot(
8451 axum::http::Request::builder()
8452 .uri("/api/v1/check_duplicate")
8453 .method("POST")
8454 .header("content-type", "application/json")
8455 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8456 .unwrap(),
8457 )
8458 .await
8459 .unwrap();
8460 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8461 }
8462
8463 #[tokio::test]
8464 async fn http_check_duplicate_rejects_invalid_namespace() {
8465 let state = test_state();
8466 let app = Router::new()
8467 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8468 .with_state(test_app_state(state));
8469 let body = serde_json::json!({
8471 "title": "ok",
8472 "content": "ok content",
8473 "namespace": "BAD NAMESPACE WITH SPACES",
8474 });
8475 let resp = app
8476 .oneshot(
8477 axum::http::Request::builder()
8478 .uri("/api/v1/check_duplicate")
8479 .method("POST")
8480 .header("content-type", "application/json")
8481 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8482 .unwrap(),
8483 )
8484 .await
8485 .unwrap();
8486 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8487 }
8488
8489 #[tokio::test]
8490 async fn http_check_duplicate_503_when_no_embedder() {
8491 let state = test_state();
8493 let app = Router::new()
8494 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
8495 .with_state(test_app_state(state));
8496 let body = serde_json::json!({"title": "anchor", "content": "some long enough content"});
8497 let resp = app
8498 .oneshot(
8499 axum::http::Request::builder()
8500 .uri("/api/v1/check_duplicate")
8501 .method("POST")
8502 .header("content-type", "application/json")
8503 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8504 .unwrap(),
8505 )
8506 .await
8507 .unwrap();
8508 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
8509 }
8510
8511 #[tokio::test]
8514 async fn http_entity_register_creates_then_idempotent_returns_200() {
8515 let state = test_state();
8516 let app = Router::new()
8517 .route("/api/v1/entities", axum_post(entity_register))
8518 .with_state(state.clone());
8519 let body = serde_json::json!({
8521 "canonical_name": "Acme Corp",
8522 "namespace": "kg-test",
8523 "aliases": ["acme", "Acme"],
8524 "metadata": {"region": "us"},
8525 });
8526 let resp = app
8527 .clone()
8528 .oneshot(
8529 axum::http::Request::builder()
8530 .uri("/api/v1/entities")
8531 .method("POST")
8532 .header("content-type", "application/json")
8533 .header("x-agent-id", "alice")
8534 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8535 .unwrap(),
8536 )
8537 .await
8538 .unwrap();
8539 assert_eq!(resp.status(), StatusCode::CREATED);
8540
8541 let resp2 = app
8543 .oneshot(
8544 axum::http::Request::builder()
8545 .uri("/api/v1/entities")
8546 .method("POST")
8547 .header("content-type", "application/json")
8548 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8549 .unwrap(),
8550 )
8551 .await
8552 .unwrap();
8553 assert_eq!(resp2.status(), StatusCode::OK);
8554 }
8555
8556 #[tokio::test]
8557 async fn http_entity_register_rejects_invalid_canonical_name() {
8558 let state = test_state();
8559 let app = Router::new()
8560 .route("/api/v1/entities", axum_post(entity_register))
8561 .with_state(state);
8562 let body = serde_json::json!({
8563 "canonical_name": "",
8564 "namespace": "kg-test",
8565 });
8566 let resp = app
8567 .oneshot(
8568 axum::http::Request::builder()
8569 .uri("/api/v1/entities")
8570 .method("POST")
8571 .header("content-type", "application/json")
8572 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8573 .unwrap(),
8574 )
8575 .await
8576 .unwrap();
8577 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8578 }
8579
8580 #[tokio::test]
8581 async fn http_entity_register_rejects_invalid_namespace() {
8582 let state = test_state();
8583 let app = Router::new()
8584 .route("/api/v1/entities", axum_post(entity_register))
8585 .with_state(state);
8586 let body = serde_json::json!({
8587 "canonical_name": "Acme",
8588 "namespace": "BAD NS!",
8589 });
8590 let resp = app
8591 .oneshot(
8592 axum::http::Request::builder()
8593 .uri("/api/v1/entities")
8594 .method("POST")
8595 .header("content-type", "application/json")
8596 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8597 .unwrap(),
8598 )
8599 .await
8600 .unwrap();
8601 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8602 }
8603
8604 #[tokio::test]
8605 async fn http_entity_register_rejects_invalid_agent_id_header() {
8606 let state = test_state();
8607 let app = Router::new()
8608 .route("/api/v1/entities", axum_post(entity_register))
8609 .with_state(state);
8610 let body = serde_json::json!({
8611 "canonical_name": "Acme",
8612 "namespace": "kg-test",
8613 });
8614 let resp = app
8615 .oneshot(
8616 axum::http::Request::builder()
8617 .uri("/api/v1/entities")
8618 .method("POST")
8619 .header("content-type", "application/json")
8620 .header("x-agent-id", "BAD AGENT!")
8621 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8622 .unwrap(),
8623 )
8624 .await
8625 .unwrap();
8626 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8627 }
8628
8629 #[tokio::test]
8630 async fn http_entity_register_collision_with_non_entity_returns_409() {
8631 let state = test_state();
8634 let now = Utc::now().to_rfc3339();
8635 {
8636 let lock = state.lock().await;
8637 let mem = Memory {
8638 id: Uuid::new_v4().to_string(),
8639 tier: Tier::Long,
8640 namespace: "collide-ns".into(),
8641 title: "Acme Squat".into(),
8642 content: "this is a regular memory".into(),
8643 tags: vec![],
8644 priority: 5,
8645 confidence: 1.0,
8646 source: "test".into(),
8647 access_count: 0,
8648 created_at: now.clone(),
8649 updated_at: now,
8650 last_accessed_at: None,
8651 expires_at: None,
8652 metadata: serde_json::json!({}),
8653 };
8654 db::insert(&lock.0, &mem).unwrap();
8655 }
8656 let app = Router::new()
8657 .route("/api/v1/entities", axum_post(entity_register))
8658 .with_state(state);
8659 let body = serde_json::json!({
8660 "canonical_name": "Acme Squat",
8661 "namespace": "collide-ns",
8662 });
8663 let resp = app
8664 .oneshot(
8665 axum::http::Request::builder()
8666 .uri("/api/v1/entities")
8667 .method("POST")
8668 .header("content-type", "application/json")
8669 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8670 .unwrap(),
8671 )
8672 .await
8673 .unwrap();
8674 assert_eq!(resp.status(), StatusCode::CONFLICT);
8675 }
8676
8677 #[tokio::test]
8678 async fn http_entity_get_by_alias_blank_alias_rejected() {
8679 let state = test_state();
8680 let app = Router::new()
8681 .route(
8682 "/api/v1/entities/by_alias",
8683 axum::routing::get(entity_get_by_alias),
8684 )
8685 .with_state(state);
8686 let resp = app
8687 .oneshot(
8688 axum::http::Request::builder()
8689 .uri("/api/v1/entities/by_alias?alias=%20%20")
8690 .body(Body::empty())
8691 .unwrap(),
8692 )
8693 .await
8694 .unwrap();
8695 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8696 }
8697
8698 #[tokio::test]
8699 async fn http_entity_get_by_alias_invalid_namespace_rejected() {
8700 let state = test_state();
8701 let app = Router::new()
8702 .route(
8703 "/api/v1/entities/by_alias",
8704 axum::routing::get(entity_get_by_alias),
8705 )
8706 .with_state(state);
8707 let resp = app
8708 .oneshot(
8709 axum::http::Request::builder()
8710 .uri("/api/v1/entities/by_alias?alias=acme&namespace=BAD%20NS!")
8711 .body(Body::empty())
8712 .unwrap(),
8713 )
8714 .await
8715 .unwrap();
8716 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8717 }
8718
8719 #[tokio::test]
8720 async fn http_entity_get_by_alias_returns_found_false_when_unknown() {
8721 let state = test_state();
8722 let app = Router::new()
8723 .route(
8724 "/api/v1/entities/by_alias",
8725 axum::routing::get(entity_get_by_alias),
8726 )
8727 .with_state(state);
8728 let resp = app
8729 .oneshot(
8730 axum::http::Request::builder()
8731 .uri("/api/v1/entities/by_alias?alias=nonexistent")
8732 .body(Body::empty())
8733 .unwrap(),
8734 )
8735 .await
8736 .unwrap();
8737 assert_eq!(resp.status(), StatusCode::OK);
8738 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8739 .await
8740 .unwrap();
8741 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8742 assert_eq!(v["found"], serde_json::json!(false));
8743 }
8744
8745 #[tokio::test]
8746 async fn http_entity_get_by_alias_returns_found_true_after_register() {
8747 let state = test_state();
8749 {
8750 let lock = state.lock().await;
8751 db::entity_register(
8752 &lock.0,
8753 "Acme Corp",
8754 "kg-lookup",
8755 &["acme".to_string(), "ACME".to_string()],
8756 &serde_json::json!({}),
8757 Some("alice"),
8758 )
8759 .unwrap();
8760 }
8761 let app = Router::new()
8762 .route(
8763 "/api/v1/entities/by_alias",
8764 axum::routing::get(entity_get_by_alias),
8765 )
8766 .with_state(state);
8767 let resp = app
8768 .oneshot(
8769 axum::http::Request::builder()
8770 .uri("/api/v1/entities/by_alias?alias=acme&namespace=kg-lookup")
8771 .body(Body::empty())
8772 .unwrap(),
8773 )
8774 .await
8775 .unwrap();
8776 assert_eq!(resp.status(), StatusCode::OK);
8777 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8778 .await
8779 .unwrap();
8780 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8781 assert_eq!(v["found"], serde_json::json!(true));
8782 assert_eq!(v["canonical_name"], serde_json::json!("Acme Corp"));
8783 }
8784
8785 #[tokio::test]
8788 async fn http_kg_timeline_rejects_invalid_source_id() {
8789 let state = test_state();
8790 let app = Router::new()
8791 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8792 .with_state(state);
8793 let resp = app
8795 .oneshot(
8796 axum::http::Request::builder()
8797 .uri("/api/v1/kg/timeline?source_id=")
8798 .body(Body::empty())
8799 .unwrap(),
8800 )
8801 .await
8802 .unwrap();
8803 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8804 }
8805
8806 #[tokio::test]
8807 async fn http_kg_timeline_rejects_invalid_since() {
8808 let state = test_state();
8809 let app = Router::new()
8810 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8811 .with_state(state);
8812 let id = Uuid::new_v4().to_string();
8813 let uri = format!("/api/v1/kg/timeline?source_id={id}&since=NOT-A-TIMESTAMP");
8814 let resp = app
8815 .oneshot(
8816 axum::http::Request::builder()
8817 .uri(&uri)
8818 .body(Body::empty())
8819 .unwrap(),
8820 )
8821 .await
8822 .unwrap();
8823 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8824 }
8825
8826 #[tokio::test]
8827 async fn http_kg_timeline_rejects_invalid_until() {
8828 let state = test_state();
8829 let app = Router::new()
8830 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8831 .with_state(state);
8832 let id = Uuid::new_v4().to_string();
8833 let uri = format!("/api/v1/kg/timeline?source_id={id}&until=garbage");
8834 let resp = app
8835 .oneshot(
8836 axum::http::Request::builder()
8837 .uri(&uri)
8838 .body(Body::empty())
8839 .unwrap(),
8840 )
8841 .await
8842 .unwrap();
8843 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8844 }
8845
8846 #[tokio::test]
8847 async fn http_kg_timeline_returns_empty_for_unlinked_source() {
8848 let state = test_state();
8850 let id = {
8851 let lock = state.lock().await;
8852 let now = Utc::now().to_rfc3339();
8853 let mem = Memory {
8854 id: Uuid::new_v4().to_string(),
8855 tier: Tier::Long,
8856 namespace: "kg-tl".into(),
8857 title: "anchor".into(),
8858 content: "anchor body".into(),
8859 tags: vec![],
8860 priority: 5,
8861 confidence: 1.0,
8862 source: "test".into(),
8863 access_count: 0,
8864 created_at: now.clone(),
8865 updated_at: now,
8866 last_accessed_at: None,
8867 expires_at: None,
8868 metadata: serde_json::json!({}),
8869 };
8870 db::insert(&lock.0, &mem).unwrap()
8871 };
8872 let app = Router::new()
8873 .route("/api/v1/kg/timeline", axum::routing::get(kg_timeline))
8874 .with_state(state);
8875 let uri = format!("/api/v1/kg/timeline?source_id={id}");
8876 let resp = app
8877 .oneshot(
8878 axum::http::Request::builder()
8879 .uri(&uri)
8880 .body(Body::empty())
8881 .unwrap(),
8882 )
8883 .await
8884 .unwrap();
8885 assert_eq!(resp.status(), StatusCode::OK);
8886 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
8887 .await
8888 .unwrap();
8889 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
8890 assert_eq!(v["count"], serde_json::json!(0));
8891 assert!(v["events"].is_array());
8892 }
8893
8894 #[tokio::test]
8897 async fn http_kg_invalidate_rejects_invalid_link() {
8898 let state = test_state();
8899 let app = Router::new()
8900 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8901 .with_state(state);
8902 let body = serde_json::json!({
8904 "source_id": "11111111-1111-4111-8111-111111111111",
8905 "target_id": "11111111-1111-4111-8111-111111111111",
8906 "relation": "related_to",
8907 });
8908 let resp = app
8909 .oneshot(
8910 axum::http::Request::builder()
8911 .uri("/api/v1/kg/invalidate")
8912 .method("POST")
8913 .header("content-type", "application/json")
8914 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8915 .unwrap(),
8916 )
8917 .await
8918 .unwrap();
8919 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8920 }
8921
8922 #[tokio::test]
8923 async fn http_kg_invalidate_rejects_invalid_valid_until() {
8924 let state = test_state();
8925 let app = Router::new()
8926 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8927 .with_state(state);
8928 let body = serde_json::json!({
8929 "source_id": "11111111-1111-4111-8111-111111111111",
8930 "target_id": "22222222-2222-4222-8222-222222222222",
8931 "relation": "related_to",
8932 "valid_until": "garbage",
8933 });
8934 let resp = app
8935 .oneshot(
8936 axum::http::Request::builder()
8937 .uri("/api/v1/kg/invalidate")
8938 .method("POST")
8939 .header("content-type", "application/json")
8940 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8941 .unwrap(),
8942 )
8943 .await
8944 .unwrap();
8945 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
8948 }
8949
8950 #[tokio::test]
8951 async fn http_kg_invalidate_404_when_link_missing() {
8952 let state = test_state();
8953 let app = Router::new()
8954 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
8955 .with_state(state);
8956 let body = serde_json::json!({
8957 "source_id": "11111111-1111-4111-8111-111111111111",
8958 "target_id": "22222222-2222-4222-8222-222222222222",
8959 "relation": "related_to",
8960 });
8961 let resp = app
8962 .oneshot(
8963 axum::http::Request::builder()
8964 .uri("/api/v1/kg/invalidate")
8965 .method("POST")
8966 .header("content-type", "application/json")
8967 .body(Body::from(serde_json::to_vec(&body).unwrap()))
8968 .unwrap(),
8969 )
8970 .await
8971 .unwrap();
8972 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8973 }
8974
8975 #[tokio::test]
8976 async fn http_kg_invalidate_marks_link_as_invalidated() {
8977 let state = test_state();
8979 let (a_id, b_id) = {
8980 let lock = state.lock().await;
8981 let now = Utc::now().to_rfc3339();
8982 let mk = |title: &str| Memory {
8983 id: Uuid::new_v4().to_string(),
8984 tier: Tier::Long,
8985 namespace: "kg-inv".into(),
8986 title: title.into(),
8987 content: format!("{title} body"),
8988 tags: vec![],
8989 priority: 5,
8990 confidence: 1.0,
8991 source: "test".into(),
8992 access_count: 0,
8993 created_at: now.clone(),
8994 updated_at: now.clone(),
8995 last_accessed_at: None,
8996 expires_at: None,
8997 metadata: serde_json::json!({}),
8998 };
8999 let a = db::insert(&lock.0, &mk("source-a")).unwrap();
9000 let b = db::insert(&lock.0, &mk("target-b")).unwrap();
9001 db::create_link(&lock.0, &a, &b, "related_to").unwrap();
9002 (a, b)
9003 };
9004 let app = Router::new()
9005 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
9006 .with_state(state);
9007 let body = serde_json::json!({
9008 "source_id": a_id,
9009 "target_id": b_id,
9010 "relation": "related_to",
9011 });
9012 let resp = app
9013 .oneshot(
9014 axum::http::Request::builder()
9015 .uri("/api/v1/kg/invalidate")
9016 .method("POST")
9017 .header("content-type", "application/json")
9018 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9019 .unwrap(),
9020 )
9021 .await
9022 .unwrap();
9023 assert_eq!(resp.status(), StatusCode::OK);
9024 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9025 .await
9026 .unwrap();
9027 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9028 assert_eq!(v["found"], serde_json::json!(true));
9029 }
9030
9031 #[tokio::test]
9034 async fn http_kg_query_rejects_invalid_source_id() {
9035 let state = test_state();
9036 let app = Router::new()
9037 .route("/api/v1/kg/query", axum_post(kg_query))
9038 .with_state(state);
9039 let body = serde_json::json!({"source_id": ""});
9041 let resp = app
9042 .oneshot(
9043 axum::http::Request::builder()
9044 .uri("/api/v1/kg/query")
9045 .method("POST")
9046 .header("content-type", "application/json")
9047 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9048 .unwrap(),
9049 )
9050 .await
9051 .unwrap();
9052 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9053 }
9054
9055 #[tokio::test]
9056 async fn http_kg_query_rejects_invalid_valid_at() {
9057 let state = test_state();
9058 let app = Router::new()
9059 .route("/api/v1/kg/query", axum_post(kg_query))
9060 .with_state(state);
9061 let body = serde_json::json!({
9062 "source_id": "11111111-1111-4111-8111-111111111111",
9063 "valid_at": "not-a-timestamp",
9064 });
9065 let resp = app
9066 .oneshot(
9067 axum::http::Request::builder()
9068 .uri("/api/v1/kg/query")
9069 .method("POST")
9070 .header("content-type", "application/json")
9071 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9072 .unwrap(),
9073 )
9074 .await
9075 .unwrap();
9076 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9077 }
9078
9079 #[tokio::test]
9080 async fn http_kg_query_rejects_invalid_allowed_agent() {
9081 let state = test_state();
9082 let app = Router::new()
9083 .route("/api/v1/kg/query", axum_post(kg_query))
9084 .with_state(state);
9085 let body = serde_json::json!({
9086 "source_id": "11111111-1111-4111-8111-111111111111",
9087 "allowed_agents": ["BAD AGENT!"],
9088 });
9089 let resp = app
9090 .oneshot(
9091 axum::http::Request::builder()
9092 .uri("/api/v1/kg/query")
9093 .method("POST")
9094 .header("content-type", "application/json")
9095 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9096 .unwrap(),
9097 )
9098 .await
9099 .unwrap();
9100 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9101 }
9102
9103 #[tokio::test]
9104 async fn http_kg_query_returns_422_for_oversized_max_depth() {
9105 let state = test_state();
9108 let app = Router::new()
9109 .route("/api/v1/kg/query", axum_post(kg_query))
9110 .with_state(state);
9111 let body = serde_json::json!({
9112 "source_id": "11111111-1111-4111-8111-111111111111",
9113 "max_depth": 999_usize,
9114 });
9115 let resp = app
9116 .oneshot(
9117 axum::http::Request::builder()
9118 .uri("/api/v1/kg/query")
9119 .method("POST")
9120 .header("content-type", "application/json")
9121 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9122 .unwrap(),
9123 )
9124 .await
9125 .unwrap();
9126 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
9127 }
9128
9129 #[tokio::test]
9130 async fn http_kg_query_returns_422_for_zero_max_depth() {
9131 let state = test_state();
9134 let app = Router::new()
9135 .route("/api/v1/kg/query", axum_post(kg_query))
9136 .with_state(state);
9137 let body = serde_json::json!({
9138 "source_id": "11111111-1111-4111-8111-111111111111",
9139 "max_depth": 0_usize,
9140 });
9141 let resp = app
9142 .oneshot(
9143 axum::http::Request::builder()
9144 .uri("/api/v1/kg/query")
9145 .method("POST")
9146 .header("content-type", "application/json")
9147 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9148 .unwrap(),
9149 )
9150 .await
9151 .unwrap();
9152 assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
9153 }
9154
9155 #[tokio::test]
9156 async fn http_kg_query_returns_empty_for_unlinked_source() {
9157 let state = test_state();
9159 let id = {
9160 let lock = state.lock().await;
9161 let now = Utc::now().to_rfc3339();
9162 let mem = Memory {
9163 id: Uuid::new_v4().to_string(),
9164 tier: Tier::Long,
9165 namespace: "kg-q".into(),
9166 title: "anchor".into(),
9167 content: "anchor body".into(),
9168 tags: vec![],
9169 priority: 5,
9170 confidence: 1.0,
9171 source: "test".into(),
9172 access_count: 0,
9173 created_at: now.clone(),
9174 updated_at: now,
9175 last_accessed_at: None,
9176 expires_at: None,
9177 metadata: serde_json::json!({}),
9178 };
9179 db::insert(&lock.0, &mem).unwrap()
9180 };
9181 let app = Router::new()
9182 .route("/api/v1/kg/query", axum_post(kg_query))
9183 .with_state(state);
9184 let body = serde_json::json!({
9185 "source_id": id,
9186 "max_depth": 1_usize,
9187 });
9188 let resp = app
9189 .oneshot(
9190 axum::http::Request::builder()
9191 .uri("/api/v1/kg/query")
9192 .method("POST")
9193 .header("content-type", "application/json")
9194 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9195 .unwrap(),
9196 )
9197 .await
9198 .unwrap();
9199 assert_eq!(resp.status(), StatusCode::OK);
9200 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9201 .await
9202 .unwrap();
9203 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9204 assert_eq!(v["count"], serde_json::json!(0));
9205 assert_eq!(v["max_depth"], serde_json::json!(1));
9206 }
9207
9208 #[tokio::test]
9209 async fn http_kg_query_short_circuits_empty_allowed_agents() {
9210 let state = test_state();
9212 let app = Router::new()
9213 .route("/api/v1/kg/query", axum_post(kg_query))
9214 .with_state(state);
9215 let body = serde_json::json!({
9216 "source_id": "11111111-1111-4111-8111-111111111111",
9217 "allowed_agents": [],
9218 });
9219 let resp = app
9220 .oneshot(
9221 axum::http::Request::builder()
9222 .uri("/api/v1/kg/query")
9223 .method("POST")
9224 .header("content-type", "application/json")
9225 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9226 .unwrap(),
9227 )
9228 .await
9229 .unwrap();
9230 assert_eq!(resp.status(), StatusCode::OK);
9231 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9232 .await
9233 .unwrap();
9234 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9235 assert_eq!(v["count"], serde_json::json!(0));
9236 }
9237
9238 #[tokio::test]
9241 async fn http_delete_link_rejects_self_link() {
9242 let state = test_state();
9244 let app = Router::new()
9245 .route("/api/v1/links", axum::routing::delete(delete_link))
9246 .with_state(test_app_state(state));
9247 let body = serde_json::json!({
9248 "source_id": "11111111-1111-4111-8111-111111111111",
9249 "target_id": "11111111-1111-4111-8111-111111111111",
9250 "relation": "related_to",
9251 });
9252 let resp = app
9253 .oneshot(
9254 axum::http::Request::builder()
9255 .uri("/api/v1/links")
9256 .method("DELETE")
9257 .header("content-type", "application/json")
9258 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9259 .unwrap(),
9260 )
9261 .await
9262 .unwrap();
9263 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9264 }
9265
9266 #[tokio::test]
9267 async fn http_delete_link_returns_deleted_false_when_missing() {
9268 let state = test_state();
9269 let app = Router::new()
9270 .route("/api/v1/links", axum::routing::delete(delete_link))
9271 .with_state(test_app_state(state));
9272 let body = serde_json::json!({
9273 "source_id": "11111111-1111-4111-8111-111111111111",
9274 "target_id": "22222222-2222-4222-8222-222222222222",
9275 "relation": "related_to",
9276 });
9277 let resp = app
9278 .oneshot(
9279 axum::http::Request::builder()
9280 .uri("/api/v1/links")
9281 .method("DELETE")
9282 .header("content-type", "application/json")
9283 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9284 .unwrap(),
9285 )
9286 .await
9287 .unwrap();
9288 assert_eq!(resp.status(), StatusCode::OK);
9289 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9290 .await
9291 .unwrap();
9292 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9293 assert_eq!(v["deleted"], serde_json::json!(false));
9294 }
9295
9296 #[tokio::test]
9297 async fn http_get_links_for_unknown_id_returns_empty_array() {
9298 let state = test_state();
9302 let app = Router::new()
9303 .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9304 .with_state(state);
9305 let resp = app
9306 .oneshot(
9307 axum::http::Request::builder()
9308 .uri("/api/v1/memories/nonexistent-id/links")
9309 .body(Body::empty())
9310 .unwrap(),
9311 )
9312 .await
9313 .unwrap();
9314 assert_eq!(resp.status(), StatusCode::OK);
9315 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9316 .await
9317 .unwrap();
9318 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9319 assert!(v["links"].is_array());
9320 assert_eq!(v["links"].as_array().unwrap().len(), 0);
9321 }
9322
9323 #[tokio::test]
9324 async fn http_get_links_returns_empty_array_for_unlinked_id() {
9325 let state = test_state();
9326 let id = {
9327 let lock = state.lock().await;
9328 let now = Utc::now().to_rfc3339();
9329 let mem = Memory {
9330 id: Uuid::new_v4().to_string(),
9331 tier: Tier::Long,
9332 namespace: "links-test".into(),
9333 title: "anchor".into(),
9334 content: "no links yet".into(),
9335 tags: vec![],
9336 priority: 5,
9337 confidence: 1.0,
9338 source: "test".into(),
9339 access_count: 0,
9340 created_at: now.clone(),
9341 updated_at: now,
9342 last_accessed_at: None,
9343 expires_at: None,
9344 metadata: serde_json::json!({}),
9345 };
9346 db::insert(&lock.0, &mem).unwrap()
9347 };
9348 let app = Router::new()
9349 .route("/api/v1/memories/{id}/links", axum::routing::get(get_links))
9350 .with_state(state);
9351 let resp = app
9352 .oneshot(
9353 axum::http::Request::builder()
9354 .uri(format!("/api/v1/memories/{id}/links"))
9355 .body(Body::empty())
9356 .unwrap(),
9357 )
9358 .await
9359 .unwrap();
9360 assert_eq!(resp.status(), StatusCode::OK);
9361 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9362 .await
9363 .unwrap();
9364 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9365 assert!(v["links"].is_array());
9366 assert_eq!(v["links"].as_array().unwrap().len(), 0);
9367 }
9368
9369 #[tokio::test]
9370 async fn http_list_namespaces_returns_empty_for_fresh_db() {
9371 let state = test_state();
9372 let app = Router::new()
9373 .route("/api/v1/namespaces", axum::routing::get(list_namespaces))
9374 .with_state(state);
9375 let resp = app
9376 .oneshot(
9377 axum::http::Request::builder()
9378 .uri("/api/v1/namespaces")
9379 .body(Body::empty())
9380 .unwrap(),
9381 )
9382 .await
9383 .unwrap();
9384 assert_eq!(resp.status(), StatusCode::OK);
9385 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9386 .await
9387 .unwrap();
9388 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9389 assert!(v["namespaces"].is_array());
9390 }
9391
9392 #[tokio::test]
9393 async fn http_forget_memories_with_namespace_filter_returns_count() {
9394 let state = test_state();
9396 {
9397 let lock = state.lock().await;
9398 let now = Utc::now().to_rfc3339();
9399 for i in 0..3 {
9400 let mem = Memory {
9401 id: Uuid::new_v4().to_string(),
9402 tier: Tier::Long,
9403 namespace: "forget-target".into(),
9404 title: format!("row-{i}"),
9405 content: format!("content {i}"),
9406 tags: vec![],
9407 priority: 5,
9408 confidence: 1.0,
9409 source: "test".into(),
9410 access_count: 0,
9411 created_at: now.clone(),
9412 updated_at: now.clone(),
9413 last_accessed_at: None,
9414 expires_at: None,
9415 metadata: serde_json::json!({}),
9416 };
9417 db::insert(&lock.0, &mem).unwrap();
9418 }
9419 }
9420 let app = Router::new()
9421 .route("/api/v1/forget", axum_post(forget_memories))
9422 .with_state(state);
9423 let body = serde_json::json!({"namespace": "forget-target"});
9424 let resp = app
9425 .oneshot(
9426 axum::http::Request::builder()
9427 .uri("/api/v1/forget")
9428 .method("POST")
9429 .header("content-type", "application/json")
9430 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9431 .unwrap(),
9432 )
9433 .await
9434 .unwrap();
9435 assert_eq!(resp.status(), StatusCode::OK);
9436 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9437 .await
9438 .unwrap();
9439 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9440 assert!(v["deleted"].as_u64().is_some());
9442 }
9443
9444 #[tokio::test]
9447 async fn http_archive_stats_empty_db_returns_zero() {
9448 let state = test_state();
9449 let app = Router::new()
9450 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
9451 .with_state(state);
9452 let resp = app
9453 .oneshot(
9454 axum::http::Request::builder()
9455 .uri("/api/v1/archive/stats")
9456 .body(Body::empty())
9457 .unwrap(),
9458 )
9459 .await
9460 .unwrap();
9461 assert_eq!(resp.status(), StatusCode::OK);
9462 }
9463
9464 #[tokio::test]
9465 async fn http_purge_archive_returns_zero_for_empty_archive() {
9466 let state = test_state();
9467 let app = Router::new()
9468 .route("/api/v1/archive/purge", axum_post(purge_archive))
9469 .with_state(state);
9470 let resp = app
9471 .oneshot(
9472 axum::http::Request::builder()
9473 .uri("/api/v1/archive/purge")
9474 .method("POST")
9475 .body(Body::empty())
9476 .unwrap(),
9477 )
9478 .await
9479 .unwrap();
9480 assert_eq!(resp.status(), StatusCode::OK);
9481 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9482 .await
9483 .unwrap();
9484 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9485 assert_eq!(v["purged"], serde_json::json!(0));
9486 }
9487
9488 #[tokio::test]
9491 async fn http_run_gc_returns_zero_for_clean_db() {
9492 let state = test_state();
9493 let app = Router::new()
9494 .route("/api/v1/gc", axum_post(run_gc))
9495 .with_state(state);
9496 let resp = app
9497 .oneshot(
9498 axum::http::Request::builder()
9499 .uri("/api/v1/gc")
9500 .method("POST")
9501 .body(Body::empty())
9502 .unwrap(),
9503 )
9504 .await
9505 .unwrap();
9506 assert_eq!(resp.status(), StatusCode::OK);
9507 }
9508
9509 #[tokio::test]
9510 async fn http_export_memories_empty_returns_zero_count() {
9511 let state = test_state();
9512 let app = Router::new()
9513 .route("/api/v1/export", axum::routing::get(export_memories))
9514 .with_state(state);
9515 let resp = app
9516 .oneshot(
9517 axum::http::Request::builder()
9518 .uri("/api/v1/export")
9519 .body(Body::empty())
9520 .unwrap(),
9521 )
9522 .await
9523 .unwrap();
9524 assert_eq!(resp.status(), StatusCode::OK);
9525 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9526 .await
9527 .unwrap();
9528 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9529 assert_eq!(v["count"], serde_json::json!(0));
9530 }
9531
9532 #[tokio::test]
9533 async fn http_import_memories_oversized_batch_rejected() {
9534 let state = test_state();
9535 let app = Router::new()
9536 .route("/api/v1/import", axum_post(import_memories))
9537 .with_state(state);
9538 let many: Vec<serde_json::Value> = (0..=MAX_BULK_SIZE)
9541 .map(|i| {
9542 serde_json::json!({
9543 "id": format!("11111111-1111-4111-8111-{:012}", i),
9544 "tier": "long",
9545 "namespace": "imp",
9546 "title": format!("t-{i}"),
9547 "content": "x",
9548 "tags": [],
9549 "priority": 5,
9550 "confidence": 1.0,
9551 "source": "import",
9552 "access_count": 0,
9553 "created_at": "2026-01-01T00:00:00Z",
9554 "updated_at": "2026-01-01T00:00:00Z",
9555 "last_accessed_at": null,
9556 "expires_at": null,
9557 "metadata": {},
9558 })
9559 })
9560 .collect();
9561 let body = serde_json::json!({"memories": many});
9562 let resp = app
9563 .oneshot(
9564 axum::http::Request::builder()
9565 .uri("/api/v1/import")
9566 .method("POST")
9567 .header("content-type", "application/json")
9568 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9569 .unwrap(),
9570 )
9571 .await
9572 .unwrap();
9573 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
9574 }
9575
9576 #[tokio::test]
9577 async fn http_import_memories_skips_invalid_rows() {
9578 let state = test_state();
9580 let app = Router::new()
9581 .route("/api/v1/import", axum_post(import_memories))
9582 .with_state(state);
9583 let valid = serde_json::json!({
9584 "id": Uuid::new_v4().to_string(),
9585 "tier": "long",
9586 "namespace": "imp",
9587 "title": "ok-row",
9588 "content": "valid content",
9589 "tags": [],
9590 "priority": 5,
9591 "confidence": 1.0,
9592 "source": "import",
9593 "access_count": 0,
9594 "created_at": "2026-01-01T00:00:00Z",
9595 "updated_at": "2026-01-01T00:00:00Z",
9596 "last_accessed_at": null,
9597 "expires_at": null,
9598 "metadata": {},
9599 });
9600 let invalid = serde_json::json!({
9602 "id": Uuid::new_v4().to_string(),
9603 "tier": "long",
9604 "namespace": "imp",
9605 "title": "",
9606 "content": "x",
9607 "tags": [],
9608 "priority": 5,
9609 "confidence": 1.0,
9610 "source": "import",
9611 "access_count": 0,
9612 "created_at": "2026-01-01T00:00:00Z",
9613 "updated_at": "2026-01-01T00:00:00Z",
9614 "last_accessed_at": null,
9615 "expires_at": null,
9616 "metadata": {},
9617 });
9618 let body = serde_json::json!({"memories": [valid, invalid]});
9619 let resp = app
9620 .oneshot(
9621 axum::http::Request::builder()
9622 .uri("/api/v1/import")
9623 .method("POST")
9624 .header("content-type", "application/json")
9625 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9626 .unwrap(),
9627 )
9628 .await
9629 .unwrap();
9630 assert_eq!(resp.status(), StatusCode::OK);
9631 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9632 .await
9633 .unwrap();
9634 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9635 assert_eq!(v["imported"], serde_json::json!(1));
9637 assert!(v["errors"].as_array().unwrap().len() >= 1);
9638 }
9639
9640 #[tokio::test]
9643 async fn http_get_stats_empty_db() {
9644 let state = test_state();
9645 let app = Router::new()
9646 .route("/api/v1/stats", axum::routing::get(get_stats))
9647 .with_state(state);
9648 let resp = app
9649 .oneshot(
9650 axum::http::Request::builder()
9651 .uri("/api/v1/stats")
9652 .body(Body::empty())
9653 .unwrap(),
9654 )
9655 .await
9656 .unwrap();
9657 assert_eq!(resp.status(), StatusCode::OK);
9658 }
9659
9660 #[tokio::test]
9661 async fn http_sync_push_namespace_meta_clears_garbage_skipped() {
9662 let state = test_state();
9665 let app = Router::new()
9666 .route("/api/v1/sync/push", axum_post(sync_push))
9667 .with_state(test_app_state(state));
9668 let body = serde_json::json!({
9669 "sender_agent_id": "peer-x",
9670 "memories": [],
9671 "namespace_meta_clears": ["BAD NAMESPACE!"],
9672 });
9673 let resp = app
9674 .oneshot(
9675 axum::http::Request::builder()
9676 .uri("/api/v1/sync/push")
9677 .method("POST")
9678 .header("content-type", "application/json")
9679 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9680 .unwrap(),
9681 )
9682 .await
9683 .unwrap();
9684 assert_eq!(resp.status(), StatusCode::OK);
9685 }
9686
9687 #[tokio::test]
9688 async fn http_sync_push_pending_decision_invalid_id_skipped() {
9689 let state = test_state();
9691 let app = Router::new()
9692 .route("/api/v1/sync/push", axum_post(sync_push))
9693 .with_state(test_app_state(state));
9694 let body = serde_json::json!({
9695 "sender_agent_id": "peer-x",
9696 "memories": [],
9697 "pending_decisions": [
9698 {"id": "BAD ID!", "approved": true, "decider": "alice"}
9699 ],
9700 });
9701 let resp = app
9702 .oneshot(
9703 axum::http::Request::builder()
9704 .uri("/api/v1/sync/push")
9705 .method("POST")
9706 .header("content-type", "application/json")
9707 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9708 .unwrap(),
9709 )
9710 .await
9711 .unwrap();
9712 assert_eq!(resp.status(), StatusCode::OK);
9713 }
9714
9715 #[tokio::test]
9716 async fn http_sync_push_namespace_meta_invalid_skipped() {
9717 let state = test_state();
9720 let app = Router::new()
9721 .route("/api/v1/sync/push", axum_post(sync_push))
9722 .with_state(test_app_state(state));
9723 let body = serde_json::json!({
9724 "sender_agent_id": "peer-x",
9725 "memories": [],
9726 "namespace_meta": [
9727 {"namespace": "BAD NS!", "standard_id": "11111111-1111-4111-8111-111111111111", "parent_namespace": null}
9728 ],
9729 });
9730 let resp = app
9731 .oneshot(
9732 axum::http::Request::builder()
9733 .uri("/api/v1/sync/push")
9734 .method("POST")
9735 .header("content-type", "application/json")
9736 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9737 .unwrap(),
9738 )
9739 .await
9740 .unwrap();
9741 assert_eq!(resp.status(), StatusCode::OK);
9742 }
9743
9744 #[tokio::test]
9745 async fn http_sync_push_dry_run_namespace_meta_no_apply() {
9746 let state = test_state();
9748 let app = Router::new()
9749 .route("/api/v1/sync/push", axum_post(sync_push))
9750 .with_state(test_app_state(state.clone()));
9751 let body = serde_json::json!({
9752 "sender_agent_id": "peer-x",
9753 "memories": [],
9754 "dry_run": true,
9755 "namespace_meta_clears": ["preview-ns"],
9756 "pending_decisions": [
9757 {"id": "11111111-1111-4111-8111-111111111111", "approved": true, "decider": "alice"}
9758 ],
9759 });
9760 let resp = app
9761 .oneshot(
9762 axum::http::Request::builder()
9763 .uri("/api/v1/sync/push")
9764 .method("POST")
9765 .header("content-type", "application/json")
9766 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9767 .unwrap(),
9768 )
9769 .await
9770 .unwrap();
9771 assert_eq!(resp.status(), StatusCode::OK);
9772 }
9773
9774 #[tokio::test]
9785 async fn http_list_archive_empty_returns_empty_array() {
9786 let state = test_state();
9788 let app = Router::new()
9789 .route("/api/v1/archive", axum::routing::get(list_archive))
9790 .with_state(state);
9791 let resp = app
9792 .oneshot(
9793 axum::http::Request::builder()
9794 .uri("/api/v1/archive")
9795 .body(Body::empty())
9796 .unwrap(),
9797 )
9798 .await
9799 .unwrap();
9800 assert_eq!(resp.status(), StatusCode::OK);
9801 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9802 .await
9803 .unwrap();
9804 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9805 assert_eq!(v["count"], 0);
9806 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
9807 }
9808
9809 #[tokio::test]
9810 async fn http_list_archive_with_items_returns_them() {
9811 let state = test_state();
9813 let id_a = insert_test_memory(&state, "h8a-list-items", "row-a").await;
9814 let id_b = insert_test_memory(&state, "h8a-list-items", "row-b").await;
9815 {
9816 let lock = state.lock().await;
9817 db::archive_memory(&lock.0, &id_a, Some("test")).unwrap();
9818 db::archive_memory(&lock.0, &id_b, Some("test")).unwrap();
9819 }
9820 let app = Router::new()
9821 .route("/api/v1/archive", axum::routing::get(list_archive))
9822 .with_state(state);
9823 let resp = app
9824 .oneshot(
9825 axum::http::Request::builder()
9826 .uri("/api/v1/archive?limit=10")
9827 .body(Body::empty())
9828 .unwrap(),
9829 )
9830 .await
9831 .unwrap();
9832 assert_eq!(resp.status(), StatusCode::OK);
9833 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9834 .await
9835 .unwrap();
9836 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9837 assert_eq!(v["count"], 2);
9838 }
9839
9840 #[tokio::test]
9841 async fn http_list_archive_pagination_offset_skips() {
9842 let state = test_state();
9845 let id1 = insert_test_memory(&state, "h8a-page", "row-1").await;
9846 let id2 = insert_test_memory(&state, "h8a-page", "row-2").await;
9847 let id3 = insert_test_memory(&state, "h8a-page", "row-3").await;
9848 {
9849 let lock = state.lock().await;
9850 db::archive_memory(&lock.0, &id1, Some("p")).unwrap();
9851 db::archive_memory(&lock.0, &id2, Some("p")).unwrap();
9852 db::archive_memory(&lock.0, &id3, Some("p")).unwrap();
9853 }
9854 let app = Router::new()
9855 .route("/api/v1/archive", axum::routing::get(list_archive))
9856 .with_state(state);
9857 let resp = app
9858 .oneshot(
9859 axum::http::Request::builder()
9860 .uri("/api/v1/archive?limit=1&offset=1")
9861 .body(Body::empty())
9862 .unwrap(),
9863 )
9864 .await
9865 .unwrap();
9866 assert_eq!(resp.status(), StatusCode::OK);
9867 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9868 .await
9869 .unwrap();
9870 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9871 assert_eq!(v["count"], 1);
9872 }
9873
9874 #[tokio::test]
9875 async fn http_list_archive_namespace_filter_excludes_others() {
9876 let state = test_state();
9879 let id_a = insert_test_memory(&state, "h8a-ns-a", "row-a").await;
9880 let id_b = insert_test_memory(&state, "h8a-ns-b", "row-b").await;
9881 {
9882 let lock = state.lock().await;
9883 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9884 db::archive_memory(&lock.0, &id_b, Some("t")).unwrap();
9885 }
9886 let app = Router::new()
9887 .route("/api/v1/archive", axum::routing::get(list_archive))
9888 .with_state(state);
9889 let resp = app
9890 .oneshot(
9891 axum::http::Request::builder()
9892 .uri("/api/v1/archive?namespace=h8a-ns-a&limit=10")
9893 .body(Body::empty())
9894 .unwrap(),
9895 )
9896 .await
9897 .unwrap();
9898 assert_eq!(resp.status(), StatusCode::OK);
9899 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9900 .await
9901 .unwrap();
9902 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9903 assert_eq!(v["count"], 1);
9904 let entries = v["archived"].as_array().unwrap();
9905 assert_eq!(entries[0]["namespace"], "h8a-ns-a");
9906 }
9907
9908 #[tokio::test]
9909 async fn http_list_archive_namespace_filter_unknown_returns_empty() {
9910 let state = test_state();
9913 let id_a = insert_test_memory(&state, "h8a-ns-known", "row-a").await;
9914 {
9915 let lock = state.lock().await;
9916 db::archive_memory(&lock.0, &id_a, Some("t")).unwrap();
9917 }
9918 let app = Router::new()
9919 .route("/api/v1/archive", axum::routing::get(list_archive))
9920 .with_state(state);
9921 let resp = app
9922 .oneshot(
9923 axum::http::Request::builder()
9924 .uri("/api/v1/archive?namespace=h8a-no-such-ns")
9925 .body(Body::empty())
9926 .unwrap(),
9927 )
9928 .await
9929 .unwrap();
9930 assert_eq!(resp.status(), StatusCode::OK);
9931 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9932 .await
9933 .unwrap();
9934 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9935 assert_eq!(v["count"], 0);
9936 }
9937
9938 #[tokio::test]
9941 async fn http_archive_by_ids_single_id_success() {
9942 let state = test_state();
9944 let id = insert_test_memory(&state, "h8a-aby-single", "row").await;
9945 let app = Router::new()
9946 .route("/api/v1/archive", axum_post(archive_by_ids))
9947 .with_state(test_app_state(state.clone()));
9948 let body = serde_json::json!({"ids": [id], "reason": "h8a-single"});
9949 let resp = app
9950 .oneshot(
9951 axum::http::Request::builder()
9952 .uri("/api/v1/archive")
9953 .method("POST")
9954 .header("content-type", "application/json")
9955 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9956 .unwrap(),
9957 )
9958 .await
9959 .unwrap();
9960 assert_eq!(resp.status(), StatusCode::OK);
9961 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9962 .await
9963 .unwrap();
9964 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9965 assert_eq!(v["count"], 1);
9966 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9967 assert_eq!(v["reason"], "h8a-single");
9968 }
9969
9970 #[tokio::test]
9971 async fn http_archive_by_ids_bulk_success() {
9972 let state = test_state();
9974 let id1 = insert_test_memory(&state, "h8a-bulk", "row-1").await;
9975 let id2 = insert_test_memory(&state, "h8a-bulk", "row-2").await;
9976 let id3 = insert_test_memory(&state, "h8a-bulk", "row-3").await;
9977 let app = Router::new()
9978 .route("/api/v1/archive", axum_post(archive_by_ids))
9979 .with_state(test_app_state(state.clone()));
9980 let body = serde_json::json!({"ids": [id1, id2, id3]});
9981 let resp = app
9982 .oneshot(
9983 axum::http::Request::builder()
9984 .uri("/api/v1/archive")
9985 .method("POST")
9986 .header("content-type", "application/json")
9987 .body(Body::from(serde_json::to_vec(&body).unwrap()))
9988 .unwrap(),
9989 )
9990 .await
9991 .unwrap();
9992 assert_eq!(resp.status(), StatusCode::OK);
9993 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
9994 .await
9995 .unwrap();
9996 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9997 assert_eq!(v["count"], 3);
9998 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
9999 }
10000
10001 #[tokio::test]
10002 async fn http_archive_by_ids_empty_array_returns_ok_zero_count() {
10003 let state = test_state();
10006 let app = Router::new()
10007 .route("/api/v1/archive", axum_post(archive_by_ids))
10008 .with_state(test_app_state(state.clone()));
10009 let body = serde_json::json!({"ids": []});
10010 let resp = app
10011 .oneshot(
10012 axum::http::Request::builder()
10013 .uri("/api/v1/archive")
10014 .method("POST")
10015 .header("content-type", "application/json")
10016 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10017 .unwrap(),
10018 )
10019 .await
10020 .unwrap();
10021 assert_eq!(resp.status(), StatusCode::OK);
10022 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10023 .await
10024 .unwrap();
10025 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10026 assert_eq!(v["count"], 0);
10027 assert_eq!(v["archived"].as_array().unwrap().len(), 0);
10028 assert_eq!(v["missing"].as_array().unwrap().len(), 0);
10029 }
10030
10031 #[tokio::test]
10032 async fn http_archive_by_ids_missing_ids_field_returns_400() {
10033 let state = test_state();
10036 let app = Router::new()
10037 .route("/api/v1/archive", axum_post(archive_by_ids))
10038 .with_state(test_app_state(state));
10039 let body = serde_json::json!({"reason": "no-ids-field"});
10040 let resp = app
10041 .oneshot(
10042 axum::http::Request::builder()
10043 .uri("/api/v1/archive")
10044 .method("POST")
10045 .header("content-type", "application/json")
10046 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10047 .unwrap(),
10048 )
10049 .await
10050 .unwrap();
10051 assert!(resp.status().is_client_error());
10052 }
10053
10054 #[tokio::test]
10055 async fn http_archive_by_ids_malformed_json_returns_400() {
10056 let state = test_state();
10058 let app = Router::new()
10059 .route("/api/v1/archive", axum_post(archive_by_ids))
10060 .with_state(test_app_state(state));
10061 let resp = app
10062 .oneshot(
10063 axum::http::Request::builder()
10064 .uri("/api/v1/archive")
10065 .method("POST")
10066 .header("content-type", "application/json")
10067 .body(Body::from("not-valid-json{{"))
10068 .unwrap(),
10069 )
10070 .await
10071 .unwrap();
10072 assert!(resp.status().is_client_error());
10073 }
10074
10075 #[tokio::test]
10078 async fn http_purge_archive_older_than_keeps_recent() {
10079 let state = test_state();
10082 let id = insert_test_memory(&state, "h8a-purge-recent", "row").await;
10083 {
10084 let lock = state.lock().await;
10085 db::archive_memory(&lock.0, &id, Some("recent")).unwrap();
10086 }
10087 let app = Router::new()
10088 .route("/api/v1/archive", axum::routing::delete(purge_archive))
10089 .with_state(state.clone());
10090 let resp = app
10091 .oneshot(
10092 axum::http::Request::builder()
10093 .uri("/api/v1/archive?older_than_days=365")
10094 .method("DELETE")
10095 .body(Body::empty())
10096 .unwrap(),
10097 )
10098 .await
10099 .unwrap();
10100 assert_eq!(resp.status(), StatusCode::OK);
10101 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10102 .await
10103 .unwrap();
10104 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10105 assert_eq!(v["purged"], 0);
10106 let lock = state.lock().await;
10108 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10109 assert_eq!(rows.len(), 1);
10110 }
10111
10112 #[tokio::test]
10113 async fn http_purge_archive_unfiltered_purges_everything() {
10114 let state = test_state();
10116 for i in 0..3 {
10117 let id = insert_test_memory(&state, "h8a-purge-all", &format!("row-{i}")).await;
10118 let lock = state.lock().await;
10119 db::archive_memory(&lock.0, &id, Some("all")).unwrap();
10120 }
10121 let app = Router::new()
10122 .route("/api/v1/archive", axum::routing::delete(purge_archive))
10123 .with_state(state.clone());
10124 let resp = app
10125 .oneshot(
10126 axum::http::Request::builder()
10127 .uri("/api/v1/archive")
10128 .method("DELETE")
10129 .body(Body::empty())
10130 .unwrap(),
10131 )
10132 .await
10133 .unwrap();
10134 assert_eq!(resp.status(), StatusCode::OK);
10135 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10136 .await
10137 .unwrap();
10138 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10139 assert_eq!(v["purged"], 3);
10140 let lock = state.lock().await;
10141 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10142 assert!(rows.is_empty());
10143 }
10144
10145 #[tokio::test]
10146 async fn http_purge_archive_zero_days_purges_all_archived() {
10147 let state = test_state();
10150 let id = insert_test_memory(&state, "h8a-purge-zero", "row").await;
10151 {
10152 let lock = state.lock().await;
10153 db::archive_memory(&lock.0, &id, Some("zero")).unwrap();
10154 }
10155 let app = Router::new()
10156 .route("/api/v1/archive", axum::routing::delete(purge_archive))
10157 .with_state(state.clone());
10158 let resp = app
10159 .oneshot(
10160 axum::http::Request::builder()
10161 .uri("/api/v1/archive?older_than_days=0")
10162 .method("DELETE")
10163 .body(Body::empty())
10164 .unwrap(),
10165 )
10166 .await
10167 .unwrap();
10168 assert_eq!(resp.status(), StatusCode::OK);
10169 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10170 .await
10171 .unwrap();
10172 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10173 assert!(v["purged"].as_u64().unwrap() >= 1);
10175 }
10176
10177 #[tokio::test]
10178 async fn http_purge_archive_response_shape_has_purged_key() {
10179 let state = test_state();
10182 let app = Router::new()
10183 .route("/api/v1/archive", axum::routing::delete(purge_archive))
10184 .with_state(state);
10185 let resp = app
10186 .oneshot(
10187 axum::http::Request::builder()
10188 .uri("/api/v1/archive")
10189 .method("DELETE")
10190 .body(Body::empty())
10191 .unwrap(),
10192 )
10193 .await
10194 .unwrap();
10195 assert_eq!(resp.status(), StatusCode::OK);
10196 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10197 .await
10198 .unwrap();
10199 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10200 assert!(v.is_object());
10201 assert!(v["purged"].is_number());
10202 }
10203
10204 #[tokio::test]
10207 async fn http_restore_archive_happy_path_and_listed_in_active() {
10208 let state = test_state();
10211 let id = insert_test_memory(&state, "h8a-restore-ok", "row").await;
10212 {
10213 let lock = state.lock().await;
10214 db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
10215 }
10216 let app = Router::new()
10217 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10218 .with_state(test_app_state(state.clone()));
10219 let resp = app
10220 .oneshot(
10221 axum::http::Request::builder()
10222 .uri(format!("/api/v1/archive/{id}/restore"))
10223 .method("POST")
10224 .body(Body::empty())
10225 .unwrap(),
10226 )
10227 .await
10228 .unwrap();
10229 assert_eq!(resp.status(), StatusCode::OK);
10230 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10231 .await
10232 .unwrap();
10233 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10234 assert_eq!(v["restored"], true);
10235 assert_eq!(v["id"], id);
10236 let lock = state.lock().await;
10238 let got = db::get(&lock.0, &id).unwrap();
10239 assert!(got.is_some());
10240 let archived = db::list_archived(&lock.0, None, 10, 0).unwrap();
10241 assert!(archived.is_empty());
10242 }
10243
10244 #[tokio::test]
10245 async fn http_restore_archive_then_list_archive_excludes_restored() {
10246 let state = test_state();
10249 let id = insert_test_memory(&state, "h8a-restore-list", "row").await;
10250 {
10251 let lock = state.lock().await;
10252 db::archive_memory(&lock.0, &id, Some("h8a")).unwrap();
10253 let rows = db::list_archived(&lock.0, None, 10, 0).unwrap();
10255 assert_eq!(rows.len(), 1);
10256 }
10257 let restore_app = Router::new()
10258 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10259 .with_state(test_app_state(state.clone()));
10260 let resp = restore_app
10261 .oneshot(
10262 axum::http::Request::builder()
10263 .uri(format!("/api/v1/archive/{id}/restore"))
10264 .method("POST")
10265 .body(Body::empty())
10266 .unwrap(),
10267 )
10268 .await
10269 .unwrap();
10270 assert_eq!(resp.status(), StatusCode::OK);
10271
10272 let list_app = Router::new()
10273 .route("/api/v1/archive", axum::routing::get(list_archive))
10274 .with_state(state);
10275 let resp = list_app
10276 .oneshot(
10277 axum::http::Request::builder()
10278 .uri("/api/v1/archive")
10279 .body(Body::empty())
10280 .unwrap(),
10281 )
10282 .await
10283 .unwrap();
10284 assert_eq!(resp.status(), StatusCode::OK);
10285 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10286 .await
10287 .unwrap();
10288 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10289 assert_eq!(v["count"], 0);
10290 }
10291
10292 #[tokio::test]
10293 async fn http_restore_archive_preserves_namespace_and_title() {
10294 let state = test_state();
10297 let id = insert_test_memory(&state, "h8a-rest-meta", "preserve-me").await;
10298 {
10299 let lock = state.lock().await;
10300 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10301 }
10302 let app = Router::new()
10303 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10304 .with_state(test_app_state(state.clone()));
10305 let resp = app
10306 .oneshot(
10307 axum::http::Request::builder()
10308 .uri(format!("/api/v1/archive/{id}/restore"))
10309 .method("POST")
10310 .body(Body::empty())
10311 .unwrap(),
10312 )
10313 .await
10314 .unwrap();
10315 assert_eq!(resp.status(), StatusCode::OK);
10316 let lock = state.lock().await;
10317 let got = db::get(&lock.0, &id).unwrap().unwrap();
10318 assert_eq!(got.namespace, "h8a-rest-meta");
10319 assert_eq!(got.title, "preserve-me");
10320 }
10321
10322 #[tokio::test]
10323 async fn http_restore_archive_after_purge_returns_404() {
10324 let state = test_state();
10327 let id = insert_test_memory(&state, "h8a-rest-purged", "row").await;
10328 {
10329 let lock = state.lock().await;
10330 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
10331 db::purge_archive(&lock.0, None).unwrap();
10333 }
10334 let app = Router::new()
10335 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10336 .with_state(test_app_state(state));
10337 let resp = app
10338 .oneshot(
10339 axum::http::Request::builder()
10340 .uri(format!("/api/v1/archive/{id}/restore"))
10341 .method("POST")
10342 .body(Body::empty())
10343 .unwrap(),
10344 )
10345 .await
10346 .unwrap();
10347 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10348 }
10349
10350 #[tokio::test]
10351 async fn http_restore_archive_oversized_id_returns_400() {
10352 let state = test_state();
10355 let app = Router::new()
10356 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
10357 .with_state(test_app_state(state));
10358 let huge = "a".repeat(200);
10359 let resp = app
10360 .oneshot(
10361 axum::http::Request::builder()
10362 .uri(format!("/api/v1/archive/{huge}/restore"))
10363 .method("POST")
10364 .body(Body::empty())
10365 .unwrap(),
10366 )
10367 .await
10368 .unwrap();
10369 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10370 }
10371
10372 #[tokio::test]
10375 async fn http_archive_stats_with_data_reports_total_and_breakdown() {
10376 let state = test_state();
10379 let id_a1 = insert_test_memory(&state, "h8a-stats-a", "row-1").await;
10380 let id_a2 = insert_test_memory(&state, "h8a-stats-a", "row-2").await;
10381 let id_b1 = insert_test_memory(&state, "h8a-stats-b", "row-3").await;
10382 {
10383 let lock = state.lock().await;
10384 db::archive_memory(&lock.0, &id_a1, Some("t")).unwrap();
10385 db::archive_memory(&lock.0, &id_a2, Some("t")).unwrap();
10386 db::archive_memory(&lock.0, &id_b1, Some("t")).unwrap();
10387 }
10388 let app = Router::new()
10389 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10390 .with_state(state);
10391 let resp = app
10392 .oneshot(
10393 axum::http::Request::builder()
10394 .uri("/api/v1/archive/stats")
10395 .body(Body::empty())
10396 .unwrap(),
10397 )
10398 .await
10399 .unwrap();
10400 assert_eq!(resp.status(), StatusCode::OK);
10401 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10402 .await
10403 .unwrap();
10404 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10405 assert_eq!(v["archived_total"], 3);
10406 let by_ns = v["by_namespace"].as_array().unwrap();
10407 assert_eq!(by_ns.len(), 2);
10408 assert_eq!(by_ns[0]["count"], 2);
10410 assert_eq!(by_ns[0]["namespace"], "h8a-stats-a");
10411 }
10412
10413 #[tokio::test]
10414 async fn http_archive_stats_empty_returns_total_zero_empty_breakdown() {
10415 let state = test_state();
10417 let app = Router::new()
10418 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10419 .with_state(state);
10420 let resp = app
10421 .oneshot(
10422 axum::http::Request::builder()
10423 .uri("/api/v1/archive/stats")
10424 .body(Body::empty())
10425 .unwrap(),
10426 )
10427 .await
10428 .unwrap();
10429 assert_eq!(resp.status(), StatusCode::OK);
10430 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10431 .await
10432 .unwrap();
10433 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10434 assert_eq!(v["archived_total"], 0);
10435 assert!(v["by_namespace"].as_array().unwrap().is_empty());
10436 }
10437
10438 #[tokio::test]
10439 async fn http_archive_stats_unaffected_by_active_rows() {
10440 let state = test_state();
10443 for i in 0..5 {
10445 insert_test_memory(&state, "h8a-stats-active", &format!("row-{i}")).await;
10446 }
10447 let app = Router::new()
10448 .route("/api/v1/archive/stats", axum::routing::get(archive_stats))
10449 .with_state(state);
10450 let resp = app
10451 .oneshot(
10452 axum::http::Request::builder()
10453 .uri("/api/v1/archive/stats")
10454 .body(Body::empty())
10455 .unwrap(),
10456 )
10457 .await
10458 .unwrap();
10459 assert_eq!(resp.status(), StatusCode::OK);
10460 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10461 .await
10462 .unwrap();
10463 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10464 assert_eq!(v["archived_total"], 0);
10465 }
10466
10467 #[tokio::test]
10470 async fn http_forget_memories_no_filter_returns_400() {
10471 let state = test_state();
10475 let app = Router::new()
10476 .route("/api/v1/forget", axum_post(forget_memories))
10477 .with_state(state);
10478 let body = serde_json::json!({});
10479 let resp = app
10480 .oneshot(
10481 axum::http::Request::builder()
10482 .uri("/api/v1/forget")
10483 .method("POST")
10484 .header("content-type", "application/json")
10485 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10486 .unwrap(),
10487 )
10488 .await
10489 .unwrap();
10490 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10491 }
10492
10493 #[tokio::test]
10494 async fn http_forget_memories_pattern_only_deletes_matches() {
10495 let state = test_state();
10498 {
10499 let lock = state.lock().await;
10500 let now = Utc::now().to_rfc3339();
10501 for (i, content) in ["delete-me alpha", "keep-this beta", "delete-me gamma"]
10502 .iter()
10503 .enumerate()
10504 {
10505 let mem = Memory {
10506 id: Uuid::new_v4().to_string(),
10507 tier: Tier::Long,
10508 namespace: "h8a-forget-pat".into(),
10509 title: format!("row-{i}"),
10510 content: (*content).into(),
10511 tags: vec![],
10512 priority: 5,
10513 confidence: 1.0,
10514 source: "test".into(),
10515 access_count: 0,
10516 created_at: now.clone(),
10517 updated_at: now.clone(),
10518 last_accessed_at: None,
10519 expires_at: None,
10520 metadata: serde_json::json!({}),
10521 };
10522 db::insert(&lock.0, &mem).unwrap();
10523 }
10524 }
10525 let app = Router::new()
10526 .route("/api/v1/forget", axum_post(forget_memories))
10527 .with_state(state);
10528 let body = serde_json::json!({"pattern": "delete-me"});
10529 let resp = app
10530 .oneshot(
10531 axum::http::Request::builder()
10532 .uri("/api/v1/forget")
10533 .method("POST")
10534 .header("content-type", "application/json")
10535 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10536 .unwrap(),
10537 )
10538 .await
10539 .unwrap();
10540 assert_eq!(resp.status(), StatusCode::OK);
10541 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10542 .await
10543 .unwrap();
10544 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10545 assert_eq!(v["deleted"], 2);
10547 }
10548
10549 #[tokio::test]
10550 async fn http_forget_memories_by_tier_only_targets_tier() {
10551 let state = test_state();
10553 {
10554 let lock = state.lock().await;
10555 let now = Utc::now().to_rfc3339();
10556 for (i, tier) in [Tier::Short, Tier::Short, Tier::Long].iter().enumerate() {
10557 let mem = Memory {
10558 id: Uuid::new_v4().to_string(),
10559 tier: tier.clone(),
10560 namespace: "h8a-forget-tier".into(),
10561 title: format!("row-{i}"),
10562 content: format!("content {i}"),
10563 tags: vec![],
10564 priority: 5,
10565 confidence: 1.0,
10566 source: "test".into(),
10567 access_count: 0,
10568 created_at: now.clone(),
10569 updated_at: now.clone(),
10570 last_accessed_at: None,
10571 expires_at: None,
10572 metadata: serde_json::json!({}),
10573 };
10574 db::insert(&lock.0, &mem).unwrap();
10575 }
10576 }
10577 let app = Router::new()
10578 .route("/api/v1/forget", axum_post(forget_memories))
10579 .with_state(state);
10580 let body = serde_json::json!({"tier": "short"});
10581 let resp = app
10582 .oneshot(
10583 axum::http::Request::builder()
10584 .uri("/api/v1/forget")
10585 .method("POST")
10586 .header("content-type", "application/json")
10587 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10588 .unwrap(),
10589 )
10590 .await
10591 .unwrap();
10592 assert_eq!(resp.status(), StatusCode::OK);
10593 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10594 .await
10595 .unwrap();
10596 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10597 assert_eq!(v["deleted"], 2);
10598 }
10599
10600 #[tokio::test]
10601 async fn http_forget_memories_combined_filters_intersect() {
10602 let state = test_state();
10605 {
10606 let lock = state.lock().await;
10607 let now = Utc::now().to_rfc3339();
10608 for (ns, content) in [
10611 ("h8a-forget-and", "purge alpha"),
10612 ("h8a-forget-and", "purge beta"),
10613 ("h8a-forget-and", "keep gamma"),
10614 ("h8a-forget-other", "purge delta"),
10615 ] {
10616 let mem = Memory {
10617 id: Uuid::new_v4().to_string(),
10618 tier: Tier::Long,
10619 namespace: ns.into(),
10620 title: format!("row-{content}"),
10621 content: content.into(),
10622 tags: vec![],
10623 priority: 5,
10624 confidence: 1.0,
10625 source: "test".into(),
10626 access_count: 0,
10627 created_at: now.clone(),
10628 updated_at: now.clone(),
10629 last_accessed_at: None,
10630 expires_at: None,
10631 metadata: serde_json::json!({}),
10632 };
10633 db::insert(&lock.0, &mem).unwrap();
10634 }
10635 }
10636 let app = Router::new()
10637 .route("/api/v1/forget", axum_post(forget_memories))
10638 .with_state(state);
10639 let body = serde_json::json!({
10640 "namespace": "h8a-forget-and",
10641 "pattern": "purge"
10642 });
10643 let resp = app
10644 .oneshot(
10645 axum::http::Request::builder()
10646 .uri("/api/v1/forget")
10647 .method("POST")
10648 .header("content-type", "application/json")
10649 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10650 .unwrap(),
10651 )
10652 .await
10653 .unwrap();
10654 assert_eq!(resp.status(), StatusCode::OK);
10655 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10656 .await
10657 .unwrap();
10658 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10659 assert_eq!(v["deleted"], 2);
10661 }
10662
10663 #[tokio::test]
10664 async fn http_forget_memories_malformed_json_returns_400() {
10665 let state = test_state();
10667 let app = Router::new()
10668 .route("/api/v1/forget", axum_post(forget_memories))
10669 .with_state(state);
10670 let resp = app
10671 .oneshot(
10672 axum::http::Request::builder()
10673 .uri("/api/v1/forget")
10674 .method("POST")
10675 .header("content-type", "application/json")
10676 .body(Body::from("{not-json"))
10677 .unwrap(),
10678 )
10679 .await
10680 .unwrap();
10681 assert!(resp.status().is_client_error());
10682 }
10683
10684 #[tokio::test]
10685 async fn http_forget_memories_no_match_returns_zero_deleted() {
10686 let state = test_state();
10688 for i in 0..3 {
10691 insert_test_memory(&state, "h8a-forget-keep", &format!("k-{i}")).await;
10692 }
10693 let app = Router::new()
10694 .route("/api/v1/forget", axum_post(forget_memories))
10695 .with_state(state.clone());
10696 let body = serde_json::json!({"namespace": "h8a-forget-empty"});
10697 let resp = app
10698 .oneshot(
10699 axum::http::Request::builder()
10700 .uri("/api/v1/forget")
10701 .method("POST")
10702 .header("content-type", "application/json")
10703 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10704 .unwrap(),
10705 )
10706 .await
10707 .unwrap();
10708 assert_eq!(resp.status(), StatusCode::OK);
10709 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
10710 .await
10711 .unwrap();
10712 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10713 assert_eq!(v["deleted"], 0);
10714 let lock = state.lock().await;
10716 let rows = db::list(
10717 &lock.0,
10718 Some("h8a-forget-keep"),
10719 None,
10720 10,
10721 0,
10722 None,
10723 None,
10724 None,
10725 None,
10726 None,
10727 )
10728 .unwrap();
10729 assert_eq!(rows.len(), 3);
10730 }
10731 #[tokio::test]
10750 async fn h8b_subscribe_https_url_returns_created() {
10751 let state = test_state();
10752 let app = Router::new()
10753 .route("/api/v1/subscriptions", axum_post(subscribe))
10754 .with_state(test_app_state(state));
10755
10756 let body = serde_json::json!({
10757 "url": "https://example.com/webhook",
10758 "events": "*",
10759 });
10760 let resp = app
10761 .oneshot(
10762 axum::http::Request::builder()
10763 .uri("/api/v1/subscriptions")
10764 .method("POST")
10765 .header("content-type", "application/json")
10766 .header("x-agent-id", "alice")
10767 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10768 .unwrap(),
10769 )
10770 .await
10771 .unwrap();
10772 assert_eq!(resp.status(), StatusCode::CREATED);
10773 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10774 .await
10775 .unwrap();
10776 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10777 assert!(v["id"].as_str().is_some(), "id must be returned");
10778 assert_eq!(v["url"], "https://example.com/webhook");
10779 assert_eq!(v["created_by"], "alice");
10780 }
10781
10782 #[tokio::test]
10785 async fn h8b_subscribe_missing_url_and_namespace_rejected() {
10786 let state = test_state();
10787 let app = Router::new()
10788 .route("/api/v1/subscriptions", axum_post(subscribe))
10789 .with_state(test_app_state(state));
10790
10791 let body = serde_json::json!({"events": "*"});
10792 let resp = app
10793 .oneshot(
10794 axum::http::Request::builder()
10795 .uri("/api/v1/subscriptions")
10796 .method("POST")
10797 .header("content-type", "application/json")
10798 .header("x-agent-id", "alice")
10799 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10800 .unwrap(),
10801 )
10802 .await
10803 .unwrap();
10804 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10805 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10806 .await
10807 .unwrap();
10808 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10809 assert!(v["error"].as_str().unwrap().contains("url or namespace"),);
10810 }
10811
10812 #[tokio::test]
10815 async fn h8b_subscribe_invalid_url_rejected() {
10816 let state = test_state();
10817 let app = Router::new()
10818 .route("/api/v1/subscriptions", axum_post(subscribe))
10819 .with_state(test_app_state(state));
10820
10821 let body = serde_json::json!({
10822 "url": "not-a-url",
10823 "events": "*",
10824 });
10825 let resp = app
10826 .oneshot(
10827 axum::http::Request::builder()
10828 .uri("/api/v1/subscriptions")
10829 .method("POST")
10830 .header("content-type", "application/json")
10831 .header("x-agent-id", "alice")
10832 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10833 .unwrap(),
10834 )
10835 .await
10836 .unwrap();
10837 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10838 }
10839
10840 #[tokio::test]
10845 async fn h8b_subscribe_rejects_link_local_metadata_ip() {
10846 let state = test_state();
10847 let app = Router::new()
10848 .route("/api/v1/subscriptions", axum_post(subscribe))
10849 .with_state(test_app_state(state));
10850
10851 let body = serde_json::json!({
10852 "url": "https://169.254.169.254/latest/meta-data/",
10853 "events": "*",
10854 });
10855 let resp = app
10856 .oneshot(
10857 axum::http::Request::builder()
10858 .uri("/api/v1/subscriptions")
10859 .method("POST")
10860 .header("content-type", "application/json")
10861 .header("x-agent-id", "alice")
10862 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10863 .unwrap(),
10864 )
10865 .await
10866 .unwrap();
10867 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10868 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10869 .await
10870 .unwrap();
10871 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10872 let err = v["error"].as_str().unwrap();
10873 assert!(
10876 err.contains("private") || err.contains("link-local") || err.contains("non-loopback"),
10877 "expected SSRF rejection, got: {err}",
10878 );
10879 }
10880
10881 #[tokio::test]
10884 async fn h8b_subscribe_namespace_shape_synthesizes_url() {
10885 let state = test_state();
10886 let app = Router::new()
10887 .route("/api/v1/subscriptions", axum_post(subscribe))
10888 .with_state(test_app_state(state));
10889
10890 let body = serde_json::json!({
10891 "agent_id": "alice",
10892 "namespace": "team/research",
10893 });
10894 let resp = app
10895 .oneshot(
10896 axum::http::Request::builder()
10897 .uri("/api/v1/subscriptions")
10898 .method("POST")
10899 .header("content-type", "application/json")
10900 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10901 .unwrap(),
10902 )
10903 .await
10904 .unwrap();
10905 assert_eq!(resp.status(), StatusCode::CREATED);
10906 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10907 .await
10908 .unwrap();
10909 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10910 assert_eq!(v["agent_id"], "alice");
10911 assert_eq!(v["namespace"], "team/research");
10912 assert!(
10913 v["url"]
10914 .as_str()
10915 .unwrap()
10916 .starts_with("http://localhost/_ns/"),
10917 "expected synthetic URL, got {}",
10918 v["url"],
10919 );
10920 }
10921
10922 #[tokio::test]
10925 async fn h8b_subscribe_event_filter_round_trips() {
10926 let state = test_state();
10927 let app = Router::new()
10928 .route("/api/v1/subscriptions", axum_post(subscribe))
10929 .with_state(test_app_state(state));
10930
10931 let body = serde_json::json!({
10932 "url": "https://example.com/hook",
10933 "events": "memory.created",
10934 "namespace_filter": "global",
10935 });
10936 let resp = app
10937 .oneshot(
10938 axum::http::Request::builder()
10939 .uri("/api/v1/subscriptions")
10940 .method("POST")
10941 .header("content-type", "application/json")
10942 .header("x-agent-id", "alice")
10943 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10944 .unwrap(),
10945 )
10946 .await
10947 .unwrap();
10948 assert_eq!(resp.status(), StatusCode::CREATED);
10949 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10950 .await
10951 .unwrap();
10952 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10953 assert_eq!(v["events"], "memory.created");
10954 assert_eq!(v["namespace_filter"], "global");
10955 }
10956
10957 #[tokio::test]
10962 async fn h8b_subscribe_persists_hmac_secret() {
10963 let state = test_state();
10964 let app = Router::new()
10965 .route("/api/v1/subscriptions", axum_post(subscribe))
10966 .with_state(test_app_state(state.clone()));
10967
10968 let body = serde_json::json!({
10969 "url": "https://example.com/signed-hook",
10970 "events": "*",
10971 "secret": "topsecret-hmac-key",
10972 });
10973 let resp = app
10974 .oneshot(
10975 axum::http::Request::builder()
10976 .uri("/api/v1/subscriptions")
10977 .method("POST")
10978 .header("content-type", "application/json")
10979 .header("x-agent-id", "alice")
10980 .body(Body::from(serde_json::to_vec(&body).unwrap()))
10981 .unwrap(),
10982 )
10983 .await
10984 .unwrap();
10985 assert_eq!(resp.status(), StatusCode::CREATED);
10986 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
10987 .await
10988 .unwrap();
10989 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
10990 assert!(v.get("secret").is_none(), "secret leaked into response");
10992 let lock = state.lock().await;
10994 let subs = crate::subscriptions::list(&lock.0).unwrap();
10995 assert_eq!(subs.len(), 1);
10996 assert_eq!(subs[0].url, "https://example.com/signed-hook");
10997 }
10998
10999 #[tokio::test]
11004 async fn h8b_unsubscribe_by_id_happy_path() {
11005 let state = test_state();
11006 let id = {
11007 let lock = state.lock().await;
11008 crate::subscriptions::insert(
11009 &lock.0,
11010 &crate::subscriptions::NewSubscription {
11011 url: "https://example.com/h",
11012 events: "*",
11013 secret: None,
11014 namespace_filter: None,
11015 agent_filter: None,
11016 created_by: Some("alice"),
11017 event_types: None,
11018 },
11019 )
11020 .unwrap()
11021 };
11022
11023 let app = Router::new()
11024 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11025 .with_state(test_app_state(state.clone()));
11026
11027 let resp = app
11028 .oneshot(
11029 axum::http::Request::builder()
11030 .uri(format!("/api/v1/subscriptions?id={id}"))
11031 .method("DELETE")
11032 .body(Body::empty())
11033 .unwrap(),
11034 )
11035 .await
11036 .unwrap();
11037 assert_eq!(resp.status(), StatusCode::OK);
11038 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11039 .await
11040 .unwrap();
11041 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11042 assert_eq!(v["removed"], true);
11043
11044 let lock = state.lock().await;
11046 assert!(crate::subscriptions::list(&lock.0).unwrap().is_empty());
11047 }
11048
11049 #[tokio::test]
11052 async fn h8b_unsubscribe_nonexistent_id_returns_removed_false() {
11053 let state = test_state();
11054 let app = Router::new()
11055 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11056 .with_state(test_app_state(state));
11057
11058 let resp = app
11059 .oneshot(
11060 axum::http::Request::builder()
11061 .uri("/api/v1/subscriptions?id=does-not-exist")
11062 .method("DELETE")
11063 .body(Body::empty())
11064 .unwrap(),
11065 )
11066 .await
11067 .unwrap();
11068 assert_eq!(resp.status(), StatusCode::OK);
11069 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11070 .await
11071 .unwrap();
11072 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11073 assert_eq!(v["removed"], false);
11074 }
11075
11076 #[tokio::test]
11079 async fn h8b_unsubscribe_by_agent_and_namespace() {
11080 let state = test_state();
11081 {
11083 let lock = state.lock().await;
11084 crate::subscriptions::insert(
11085 &lock.0,
11086 &crate::subscriptions::NewSubscription {
11087 url: "http://localhost/_ns/alice/demo",
11088 events: "*",
11089 secret: None,
11090 namespace_filter: Some("demo"),
11091 agent_filter: Some("alice"),
11092 created_by: Some("alice"),
11093 event_types: None,
11094 },
11095 )
11096 .unwrap();
11097 }
11098
11099 let app = Router::new()
11100 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11101 .with_state(test_app_state(state.clone()));
11102
11103 let resp = app
11104 .oneshot(
11105 axum::http::Request::builder()
11106 .uri("/api/v1/subscriptions?namespace=demo")
11107 .method("DELETE")
11108 .header("x-agent-id", "alice")
11109 .body(Body::empty())
11110 .unwrap(),
11111 )
11112 .await
11113 .unwrap();
11114 assert_eq!(resp.status(), StatusCode::OK);
11115 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11116 .await
11117 .unwrap();
11118 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11119 assert_eq!(v["removed"], true);
11120 }
11121
11122 #[tokio::test]
11124 async fn h8b_unsubscribe_missing_id_and_namespace_rejected() {
11125 let state = test_state();
11126 let app = Router::new()
11127 .route("/api/v1/subscriptions", axum::routing::delete(unsubscribe))
11128 .with_state(test_app_state(state));
11129
11130 let resp = app
11131 .oneshot(
11132 axum::http::Request::builder()
11133 .uri("/api/v1/subscriptions")
11134 .method("DELETE")
11135 .header("x-agent-id", "alice")
11136 .body(Body::empty())
11137 .unwrap(),
11138 )
11139 .await
11140 .unwrap();
11141 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11142 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11143 .await
11144 .unwrap();
11145 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11146 assert!(
11147 v["error"]
11148 .as_str()
11149 .unwrap()
11150 .contains("id or (agent_id, namespace)"),
11151 );
11152 }
11153
11154 #[tokio::test]
11159 async fn h8b_list_subscriptions_returns_seeded_rows() {
11160 let state = test_state();
11161 {
11162 let lock = state.lock().await;
11163 crate::subscriptions::insert(
11164 &lock.0,
11165 &crate::subscriptions::NewSubscription {
11166 url: "https://example.com/a",
11167 events: "*",
11168 secret: None,
11169 namespace_filter: Some("ns1"),
11170 agent_filter: Some("alice"),
11171 created_by: Some("alice"),
11172 event_types: None,
11173 },
11174 )
11175 .unwrap();
11176 crate::subscriptions::insert(
11177 &lock.0,
11178 &crate::subscriptions::NewSubscription {
11179 url: "https://example.com/b",
11180 events: "memory.updated",
11181 secret: None,
11182 namespace_filter: Some("ns2"),
11183 agent_filter: Some("bob"),
11184 created_by: Some("bob"),
11185 event_types: None,
11186 },
11187 )
11188 .unwrap();
11189 }
11190
11191 let app = Router::new()
11192 .route(
11193 "/api/v1/subscriptions",
11194 axum::routing::get(list_subscriptions),
11195 )
11196 .with_state(state);
11197
11198 let resp = app
11199 .oneshot(
11200 axum::http::Request::builder()
11201 .uri("/api/v1/subscriptions")
11202 .body(Body::empty())
11203 .unwrap(),
11204 )
11205 .await
11206 .unwrap();
11207 assert_eq!(resp.status(), StatusCode::OK);
11208 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11209 .await
11210 .unwrap();
11211 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11212 assert_eq!(v["count"], 2);
11213 let subs = v["subscriptions"].as_array().unwrap();
11214 assert_eq!(subs.len(), 2);
11215 for s in subs {
11217 assert!(s["namespace"].is_string());
11218 assert!(s["namespace_filter"].is_string());
11219 assert!(s["id"].is_string());
11220 }
11221 }
11222
11223 #[tokio::test]
11227 async fn h8b_list_subscriptions_agent_id_filter_excludes_others() {
11228 let state = test_state();
11229 {
11230 let lock = state.lock().await;
11231 crate::subscriptions::insert(
11232 &lock.0,
11233 &crate::subscriptions::NewSubscription {
11234 url: "https://example.com/a",
11235 events: "*",
11236 secret: None,
11237 namespace_filter: Some("ns1"),
11238 agent_filter: Some("alice"),
11239 created_by: Some("alice"),
11240 event_types: None,
11241 },
11242 )
11243 .unwrap();
11244 crate::subscriptions::insert(
11245 &lock.0,
11246 &crate::subscriptions::NewSubscription {
11247 url: "https://example.com/b",
11248 events: "*",
11249 secret: None,
11250 namespace_filter: Some("ns2"),
11251 agent_filter: Some("bob"),
11252 created_by: Some("bob"),
11253 event_types: None,
11254 },
11255 )
11256 .unwrap();
11257 }
11258
11259 let app = Router::new()
11260 .route(
11261 "/api/v1/subscriptions",
11262 axum::routing::get(list_subscriptions),
11263 )
11264 .with_state(state);
11265
11266 let resp = app
11267 .oneshot(
11268 axum::http::Request::builder()
11269 .uri("/api/v1/subscriptions?agent_id=alice")
11270 .body(Body::empty())
11271 .unwrap(),
11272 )
11273 .await
11274 .unwrap();
11275 assert_eq!(resp.status(), StatusCode::OK);
11276 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11277 .await
11278 .unwrap();
11279 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11280 assert_eq!(v["count"], 1);
11281 assert_eq!(v["subscriptions"][0]["namespace"], "ns1");
11282 }
11283
11284 #[tokio::test]
11289 async fn h8b_notify_happy_path_creates_message() {
11290 let state = test_state();
11291 let app = Router::new()
11292 .route("/api/v1/notify", axum_post(notify))
11293 .with_state(test_app_state(state.clone()));
11294
11295 let body = serde_json::json!({
11296 "target_agent_id": "bob",
11297 "title": "Hi bob",
11298 "payload": "hello there",
11299 });
11300 let resp = app
11301 .oneshot(
11302 axum::http::Request::builder()
11303 .uri("/api/v1/notify")
11304 .method("POST")
11305 .header("content-type", "application/json")
11306 .header("x-agent-id", "alice")
11307 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11308 .unwrap(),
11309 )
11310 .await
11311 .unwrap();
11312 assert_eq!(resp.status(), StatusCode::CREATED);
11313 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11314 .await
11315 .unwrap();
11316 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11317 assert_eq!(v["to"], "bob");
11318 assert!(v["id"].as_str().is_some());
11319 assert!(v["delivered_at"].as_str().is_some());
11320
11321 let lock = state.lock().await;
11323 let rows = db::list(
11324 &lock.0,
11325 Some("_messages/bob"),
11326 None,
11327 10,
11328 0,
11329 None,
11330 None,
11331 None,
11332 None,
11333 None,
11334 )
11335 .unwrap();
11336 assert_eq!(rows.len(), 1);
11337 assert_eq!(rows[0].title, "Hi bob");
11338 }
11339
11340 #[tokio::test]
11344 async fn h8b_notify_missing_target_agent_id_rejected() {
11345 let state = test_state();
11346 let app = Router::new()
11347 .route("/api/v1/notify", axum_post(notify))
11348 .with_state(test_app_state(state));
11349
11350 let body = serde_json::json!({
11352 "title": "stray",
11353 "payload": "no target",
11354 });
11355 let resp = app
11356 .oneshot(
11357 axum::http::Request::builder()
11358 .uri("/api/v1/notify")
11359 .method("POST")
11360 .header("content-type", "application/json")
11361 .header("x-agent-id", "alice")
11362 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11363 .unwrap(),
11364 )
11365 .await
11366 .unwrap();
11367 assert!(
11369 resp.status() == StatusCode::UNPROCESSABLE_ENTITY
11370 || resp.status() == StatusCode::BAD_REQUEST,
11371 "expected 4xx for missing target_agent_id, got {}",
11372 resp.status(),
11373 );
11374 }
11375
11376 #[tokio::test]
11379 async fn h8b_notify_invalid_target_agent_id_rejected() {
11380 let state = test_state();
11381 let app = Router::new()
11382 .route("/api/v1/notify", axum_post(notify))
11383 .with_state(test_app_state(state));
11384
11385 let body = serde_json::json!({
11386 "target_agent_id": "bob with spaces",
11387 "title": "Hi",
11388 "payload": "hello",
11389 });
11390 let resp = app
11391 .oneshot(
11392 axum::http::Request::builder()
11393 .uri("/api/v1/notify")
11394 .method("POST")
11395 .header("content-type", "application/json")
11396 .header("x-agent-id", "alice")
11397 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11398 .unwrap(),
11399 )
11400 .await
11401 .unwrap();
11402 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11403 }
11404
11405 #[tokio::test]
11408 async fn h8b_notify_oversized_payload_rejected() {
11409 let state = test_state();
11410 let app = Router::new()
11411 .route("/api/v1/notify", axum_post(notify))
11412 .with_state(test_app_state(state));
11413
11414 let big = "a".repeat(65_537);
11416 let body = serde_json::json!({
11417 "target_agent_id": "bob",
11418 "title": "huge",
11419 "payload": big,
11420 });
11421 let resp = app
11422 .oneshot(
11423 axum::http::Request::builder()
11424 .uri("/api/v1/notify")
11425 .method("POST")
11426 .header("content-type", "application/json")
11427 .header("x-agent-id", "alice")
11428 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11429 .unwrap(),
11430 )
11431 .await
11432 .unwrap();
11433 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11434 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11435 .await
11436 .unwrap();
11437 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11438 assert!(
11439 v["error"].as_str().unwrap().contains("max"),
11440 "expected size-limit error, got {:?}",
11441 v["error"],
11442 );
11443 }
11444
11445 #[tokio::test]
11448 async fn h8b_notify_accepts_content_alias_for_payload() {
11449 let state = test_state();
11450 let app = Router::new()
11451 .route("/api/v1/notify", axum_post(notify))
11452 .with_state(test_app_state(state));
11453
11454 let body = serde_json::json!({
11455 "target_agent_id": "bob",
11456 "title": "alias",
11457 "content": "via the content field",
11458 });
11459 let resp = app
11460 .oneshot(
11461 axum::http::Request::builder()
11462 .uri("/api/v1/notify")
11463 .method("POST")
11464 .header("content-type", "application/json")
11465 .header("x-agent-id", "alice")
11466 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11467 .unwrap(),
11468 )
11469 .await
11470 .unwrap();
11471 assert_eq!(resp.status(), StatusCode::CREATED);
11472 }
11473
11474 #[tokio::test]
11478 async fn h8b_get_inbox_empty_returns_zero() {
11479 let state = test_state();
11480 let app = Router::new()
11481 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11482 .with_state(test_app_state(state));
11483
11484 let resp = app
11485 .oneshot(
11486 axum::http::Request::builder()
11487 .uri("/api/v1/inbox?agent_id=alice")
11488 .body(Body::empty())
11489 .unwrap(),
11490 )
11491 .await
11492 .unwrap();
11493 assert_eq!(resp.status(), StatusCode::OK);
11494 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11495 .await
11496 .unwrap();
11497 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11498 assert_eq!(v["count"], 0);
11499 assert_eq!(v["messages"].as_array().unwrap().len(), 0);
11500 }
11501
11502 #[tokio::test]
11506 async fn h8b_get_inbox_returns_pending_after_notify() {
11507 let state = test_state();
11508
11509 let notify_app = Router::new()
11511 .route("/api/v1/notify", axum_post(notify))
11512 .with_state(test_app_state(state.clone()));
11513 let notify_body = serde_json::json!({
11514 "target_agent_id": "bob",
11515 "title": "ping",
11516 "payload": "wake up",
11517 });
11518 let resp = notify_app
11519 .oneshot(
11520 axum::http::Request::builder()
11521 .uri("/api/v1/notify")
11522 .method("POST")
11523 .header("content-type", "application/json")
11524 .header("x-agent-id", "alice")
11525 .body(Body::from(serde_json::to_vec(¬ify_body).unwrap()))
11526 .unwrap(),
11527 )
11528 .await
11529 .unwrap();
11530 assert_eq!(resp.status(), StatusCode::CREATED);
11531
11532 let inbox_app = Router::new()
11534 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11535 .with_state(test_app_state(state));
11536 let resp = inbox_app
11537 .oneshot(
11538 axum::http::Request::builder()
11539 .uri("/api/v1/inbox?agent_id=bob")
11540 .body(Body::empty())
11541 .unwrap(),
11542 )
11543 .await
11544 .unwrap();
11545 assert_eq!(resp.status(), StatusCode::OK);
11546 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11547 .await
11548 .unwrap();
11549 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11550 assert_eq!(v["count"], 1);
11551 let msg = &v["messages"][0];
11552 assert_eq!(msg["title"], "ping");
11553 let from = msg["from"].as_str().unwrap();
11558 assert!(
11559 from == "alice" || from.starts_with("ai:alice@"),
11560 "unexpected sender: {from}",
11561 );
11562 assert_eq!(msg["read"], false);
11563 }
11564
11565 #[tokio::test]
11569 async fn h8b_get_inbox_unread_only_filter_excludes_read() {
11570 let state = test_state();
11571 {
11573 let lock = state.lock().await;
11574 let now = Utc::now().to_rfc3339();
11575 let unread = Memory {
11576 id: Uuid::new_v4().to_string(),
11577 tier: Tier::Mid,
11578 namespace: "_messages/alice".into(),
11579 title: "unread".into(),
11580 content: "u".into(),
11581 tags: vec!["_message".into()],
11582 priority: 5,
11583 confidence: 1.0,
11584 source: "notify".into(),
11585 access_count: 0,
11586 created_at: now.clone(),
11587 updated_at: now.clone(),
11588 last_accessed_at: None,
11589 expires_at: None,
11590 metadata: serde_json::json!({"agent_id": "bob"}),
11591 };
11592 let read = Memory {
11593 id: Uuid::new_v4().to_string(),
11594 tier: Tier::Mid,
11595 namespace: "_messages/alice".into(),
11596 title: "read".into(),
11597 content: "r".into(),
11598 tags: vec!["_message".into()],
11599 priority: 5,
11600 confidence: 1.0,
11601 source: "notify".into(),
11602 access_count: 5,
11603 created_at: now.clone(),
11604 updated_at: now,
11605 last_accessed_at: None,
11606 expires_at: None,
11607 metadata: serde_json::json!({"agent_id": "bob"}),
11608 };
11609 db::insert(&lock.0, &unread).unwrap();
11610 db::insert(&lock.0, &read).unwrap();
11611 }
11612
11613 let app = Router::new()
11614 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11615 .with_state(test_app_state(state));
11616 let resp = app
11617 .oneshot(
11618 axum::http::Request::builder()
11619 .uri("/api/v1/inbox?agent_id=alice&unread_only=true")
11620 .body(Body::empty())
11621 .unwrap(),
11622 )
11623 .await
11624 .unwrap();
11625 assert_eq!(resp.status(), StatusCode::OK);
11626 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11627 .await
11628 .unwrap();
11629 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11630 assert_eq!(v["count"], 1);
11631 assert_eq!(v["messages"][0]["title"], "unread");
11632 assert_eq!(v["unread_only"], true);
11633 }
11634
11635 #[tokio::test]
11637 async fn h8b_get_inbox_limit_clamps_returned_count() {
11638 let state = test_state();
11639 {
11640 let lock = state.lock().await;
11641 let now = Utc::now().to_rfc3339();
11642 for i in 0..3 {
11643 let mem = Memory {
11644 id: Uuid::new_v4().to_string(),
11645 tier: Tier::Mid,
11646 namespace: "_messages/alice".into(),
11647 title: format!("msg-{i}"),
11648 content: "c".into(),
11649 tags: vec!["_message".into()],
11650 priority: 5,
11651 confidence: 1.0,
11652 source: "notify".into(),
11653 access_count: 0,
11654 created_at: now.clone(),
11655 updated_at: now.clone(),
11656 last_accessed_at: None,
11657 expires_at: None,
11658 metadata: serde_json::json!({"agent_id": "carol"}),
11659 };
11660 db::insert(&lock.0, &mem).unwrap();
11661 }
11662 }
11663
11664 let app = Router::new()
11665 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11666 .with_state(test_app_state(state));
11667 let resp = app
11668 .oneshot(
11669 axum::http::Request::builder()
11670 .uri("/api/v1/inbox?agent_id=alice&limit=2")
11671 .body(Body::empty())
11672 .unwrap(),
11673 )
11674 .await
11675 .unwrap();
11676 assert_eq!(resp.status(), StatusCode::OK);
11677 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11678 .await
11679 .unwrap();
11680 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11681 assert_eq!(v["count"], 2);
11682 }
11683
11684 #[tokio::test]
11687 async fn h8b_get_inbox_invalid_agent_id_rejected() {
11688 let state = test_state();
11689 let app = Router::new()
11690 .route("/api/v1/inbox", axum::routing::get(get_inbox))
11691 .with_state(test_app_state(state));
11692
11693 let resp = app
11694 .oneshot(
11695 axum::http::Request::builder()
11696 .uri("/api/v1/inbox?agent_id=bad%20agent")
11697 .body(Body::empty())
11698 .unwrap(),
11699 )
11700 .await
11701 .unwrap();
11702 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
11703 }
11704
11705 #[tokio::test]
11710 async fn h8b_session_start_with_valid_agent_id_echoes() {
11711 let state = test_state();
11712 let app = Router::new()
11713 .route("/api/v1/session/start", axum_post(session_start))
11714 .with_state(state);
11715
11716 let body = serde_json::json!({"agent_id": "alice"});
11717 let resp = app
11718 .oneshot(
11719 axum::http::Request::builder()
11720 .uri("/api/v1/session/start")
11721 .method("POST")
11722 .header("content-type", "application/json")
11723 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11724 .unwrap(),
11725 )
11726 .await
11727 .unwrap();
11728 assert_eq!(resp.status(), StatusCode::OK);
11729 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11730 .await
11731 .unwrap();
11732 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11733 assert!(v["session_id"].as_str().is_some());
11734 assert_eq!(v["agent_id"], "alice");
11735 }
11736
11737 #[tokio::test]
11739 async fn h8b_session_start_namespace_filter() {
11740 let state = test_state();
11741 {
11743 let lock = state.lock().await;
11744 let now = Utc::now().to_rfc3339();
11745 for (ns, title) in [("target-ns", "in-scope"), ("other-ns", "out")] {
11746 let mem = Memory {
11747 id: Uuid::new_v4().to_string(),
11748 tier: Tier::Long,
11749 namespace: ns.into(),
11750 title: title.into(),
11751 content: "body".into(),
11752 tags: vec![],
11753 priority: 5,
11754 confidence: 1.0,
11755 source: "api".into(),
11756 access_count: 0,
11757 created_at: now.clone(),
11758 updated_at: now.clone(),
11759 last_accessed_at: None,
11760 expires_at: None,
11761 metadata: serde_json::json!({"agent_id": "alice"}),
11762 };
11763 db::insert(&lock.0, &mem).unwrap();
11764 }
11765 }
11766
11767 let app = Router::new()
11768 .route("/api/v1/session/start", axum_post(session_start))
11769 .with_state(state);
11770 let body = serde_json::json!({"namespace": "target-ns", "limit": 5});
11771 let resp = app
11772 .oneshot(
11773 axum::http::Request::builder()
11774 .uri("/api/v1/session/start")
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::OK);
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 let mems = v["memories"].as_array().unwrap();
11789 assert_eq!(mems.len(), 1);
11790 assert_eq!(mems[0]["title"], "in-scope");
11791 }
11792
11793 #[tokio::test]
11796 async fn h8b_session_start_returns_session_id_without_agent() {
11797 let state = test_state();
11798 let app = Router::new()
11799 .route("/api/v1/session/start", axum_post(session_start))
11800 .with_state(state);
11801 let body = serde_json::json!({});
11802 let resp = app
11803 .oneshot(
11804 axum::http::Request::builder()
11805 .uri("/api/v1/session/start")
11806 .method("POST")
11807 .header("content-type", "application/json")
11808 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11809 .unwrap(),
11810 )
11811 .await
11812 .unwrap();
11813 assert_eq!(resp.status(), StatusCode::OK);
11814 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11815 .await
11816 .unwrap();
11817 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11818 let sid = v["session_id"].as_str().unwrap();
11820 assert_eq!(sid.len(), 36);
11821 assert!(v.get("agent_id").is_none() || v["agent_id"].is_null());
11823 assert_eq!(v["mode"], "session_start");
11824 }
11825
11826 #[tokio::test]
11829 async fn h8b_session_start_preloads_recent_context() {
11830 let state = test_state();
11831 {
11832 let lock = state.lock().await;
11833 let now = Utc::now().to_rfc3339();
11834 let mem = Memory {
11835 id: Uuid::new_v4().to_string(),
11836 tier: Tier::Long,
11837 namespace: "global".into(),
11838 title: "preload-me".into(),
11839 content: "context".into(),
11840 tags: vec![],
11841 priority: 5,
11842 confidence: 1.0,
11843 source: "api".into(),
11844 access_count: 0,
11845 created_at: now.clone(),
11846 updated_at: now,
11847 last_accessed_at: None,
11848 expires_at: None,
11849 metadata: serde_json::json!({"agent_id": "alice"}),
11850 };
11851 db::insert(&lock.0, &mem).unwrap();
11852 }
11853
11854 let app = Router::new()
11855 .route("/api/v1/session/start", axum_post(session_start))
11856 .with_state(state);
11857 let body = serde_json::json!({"limit": 50});
11858 let resp = app
11859 .oneshot(
11860 axum::http::Request::builder()
11861 .uri("/api/v1/session/start")
11862 .method("POST")
11863 .header("content-type", "application/json")
11864 .body(Body::from(serde_json::to_vec(&body).unwrap()))
11865 .unwrap(),
11866 )
11867 .await
11868 .unwrap();
11869 assert_eq!(resp.status(), StatusCode::OK);
11870 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11871 .await
11872 .unwrap();
11873 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11874 let mems = v["memories"].as_array().unwrap();
11875 assert!(
11876 mems.iter().any(|m| m["title"] == "preload-me"),
11877 "session_start must preload recent memories",
11878 );
11879 }
11880 #[tokio::test]
11896 async fn http_list_agents_empty_returns_zero_count() {
11897 let state = test_state();
11899 let app = Router::new()
11900 .route("/api/v1/agents", axum_get(list_agents))
11901 .with_state(state);
11902 let resp = app
11903 .oneshot(
11904 axum::http::Request::builder()
11905 .uri("/api/v1/agents")
11906 .body(Body::empty())
11907 .unwrap(),
11908 )
11909 .await
11910 .unwrap();
11911 assert_eq!(resp.status(), StatusCode::OK);
11912 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11913 .await
11914 .unwrap();
11915 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11916 assert_eq!(v["count"], 0);
11917 assert_eq!(v["agents"].as_array().unwrap().len(), 0);
11918 }
11919
11920 #[tokio::test]
11921 async fn http_list_agents_returns_registered_rows() {
11922 let state = test_state();
11925 {
11926 let lock = state.lock().await;
11927 db::register_agent(&lock.0, "alice", "human", &["read".into(), "write".into()])
11928 .unwrap();
11929 db::register_agent(&lock.0, "bob", "ai:claude-opus-4.7", &["recall".into()]).unwrap();
11930 }
11931 let app = Router::new()
11932 .route("/api/v1/agents", axum_get(list_agents))
11933 .with_state(state);
11934 let resp = app
11935 .oneshot(
11936 axum::http::Request::builder()
11937 .uri("/api/v1/agents")
11938 .body(Body::empty())
11939 .unwrap(),
11940 )
11941 .await
11942 .unwrap();
11943 assert_eq!(resp.status(), StatusCode::OK);
11944 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
11945 .await
11946 .unwrap();
11947 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
11948 assert_eq!(v["count"], 2);
11949 let agents = v["agents"].as_array().unwrap();
11950 let ids: Vec<&str> = agents
11951 .iter()
11952 .filter_map(|a| a["agent_id"].as_str())
11953 .collect();
11954 assert!(ids.contains(&"alice"));
11955 assert!(ids.contains(&"bob"));
11956 }
11957
11958 #[tokio::test]
11959 async fn http_list_agents_includes_types_and_capabilities() {
11960 let state = test_state();
11963 {
11964 let lock = state.lock().await;
11965 db::register_agent(
11966 &lock.0,
11967 "alpha",
11968 "ai:claude-opus-4.7",
11969 &["read".into(), "store".into(), "recall".into()],
11970 )
11971 .unwrap();
11972 }
11973 let app = Router::new()
11974 .route("/api/v1/agents", axum_get(list_agents))
11975 .with_state(state);
11976 let resp = app
11977 .oneshot(
11978 axum::http::Request::builder()
11979 .uri("/api/v1/agents")
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 let agents = v["agents"].as_array().unwrap();
11991 assert_eq!(agents.len(), 1);
11992 let a = &agents[0];
11993 assert_eq!(a["agent_id"], "alpha");
11994 assert_eq!(a["agent_type"], "ai:claude-opus-4.7");
11995 let caps = a["capabilities"].as_array().unwrap();
11996 assert_eq!(caps.len(), 3);
11997 let cap_strs: Vec<&str> = caps.iter().filter_map(|c| c.as_str()).collect();
11998 assert!(cap_strs.contains(&"read"));
11999 assert!(cap_strs.contains(&"store"));
12000 assert!(cap_strs.contains(&"recall"));
12001 }
12002
12003 #[tokio::test]
12006 async fn http_register_agent_happy_path_returns_created() {
12007 let state = test_state();
12008 let app = Router::new()
12009 .route("/api/v1/agents", axum_post(register_agent))
12010 .with_state(test_app_state(state.clone()));
12011 let body = serde_json::json!({
12012 "agent_id": "alice",
12013 "agent_type": "human",
12014 "capabilities": ["read", "write"]
12015 });
12016 let resp = app
12017 .oneshot(
12018 axum::http::Request::builder()
12019 .uri("/api/v1/agents")
12020 .method("POST")
12021 .header("content-type", "application/json")
12022 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12023 .unwrap(),
12024 )
12025 .await
12026 .unwrap();
12027 assert_eq!(resp.status(), StatusCode::CREATED);
12028 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12029 .await
12030 .unwrap();
12031 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12032 assert_eq!(v["registered"], true);
12033 assert_eq!(v["agent_id"], "alice");
12034 assert_eq!(v["agent_type"], "human");
12035 let lock = state.lock().await;
12037 let agents = db::list_agents(&lock.0).unwrap();
12038 assert_eq!(agents.len(), 1);
12039 assert_eq!(agents[0].agent_id, "alice");
12040 }
12041
12042 #[tokio::test]
12043 async fn http_register_agent_missing_agent_type_400() {
12044 let state = test_state();
12047 let app = Router::new()
12048 .route("/api/v1/agents", axum_post(register_agent))
12049 .with_state(test_app_state(state));
12050 let body = serde_json::json!({
12051 "agent_id": "alice"
12052 });
12054 let resp = app
12055 .oneshot(
12056 axum::http::Request::builder()
12057 .uri("/api/v1/agents")
12058 .method("POST")
12059 .header("content-type", "application/json")
12060 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12061 .unwrap(),
12062 )
12063 .await
12064 .unwrap();
12065 assert!(
12066 resp.status().is_client_error(),
12067 "expected 4xx for missing agent_type, got {}",
12068 resp.status()
12069 );
12070 }
12071
12072 #[tokio::test]
12073 async fn http_register_agent_invalid_agent_id_with_space_400() {
12074 let state = test_state();
12076 let app = Router::new()
12077 .route("/api/v1/agents", axum_post(register_agent))
12078 .with_state(test_app_state(state));
12079 let body = serde_json::json!({
12080 "agent_id": "bad agent",
12081 "agent_type": "human",
12082 "capabilities": []
12083 });
12084 let resp = app
12085 .oneshot(
12086 axum::http::Request::builder()
12087 .uri("/api/v1/agents")
12088 .method("POST")
12089 .header("content-type", "application/json")
12090 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12091 .unwrap(),
12092 )
12093 .await
12094 .unwrap();
12095 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12096 }
12097
12098 #[tokio::test]
12099 async fn http_register_agent_duplicate_register_idempotent_preserves_registered_at() {
12100 let state = test_state();
12104 let app = Router::new()
12105 .route("/api/v1/agents", axum_post(register_agent))
12106 .with_state(test_app_state(state.clone()));
12107 let body = serde_json::json!({
12108 "agent_id": "twice",
12109 "agent_type": "human",
12110 "capabilities": ["read"]
12111 });
12112 let r1 = app
12113 .clone()
12114 .oneshot(
12115 axum::http::Request::builder()
12116 .uri("/api/v1/agents")
12117 .method("POST")
12118 .header("content-type", "application/json")
12119 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12120 .unwrap(),
12121 )
12122 .await
12123 .unwrap();
12124 assert_eq!(r1.status(), StatusCode::CREATED);
12125 let r2 = app
12126 .oneshot(
12127 axum::http::Request::builder()
12128 .uri("/api/v1/agents")
12129 .method("POST")
12130 .header("content-type", "application/json")
12131 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12132 .unwrap(),
12133 )
12134 .await
12135 .unwrap();
12136 assert_eq!(r2.status(), StatusCode::CREATED);
12137 let lock = state.lock().await;
12139 let agents = db::list_agents(&lock.0).unwrap();
12140 let twice: Vec<_> = agents.iter().filter(|a| a.agent_id == "twice").collect();
12141 assert_eq!(
12142 twice.len(),
12143 1,
12144 "duplicate register must collapse to one row"
12145 );
12146 }
12147
12148 #[tokio::test]
12149 async fn http_register_agent_capabilities_array_preserved() {
12150 let state = test_state();
12153 let app = Router::new()
12154 .route("/api/v1/agents", axum_post(register_agent))
12155 .with_state(test_app_state(state.clone()));
12156 let body = serde_json::json!({
12157 "agent_id": "capper",
12158 "agent_type": "ai:claude-opus-4.7",
12159 "capabilities": ["search", "store", "recall", "consolidate"]
12160 });
12161 let resp = app
12162 .oneshot(
12163 axum::http::Request::builder()
12164 .uri("/api/v1/agents")
12165 .method("POST")
12166 .header("content-type", "application/json")
12167 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12168 .unwrap(),
12169 )
12170 .await
12171 .unwrap();
12172 assert_eq!(resp.status(), StatusCode::CREATED);
12173 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12174 .await
12175 .unwrap();
12176 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12177 let echoed = v["capabilities"].as_array().unwrap();
12178 assert_eq!(echoed.len(), 4);
12179 let lock = state.lock().await;
12181 let agents = db::list_agents(&lock.0).unwrap();
12182 let me = agents.iter().find(|a| a.agent_id == "capper").unwrap();
12183 assert_eq!(me.capabilities.len(), 4);
12184 assert!(me.capabilities.contains(&"search".to_string()));
12185 assert!(me.capabilities.contains(&"store".to_string()));
12186 assert!(me.capabilities.contains(&"recall".to_string()));
12187 assert!(me.capabilities.contains(&"consolidate".to_string()));
12188 }
12189
12190 #[tokio::test]
12193 async fn http_list_pending_with_pending_actions_returns_them() {
12194 use crate::models::GovernedAction;
12196 let state = test_state();
12197 {
12198 let lock = state.lock().await;
12199 db::queue_pending_action(
12200 &lock.0,
12201 GovernedAction::Store,
12202 "ns-a",
12203 None,
12204 "alice",
12205 &serde_json::json!({"title": "first", "content": "c1"}),
12206 )
12207 .unwrap();
12208 db::queue_pending_action(
12209 &lock.0,
12210 GovernedAction::Store,
12211 "ns-b",
12212 None,
12213 "bob",
12214 &serde_json::json!({"title": "second", "content": "c2"}),
12215 )
12216 .unwrap();
12217 }
12218 let app = Router::new()
12219 .route("/api/v1/pending", axum_get(list_pending))
12220 .with_state(state);
12221 let resp = app
12222 .oneshot(
12223 axum::http::Request::builder()
12224 .uri("/api/v1/pending")
12225 .body(Body::empty())
12226 .unwrap(),
12227 )
12228 .await
12229 .unwrap();
12230 assert_eq!(resp.status(), StatusCode::OK);
12231 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12232 .await
12233 .unwrap();
12234 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12235 assert_eq!(v["count"], 2);
12236 assert_eq!(v["pending"].as_array().unwrap().len(), 2);
12237 }
12238
12239 #[tokio::test]
12240 async fn http_list_pending_filters_by_status_pending() {
12241 use crate::models::GovernedAction;
12242 let state = test_state();
12243 let kept_id = {
12244 let lock = state.lock().await;
12245 let id = db::queue_pending_action(
12247 &lock.0,
12248 GovernedAction::Store,
12249 "ns-keep",
12250 None,
12251 "alice",
12252 &serde_json::json!({"title": "stay", "content": "x"}),
12253 )
12254 .unwrap();
12255 let other = db::queue_pending_action(
12257 &lock.0,
12258 GovernedAction::Store,
12259 "ns-reject",
12260 None,
12261 "alice",
12262 &serde_json::json!({"title": "out", "content": "x"}),
12263 )
12264 .unwrap();
12265 db::decide_pending_action(&lock.0, &other, false, "alice").unwrap();
12266 id
12267 };
12268 let app = Router::new()
12269 .route("/api/v1/pending", axum_get(list_pending))
12270 .with_state(state);
12271 let resp = app
12272 .oneshot(
12273 axum::http::Request::builder()
12274 .uri("/api/v1/pending?status=pending")
12275 .body(Body::empty())
12276 .unwrap(),
12277 )
12278 .await
12279 .unwrap();
12280 assert_eq!(resp.status(), StatusCode::OK);
12281 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12282 .await
12283 .unwrap();
12284 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12285 let items = v["pending"].as_array().unwrap();
12286 assert_eq!(items.len(), 1);
12287 assert_eq!(items[0]["id"], kept_id);
12288 assert_eq!(items[0]["status"], "pending");
12289 }
12290
12291 #[tokio::test]
12292 async fn http_list_pending_filters_by_status_rejected() {
12293 use crate::models::GovernedAction;
12294 let state = test_state();
12295 {
12296 let lock = state.lock().await;
12297 let id = db::queue_pending_action(
12298 &lock.0,
12299 GovernedAction::Store,
12300 "ns-r",
12301 None,
12302 "alice",
12303 &serde_json::json!({"title": "rejected", "content": "x"}),
12304 )
12305 .unwrap();
12306 db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12307 db::queue_pending_action(
12309 &lock.0,
12310 GovernedAction::Store,
12311 "ns-p",
12312 None,
12313 "alice",
12314 &serde_json::json!({"title": "pending", "content": "x"}),
12315 )
12316 .unwrap();
12317 }
12318 let app = Router::new()
12319 .route("/api/v1/pending", axum_get(list_pending))
12320 .with_state(state);
12321 let resp = app
12322 .oneshot(
12323 axum::http::Request::builder()
12324 .uri("/api/v1/pending?status=rejected&limit=10")
12325 .body(Body::empty())
12326 .unwrap(),
12327 )
12328 .await
12329 .unwrap();
12330 assert_eq!(resp.status(), StatusCode::OK);
12331 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12332 .await
12333 .unwrap();
12334 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12335 let items = v["pending"].as_array().unwrap();
12336 assert_eq!(items.len(), 1);
12337 assert_eq!(items[0]["status"], "rejected");
12338 }
12339
12340 #[tokio::test]
12341 async fn http_list_pending_limit_clamped_to_1000() {
12342 let state = test_state();
12345 let app = Router::new()
12346 .route("/api/v1/pending", axum_get(list_pending))
12347 .with_state(state);
12348 let resp = app
12349 .oneshot(
12350 axum::http::Request::builder()
12351 .uri("/api/v1/pending?limit=99999")
12352 .body(Body::empty())
12353 .unwrap(),
12354 )
12355 .await
12356 .unwrap();
12357 assert_eq!(resp.status(), StatusCode::OK);
12358 }
12359
12360 #[tokio::test]
12363 async fn http_approve_pending_happy_path_executes_store() {
12364 use crate::models::GovernedAction;
12367 let state = test_state();
12368 let now_rfc = Utc::now().to_rfc3339();
12369 let pending_id = {
12370 let lock = state.lock().await;
12371 db::queue_pending_action(
12372 &lock.0,
12373 GovernedAction::Store,
12374 "approve-ns",
12375 None,
12376 "alice",
12377 &serde_json::json!({
12378 "id": Uuid::new_v4().to_string(),
12379 "tier": "long",
12380 "namespace": "approve-ns",
12381 "title": "approved-store",
12382 "content": "executed via approval",
12383 "tags": [],
12384 "priority": 5,
12385 "confidence": 1.0,
12386 "source": "api",
12387 "access_count": 0,
12388 "created_at": now_rfc,
12389 "updated_at": now_rfc,
12390 "metadata": {}
12391 }),
12392 )
12393 .unwrap()
12394 };
12395 let app = Router::new()
12396 .route("/api/v1/pending/{id}/approve", axum_post(approve_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/{pending_id}/approve"))
12402 .method("POST")
12403 .header("x-agent-id", "approver-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["approved"], true);
12415 assert_eq!(v["executed"], true);
12416 assert_eq!(v["decided_by"], "approver-alice");
12417 let lock = state.lock().await;
12419 let pa = db::get_pending_action(&lock.0, &pending_id)
12420 .unwrap()
12421 .unwrap();
12422 assert_eq!(pa.status, "approved");
12423 assert_eq!(pa.decided_by.as_deref(), Some("approver-alice"));
12424 }
12425
12426 #[tokio::test]
12427 async fn http_approve_pending_invalid_id_format_400() {
12428 let state = test_state();
12432 let app = Router::new()
12433 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12434 .with_state(test_app_state(state));
12435 let resp = app
12436 .oneshot(
12437 axum::http::Request::builder()
12438 .uri("/api/v1/pending/bad%01id/approve")
12439 .method("POST")
12440 .header("x-agent-id", "alice")
12441 .body(Body::empty())
12442 .unwrap(),
12443 )
12444 .await
12445 .unwrap();
12446 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12447 }
12448
12449 #[tokio::test]
12450 async fn http_approve_pending_already_approved_is_rejected() {
12451 use crate::models::GovernedAction;
12454 let state = test_state();
12455 let pid = {
12456 let lock = state.lock().await;
12457 let id = db::queue_pending_action(
12458 &lock.0,
12459 GovernedAction::Store,
12460 "double-approve",
12461 None,
12462 "alice",
12463 &serde_json::json!({
12464 "tier": "long",
12465 "namespace": "double-approve",
12466 "title": "store",
12467 "content": "x",
12468 "tags": [], "priority": 5, "confidence": 1.0,
12469 "source": "api", "metadata": {}
12470 }),
12471 )
12472 .unwrap();
12473 db::decide_pending_action(&lock.0, &id, true, "alice").unwrap();
12474 id
12475 };
12476 let app = Router::new()
12477 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12478 .with_state(test_app_state(state));
12479 let resp = app
12480 .oneshot(
12481 axum::http::Request::builder()
12482 .uri(format!("/api/v1/pending/{pid}/approve"))
12483 .method("POST")
12484 .header("x-agent-id", "alice")
12485 .body(Body::empty())
12486 .unwrap(),
12487 )
12488 .await
12489 .unwrap();
12490 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
12491 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12492 .await
12493 .unwrap();
12494 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12495 let err = v["error"].as_str().unwrap_or("");
12496 assert!(
12497 err.contains("already decided") || err.contains("rejected"),
12498 "expected already-decided message, got {err}"
12499 );
12500 }
12501
12502 #[tokio::test]
12503 async fn http_approve_pending_executor_records_decided_by() {
12504 use crate::models::GovernedAction;
12508 let state = test_state();
12509 let now_rfc = Utc::now().to_rfc3339();
12510 let pid = {
12511 let lock = state.lock().await;
12512 db::queue_pending_action(
12513 &lock.0,
12514 GovernedAction::Store,
12515 "executor-ns",
12516 None,
12517 "requester-bob",
12518 &serde_json::json!({
12519 "id": Uuid::new_v4().to_string(),
12520 "tier": "long",
12521 "namespace": "executor-ns",
12522 "title": "e",
12523 "content": "y",
12524 "tags": [], "priority": 5, "confidence": 1.0,
12525 "source": "api",
12526 "access_count": 0,
12527 "created_at": now_rfc,
12528 "updated_at": now_rfc,
12529 "metadata": {}
12530 }),
12531 )
12532 .unwrap()
12533 };
12534 let app = Router::new()
12535 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12536 .with_state(test_app_state(state.clone()));
12537 let resp = app
12538 .oneshot(
12539 axum::http::Request::builder()
12540 .uri(format!("/api/v1/pending/{pid}/approve"))
12541 .method("POST")
12542 .header("x-agent-id", "executor-claude")
12543 .body(Body::empty())
12544 .unwrap(),
12545 )
12546 .await
12547 .unwrap();
12548 assert_eq!(resp.status(), StatusCode::OK);
12549 let lock = state.lock().await;
12550 let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12551 assert_eq!(pa.requested_by, "requester-bob");
12552 assert_eq!(pa.decided_by.as_deref(), Some("executor-claude"));
12553 assert_eq!(pa.status, "approved");
12554 }
12555
12556 #[tokio::test]
12557 async fn http_approve_pending_returns_memory_id_for_store_payload() {
12558 use crate::models::GovernedAction;
12561 let state = test_state();
12562 let now_rfc = Utc::now().to_rfc3339();
12563 let pid = {
12564 let lock = state.lock().await;
12565 db::queue_pending_action(
12566 &lock.0,
12567 GovernedAction::Store,
12568 "executed-write",
12569 None,
12570 "alice",
12571 &serde_json::json!({
12572 "id": Uuid::new_v4().to_string(),
12573 "tier": "long",
12574 "namespace": "executed-write",
12575 "title": "executed-mem",
12576 "content": "this exists after approval",
12577 "tags": [], "priority": 5, "confidence": 1.0,
12578 "source": "api",
12579 "access_count": 0,
12580 "created_at": now_rfc,
12581 "updated_at": now_rfc,
12582 "metadata": {}
12583 }),
12584 )
12585 .unwrap()
12586 };
12587 let app = Router::new()
12588 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
12589 .with_state(test_app_state(state.clone()));
12590 let resp = app
12591 .oneshot(
12592 axum::http::Request::builder()
12593 .uri(format!("/api/v1/pending/{pid}/approve"))
12594 .method("POST")
12595 .header("x-agent-id", "alice")
12596 .body(Body::empty())
12597 .unwrap(),
12598 )
12599 .await
12600 .unwrap();
12601 assert_eq!(resp.status(), StatusCode::OK);
12602 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12603 .await
12604 .unwrap();
12605 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12606 let mem_id = v["memory_id"].as_str().expect("memory_id present");
12607 let lock = state.lock().await;
12608 let mem = db::get(&lock.0, mem_id).unwrap().expect("memory exists");
12609 assert_eq!(mem.title, "executed-mem");
12610 assert_eq!(mem.namespace, "executed-write");
12611 }
12612
12613 #[tokio::test]
12616 async fn http_reject_pending_happy_path_marks_rejected_no_execution() {
12617 use crate::models::GovernedAction;
12620 let state = test_state();
12621 let pid = {
12622 let lock = state.lock().await;
12623 db::queue_pending_action(
12624 &lock.0,
12625 GovernedAction::Store,
12626 "reject-ns",
12627 None,
12628 "alice",
12629 &serde_json::json!({
12630 "tier": "long",
12631 "namespace": "reject-ns",
12632 "title": "blocked",
12633 "content": "must not be created",
12634 "tags": [], "priority": 5, "confidence": 1.0,
12635 "source": "api", "metadata": {}
12636 }),
12637 )
12638 .unwrap()
12639 };
12640 let app = Router::new()
12641 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12642 .with_state(test_app_state(state.clone()));
12643 let resp = app
12644 .oneshot(
12645 axum::http::Request::builder()
12646 .uri(format!("/api/v1/pending/{pid}/reject"))
12647 .method("POST")
12648 .header("x-agent-id", "rejector-alice")
12649 .body(Body::empty())
12650 .unwrap(),
12651 )
12652 .await
12653 .unwrap();
12654 assert_eq!(resp.status(), StatusCode::OK);
12655 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12656 .await
12657 .unwrap();
12658 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12659 assert_eq!(v["rejected"], true);
12660 assert_eq!(v["decided_by"], "rejector-alice");
12661 let lock = state.lock().await;
12662 let pa = db::get_pending_action(&lock.0, &pid).unwrap().unwrap();
12663 assert_eq!(pa.status, "rejected");
12664 let rows = db::list(
12666 &lock.0,
12667 Some("reject-ns"),
12668 None,
12669 10,
12670 0,
12671 None,
12672 None,
12673 None,
12674 None,
12675 None,
12676 )
12677 .unwrap();
12678 assert!(
12679 rows.is_empty(),
12680 "rejection must not execute the queued payload"
12681 );
12682 }
12683
12684 #[tokio::test]
12685 async fn http_reject_pending_already_rejected_returns_404() {
12686 use crate::models::GovernedAction;
12689 let state = test_state();
12690 let pid = {
12691 let lock = state.lock().await;
12692 let id = db::queue_pending_action(
12693 &lock.0,
12694 GovernedAction::Store,
12695 "double-reject",
12696 None,
12697 "alice",
12698 &serde_json::json!({
12699 "tier": "long",
12700 "namespace": "double-reject",
12701 "title": "x",
12702 "content": "x",
12703 "tags": [], "priority": 5, "confidence": 1.0,
12704 "source": "api", "metadata": {}
12705 }),
12706 )
12707 .unwrap();
12708 db::decide_pending_action(&lock.0, &id, false, "alice").unwrap();
12709 id
12710 };
12711 let app = Router::new()
12712 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12713 .with_state(test_app_state(state));
12714 let resp = app
12715 .oneshot(
12716 axum::http::Request::builder()
12717 .uri(format!("/api/v1/pending/{pid}/reject"))
12718 .method("POST")
12719 .header("x-agent-id", "alice")
12720 .body(Body::empty())
12721 .unwrap(),
12722 )
12723 .await
12724 .unwrap();
12725 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
12726 }
12727
12728 #[tokio::test]
12729 async fn http_reject_pending_invalid_id_format_400() {
12730 let state = test_state();
12733 let app = Router::new()
12734 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
12735 .with_state(test_app_state(state));
12736 let resp = app
12737 .oneshot(
12738 axum::http::Request::builder()
12739 .uri("/api/v1/pending/bad%01id/reject")
12740 .method("POST")
12741 .header("x-agent-id", "alice")
12742 .body(Body::empty())
12743 .unwrap(),
12744 )
12745 .await
12746 .unwrap();
12747 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12748 }
12749
12750 #[tokio::test]
12753 async fn http_consolidate_two_into_one_happy_path() {
12754 let state = test_state();
12757 let now = Utc::now().to_rfc3339();
12758 let (id_a, id_b) = {
12759 let lock = state.lock().await;
12760 let mk = |title: &str| Memory {
12761 id: Uuid::new_v4().to_string(),
12762 tier: Tier::Long,
12763 namespace: "merge-ns".into(),
12764 title: title.into(),
12765 content: format!("body for {title}"),
12766 tags: vec![],
12767 priority: 5,
12768 confidence: 1.0,
12769 source: "test".into(),
12770 access_count: 0,
12771 created_at: now.clone(),
12772 updated_at: now.clone(),
12773 last_accessed_at: None,
12774 expires_at: None,
12775 metadata: serde_json::json!({"agent_id": "alice"}),
12776 };
12777 let a = db::insert(&lock.0, &mk("draft-a")).unwrap();
12778 let b = db::insert(&lock.0, &mk("draft-b")).unwrap();
12779 (a, b)
12780 };
12781 let app = Router::new()
12782 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12783 .with_state(test_app_state(state.clone()));
12784 let body = serde_json::json!({
12785 "ids": [id_a, id_b],
12786 "title": "merged-result",
12787 "summary": "a merge of two drafts",
12788 "namespace": "merge-ns",
12789 "tier": "long"
12790 });
12791 let resp = app
12792 .oneshot(
12793 axum::http::Request::builder()
12794 .uri("/api/v1/consolidate")
12795 .method("POST")
12796 .header("content-type", "application/json")
12797 .header("x-agent-id", "consolidator")
12798 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12799 .unwrap(),
12800 )
12801 .await
12802 .unwrap();
12803 assert_eq!(resp.status(), StatusCode::CREATED);
12804 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12805 .await
12806 .unwrap();
12807 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12808 assert_eq!(v["consolidated"], 2);
12809 let new_id = v["id"].as_str().unwrap();
12810 let lock = state.lock().await;
12811 let merged = db::get(&lock.0, new_id).unwrap().unwrap();
12812 assert_eq!(merged.title, "merged-result");
12813 assert_eq!(merged.namespace, "merge-ns");
12814 assert!(db::get(&lock.0, &id_a).unwrap().is_none());
12816 assert!(db::get(&lock.0, &id_b).unwrap().is_none());
12817 }
12818
12819 #[tokio::test]
12820 async fn http_consolidate_single_id_400() {
12821 let state = test_state();
12824 let app = Router::new()
12825 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12826 .with_state(test_app_state(state));
12827 let body = serde_json::json!({
12828 "ids": [Uuid::new_v4().to_string()],
12829 "title": "lone-merge",
12830 "summary": "only one source",
12831 "namespace": "merge-ns"
12832 });
12833 let resp = app
12834 .oneshot(
12835 axum::http::Request::builder()
12836 .uri("/api/v1/consolidate")
12837 .method("POST")
12838 .header("content-type", "application/json")
12839 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12840 .unwrap(),
12841 )
12842 .await
12843 .unwrap();
12844 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12845 }
12846
12847 #[tokio::test]
12848 async fn http_consolidate_invalid_namespace_400() {
12849 let state = test_state();
12851 let app = Router::new()
12852 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12853 .with_state(test_app_state(state));
12854 let body = serde_json::json!({
12855 "ids": [Uuid::new_v4().to_string(), Uuid::new_v4().to_string()],
12856 "title": "merge",
12857 "summary": "x",
12858 "namespace": "bad ns"
12859 });
12860 let resp = app
12861 .oneshot(
12862 axum::http::Request::builder()
12863 .uri("/api/v1/consolidate")
12864 .method("POST")
12865 .header("content-type", "application/json")
12866 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12867 .unwrap(),
12868 )
12869 .await
12870 .unwrap();
12871 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12872 }
12873
12874 #[tokio::test]
12875 async fn http_consolidate_invalid_agent_id_400() {
12876 let state = test_state();
12878 let id_a = Uuid::new_v4().to_string();
12879 let id_b = Uuid::new_v4().to_string();
12880 let app = Router::new()
12881 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12882 .with_state(test_app_state(state));
12883 let body = serde_json::json!({
12884 "ids": [id_a, id_b],
12885 "title": "merge",
12886 "summary": "x",
12887 "namespace": "merge-ns"
12888 });
12889 let resp = app
12890 .oneshot(
12891 axum::http::Request::builder()
12892 .uri("/api/v1/consolidate")
12893 .method("POST")
12894 .header("content-type", "application/json")
12895 .header("x-agent-id", "bad agent id")
12896 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12897 .unwrap(),
12898 )
12899 .await
12900 .unwrap();
12901 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12902 }
12903
12904 #[tokio::test]
12905 async fn http_consolidate_max_id_count_cap_exceeded_400() {
12906 let state = test_state();
12908 let ids: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
12909 let app = Router::new()
12910 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12911 .with_state(test_app_state(state));
12912 let body = serde_json::json!({
12913 "ids": ids,
12914 "title": "too-many",
12915 "summary": "x",
12916 "namespace": "merge-ns"
12917 });
12918 let resp = app
12919 .oneshot(
12920 axum::http::Request::builder()
12921 .uri("/api/v1/consolidate")
12922 .method("POST")
12923 .header("content-type", "application/json")
12924 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12925 .unwrap(),
12926 )
12927 .await
12928 .unwrap();
12929 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
12930 }
12931
12932 #[tokio::test]
12933 async fn http_consolidate_missing_source_500() {
12934 let state = test_state();
12938 let id_a = Uuid::new_v4().to_string();
12939 let id_b = Uuid::new_v4().to_string();
12940 let app = Router::new()
12941 .route("/api/v1/consolidate", axum_post(consolidate_memories))
12942 .with_state(test_app_state(state));
12943 let body = serde_json::json!({
12944 "ids": [id_a, id_b],
12945 "title": "merge",
12946 "summary": "x",
12947 "namespace": "merge-ns"
12948 });
12949 let resp = app
12950 .oneshot(
12951 axum::http::Request::builder()
12952 .uri("/api/v1/consolidate")
12953 .method("POST")
12954 .header("content-type", "application/json")
12955 .body(Body::from(serde_json::to_vec(&body).unwrap()))
12956 .unwrap(),
12957 )
12958 .await
12959 .unwrap();
12960 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
12961 }
12962
12963 #[tokio::test]
12966 async fn http_contradictions_empty_no_pairs() {
12967 let state = test_state();
12970 let app = Router::new()
12971 .route("/api/v1/contradictions", axum_get(detect_contradictions))
12972 .with_state(state);
12973 let resp = app
12974 .oneshot(
12975 axum::http::Request::builder()
12976 .uri("/api/v1/contradictions?namespace=empty-ns")
12977 .body(Body::empty())
12978 .unwrap(),
12979 )
12980 .await
12981 .unwrap();
12982 assert_eq!(resp.status(), StatusCode::OK);
12983 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
12984 .await
12985 .unwrap();
12986 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
12987 assert_eq!(v["memories"].as_array().unwrap().len(), 0);
12988 assert_eq!(v["links"].as_array().unwrap().len(), 0);
12989 }
12990
12991 #[tokio::test]
12992 async fn http_contradictions_synthesizes_links_for_same_title() {
12993 let state = test_state();
12996 let now = Utc::now().to_rfc3339();
12997 {
12998 let lock = state.lock().await;
12999 let mk = |title: &str, content: &str| Memory {
13001 id: Uuid::new_v4().to_string(),
13002 tier: Tier::Long,
13003 namespace: "contradict-ns".into(),
13004 title: title.into(),
13005 content: content.into(),
13006 tags: vec![],
13007 priority: 5,
13008 confidence: 1.0,
13009 source: "api".into(),
13010 access_count: 0,
13011 created_at: now.clone(),
13012 updated_at: now.clone(),
13013 last_accessed_at: None,
13014 expires_at: None,
13015 metadata: serde_json::json!({"topic": "earth-shape"}),
13016 };
13017 db::insert(&lock.0, &mk("alice-says", "earth is round")).unwrap();
13018 db::insert(&lock.0, &mk("bob-says", "earth is flat")).unwrap();
13019 }
13020 let app = Router::new()
13021 .route("/api/v1/contradictions", axum_get(detect_contradictions))
13022 .with_state(state);
13023 let resp = app
13024 .oneshot(
13025 axum::http::Request::builder()
13026 .uri("/api/v1/contradictions?namespace=contradict-ns&topic=earth-shape")
13027 .body(Body::empty())
13028 .unwrap(),
13029 )
13030 .await
13031 .unwrap();
13032 assert_eq!(resp.status(), StatusCode::OK);
13033 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13034 .await
13035 .unwrap();
13036 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13037 let memories = v["memories"].as_array().unwrap();
13038 assert_eq!(memories.len(), 2);
13039 let links = v["links"].as_array().unwrap();
13040 assert!(links.iter().any(|l| {
13041 l["relation"].as_str() == Some("contradicts")
13042 && l["synthesized"].as_bool() == Some(true)
13043 }));
13044 }
13045
13046 #[tokio::test]
13047 async fn http_contradictions_namespace_filter_isolates_results() {
13048 let state = test_state();
13051 let now = Utc::now().to_rfc3339();
13052 {
13053 let lock = state.lock().await;
13054 let mk = |ns: &str, content: &str| Memory {
13055 id: Uuid::new_v4().to_string(),
13056 tier: Tier::Long,
13057 namespace: ns.into(),
13058 title: "shared-topic".into(),
13059 content: content.into(),
13060 tags: vec![],
13061 priority: 5,
13062 confidence: 1.0,
13063 source: "api".into(),
13064 access_count: 0,
13065 created_at: now.clone(),
13066 updated_at: now.clone(),
13067 last_accessed_at: None,
13068 expires_at: None,
13069 metadata: serde_json::json!({}),
13070 };
13071 db::insert(&lock.0, &mk("ns-iso-a", "first opinion")).unwrap();
13072 db::insert(&lock.0, &mk("ns-iso-b", "different opinion")).unwrap();
13073 }
13074 let app = Router::new()
13075 .route("/api/v1/contradictions", axum_get(detect_contradictions))
13076 .with_state(state);
13077 let resp = app
13078 .oneshot(
13079 axum::http::Request::builder()
13080 .uri("/api/v1/contradictions?namespace=ns-iso-a")
13081 .body(Body::empty())
13082 .unwrap(),
13083 )
13084 .await
13085 .unwrap();
13086 assert_eq!(resp.status(), StatusCode::OK);
13087 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13088 .await
13089 .unwrap();
13090 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13091 let memories = v["memories"].as_array().unwrap();
13092 assert_eq!(memories.len(), 1, "ns filter must isolate results");
13093 assert_eq!(memories[0]["namespace"], "ns-iso-a");
13094 }
13095
13096 #[tokio::test]
13097 async fn http_contradictions_invalid_namespace_400() {
13098 let state = test_state();
13101 let app = Router::new()
13102 .route("/api/v1/contradictions", axum_get(detect_contradictions))
13103 .with_state(state);
13104 let resp = app
13105 .oneshot(
13106 axum::http::Request::builder()
13107 .uri("/api/v1/contradictions?namespace=bad%20ns")
13108 .body(Body::empty())
13109 .unwrap(),
13110 )
13111 .await
13112 .unwrap();
13113 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13114 }
13115
13116 #[tokio::test]
13119 async fn http_capabilities_returns_expected_shape() {
13120 let state = test_state();
13123 let app = Router::new()
13124 .route("/api/v1/capabilities", axum_get(get_capabilities))
13125 .with_state(test_app_state(state));
13126 let resp = app
13127 .oneshot(
13128 axum::http::Request::builder()
13129 .uri("/api/v1/capabilities")
13130 .body(Body::empty())
13131 .unwrap(),
13132 )
13133 .await
13134 .unwrap();
13135 assert_eq!(resp.status(), StatusCode::OK);
13136 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13137 .await
13138 .unwrap();
13139 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13140 assert!(v.get("tier").is_some(), "missing `tier`");
13141 assert!(v.get("version").is_some(), "missing `version`");
13142 assert!(v.get("features").is_some(), "missing `features`");
13143 assert!(v.get("models").is_some(), "missing `models`");
13144 assert_eq!(v["features"]["keyword_search"], true);
13146 assert_eq!(v["features"]["semantic_search"], false);
13147 assert_eq!(v["features"]["query_expansion"], false);
13148 }
13149
13150 #[tokio::test]
13154 async fn http_capabilities_v2_schema_includes_all_blocks() {
13155 let state = test_state();
13156 let app = Router::new()
13157 .route("/api/v1/capabilities", axum_get(get_capabilities))
13158 .with_state(test_app_state(state));
13159 let resp = app
13160 .oneshot(
13161 axum::http::Request::builder()
13162 .uri("/api/v1/capabilities")
13163 .body(Body::empty())
13164 .unwrap(),
13165 )
13166 .await
13167 .unwrap();
13168 assert_eq!(resp.status(), StatusCode::OK);
13169 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13170 .await
13171 .unwrap();
13172 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13173
13174 assert_eq!(v["schema_version"], "2");
13175
13176 assert!(v["permissions"].is_object());
13178 assert_eq!(v["permissions"]["mode"], "advisory");
13179 assert!(v["permissions"]["active_rules"].is_number());
13180 assert!(v["permissions"].get("rule_summary").is_none());
13181 assert_eq!(v["permissions"]["inheritance"], "enforced");
13183
13184 assert!(v["hooks"].is_object());
13186 assert!(v["hooks"]["registered_count"].is_number());
13187 assert!(v["hooks"].get("by_event").is_none());
13188
13189 assert!(v["compaction"].is_object());
13191 assert_eq!(v["compaction"]["planned"], true);
13192 assert_eq!(v["compaction"]["enabled"], false);
13193 assert_eq!(v["compaction"]["version"], "v0.8+");
13194
13195 assert!(v["approval"].is_object());
13197 assert!(v["approval"]["pending_requests"].is_number());
13198 assert!(v["approval"].get("subscribers").is_none());
13199 assert!(v["approval"].get("default_timeout_seconds").is_none());
13200
13201 assert!(v["transcripts"].is_object());
13203 assert_eq!(v["transcripts"]["planned"], true);
13204 assert_eq!(v["transcripts"]["enabled"], false);
13205
13206 assert_eq!(v["features"]["recall_mode_active"], "disabled");
13209 assert_eq!(v["features"]["reranker_active"], "off");
13210 assert_eq!(v["features"]["memory_reflection"]["planned"], true);
13212 }
13213
13214 #[tokio::test]
13215 async fn http_capabilities_version_matches_pkg_version() {
13216 let state = test_state();
13219 let app = Router::new()
13220 .route("/api/v1/capabilities", axum_get(get_capabilities))
13221 .with_state(test_app_state(state));
13222 let resp = app
13223 .oneshot(
13224 axum::http::Request::builder()
13225 .uri("/api/v1/capabilities")
13226 .body(Body::empty())
13227 .unwrap(),
13228 )
13229 .await
13230 .unwrap();
13231 assert_eq!(resp.status(), StatusCode::OK);
13232 let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
13233 .await
13234 .unwrap();
13235 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13236 assert_eq!(v["version"], env!("CARGO_PKG_VERSION"));
13237 assert_eq!(v["tier"], "keyword");
13238 }
13239 async fn h8d_spawn_mock_peer(
13260 behaviour: H8dPeerBehaviour,
13261 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
13262 use std::sync::atomic::{AtomicUsize, Ordering};
13263 use tokio::net::TcpListener;
13264
13265 let count = Arc::new(AtomicUsize::new(0));
13266 let count_for_peer = count.clone();
13267 #[derive(Clone)]
13268 struct PeerState {
13269 count: Arc<AtomicUsize>,
13270 behaviour: H8dPeerBehaviour,
13271 }
13272 async fn handler(
13273 axum::extract::State(s): axum::extract::State<PeerState>,
13274 Json(_body): Json<serde_json::Value>,
13275 ) -> (StatusCode, Json<serde_json::Value>) {
13276 s.count.fetch_add(1, Ordering::Relaxed);
13277 match s.behaviour {
13278 H8dPeerBehaviour::Ack => (
13279 StatusCode::OK,
13280 Json(json!({"applied": 1, "noop": 0, "skipped": 0})),
13281 ),
13282 H8dPeerBehaviour::Fail500 => (
13283 StatusCode::INTERNAL_SERVER_ERROR,
13284 Json(json!({"error": "stub failure"})),
13285 ),
13286 H8dPeerBehaviour::Fail503 => (
13287 StatusCode::SERVICE_UNAVAILABLE,
13288 Json(json!({"error": "stub unavailable"})),
13289 ),
13290 H8dPeerBehaviour::Fail400 => (
13291 StatusCode::BAD_REQUEST,
13292 Json(json!({"error": "stub bad request"})),
13293 ),
13294 H8dPeerBehaviour::Hang => {
13295 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
13296 (StatusCode::OK, Json(json!({"applied": 1})))
13297 }
13298 }
13299 }
13300 let app = Router::new()
13301 .route("/api/v1/sync/push", axum_post(handler))
13302 .with_state(PeerState {
13303 count: count_for_peer,
13304 behaviour,
13305 });
13306 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
13307 let addr = listener.local_addr().unwrap();
13308 tokio::spawn(async move {
13309 axum::serve(listener, app).await.ok();
13310 });
13311 (format!("http://{addr}"), count)
13312 }
13313
13314 #[derive(Clone, Copy)]
13315 enum H8dPeerBehaviour {
13316 Ack,
13318 Fail500,
13320 Fail503,
13322 Fail400,
13324 Hang,
13327 }
13328
13329 fn h8d_app_state_with_fed(
13333 db: Db,
13334 peer_urls: Vec<String>,
13335 w: usize,
13336 timeout_ms: u64,
13337 ) -> AppState {
13338 let fed = crate::federation::FederationConfig::build(
13339 w,
13340 &peer_urls,
13341 std::time::Duration::from_millis(timeout_ms),
13342 None,
13343 None,
13344 None,
13345 "ai:h8d-test".to_string(),
13346 )
13347 .unwrap()
13348 .expect("federation must be built");
13349 AppState {
13350 db,
13351 embedder: Arc::new(None),
13352 vector_index: Arc::new(Mutex::new(None)),
13353 federation: Arc::new(Some(fed)),
13354 tier_config: Arc::new(crate::config::FeatureTier::Keyword.config()),
13355 scoring: Arc::new(crate::config::ResolvedScoring::default()),
13356 }
13357 }
13358
13359 #[tokio::test]
13362 async fn http_get_namespace_standard_qs_returns_standard_for_existing_ns() {
13363 let state = test_state();
13367 let app_state = test_app_state(state.clone());
13368 let set_router = Router::new()
13369 .route(
13370 "/api/v1/namespaces/{ns}/standard",
13371 axum_post(set_namespace_standard),
13372 )
13373 .with_state(app_state);
13374 let resp = set_router
13375 .oneshot(
13376 axum::http::Request::builder()
13377 .uri("/api/v1/namespaces/qs-existing/standard")
13378 .method("POST")
13379 .header("content-type", "application/json")
13380 .body(Body::from(serde_json::to_vec(&json!({})).unwrap()))
13381 .unwrap(),
13382 )
13383 .await
13384 .unwrap();
13385 assert_eq!(resp.status(), StatusCode::CREATED);
13386
13387 let get_router = Router::new()
13390 .route(
13391 "/api/v1/namespaces",
13392 axum::routing::get(get_namespace_standard_qs),
13393 )
13394 .with_state(state);
13395 let resp = get_router
13396 .oneshot(
13397 axum::http::Request::builder()
13398 .uri("/api/v1/namespaces?namespace=qs-existing")
13399 .body(Body::empty())
13400 .unwrap(),
13401 )
13402 .await
13403 .unwrap();
13404 assert_eq!(resp.status(), StatusCode::OK);
13405 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13406 .await
13407 .unwrap();
13408 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13409 assert_eq!(v["namespace"], "qs-existing");
13410 assert!(v["standard_id"].is_string(), "standard_id must be set");
13411 }
13412
13413 #[tokio::test]
13414 async fn http_get_namespace_standard_qs_returns_null_for_missing_ns_record() {
13415 let state = test_state();
13419 let app = Router::new()
13420 .route(
13421 "/api/v1/namespaces",
13422 axum::routing::get(get_namespace_standard_qs),
13423 )
13424 .with_state(state);
13425 let resp = app
13426 .oneshot(
13427 axum::http::Request::builder()
13428 .uri("/api/v1/namespaces?namespace=qs-never-set")
13429 .body(Body::empty())
13430 .unwrap(),
13431 )
13432 .await
13433 .unwrap();
13434 assert_eq!(resp.status(), StatusCode::OK);
13435 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13436 .await
13437 .unwrap();
13438 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13439 assert_eq!(v["namespace"], "qs-never-set");
13440 assert!(
13441 v["standard_id"].is_null(),
13442 "standard_id must be null for an unset namespace"
13443 );
13444 }
13445
13446 #[tokio::test]
13447 async fn http_get_namespace_standard_qs_falls_through_to_list_on_missing_param() {
13448 let state = test_state();
13453 let app = Router::new()
13454 .route(
13455 "/api/v1/namespaces",
13456 axum::routing::get(get_namespace_standard_qs),
13457 )
13458 .with_state(state);
13459 let resp = app
13460 .oneshot(
13461 axum::http::Request::builder()
13462 .uri("/api/v1/namespaces")
13463 .body(Body::empty())
13464 .unwrap(),
13465 )
13466 .await
13467 .unwrap();
13468 assert_eq!(resp.status(), StatusCode::OK);
13469 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13470 .await
13471 .unwrap();
13472 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13473 assert!(
13474 v["namespaces"].is_array(),
13475 "fallthrough must produce the list shape, got {v:?}"
13476 );
13477 }
13478
13479 #[tokio::test]
13480 async fn http_get_namespace_standard_qs_inherit_flag_returns_chain() {
13481 let state = test_state();
13484 let app = Router::new()
13485 .route(
13486 "/api/v1/namespaces",
13487 axum::routing::get(get_namespace_standard_qs),
13488 )
13489 .with_state(state);
13490 let resp = app
13491 .oneshot(
13492 axum::http::Request::builder()
13493 .uri("/api/v1/namespaces?namespace=child&inherit=true")
13494 .body(Body::empty())
13495 .unwrap(),
13496 )
13497 .await
13498 .unwrap();
13499 assert_eq!(resp.status(), StatusCode::OK);
13500 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13501 .await
13502 .unwrap();
13503 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13504 assert!(v["chain"].is_array(), "inherit must surface the chain");
13505 assert!(v["standards"].is_array());
13506 }
13507
13508 #[tokio::test]
13509 async fn http_get_namespace_standard_qs_invalid_namespace_returns_400() {
13510 let state = test_state();
13514 let app = Router::new()
13515 .route(
13516 "/api/v1/namespaces",
13517 axum::routing::get(get_namespace_standard_qs),
13518 )
13519 .with_state(state);
13520 let resp = app
13522 .oneshot(
13523 axum::http::Request::builder()
13524 .uri("/api/v1/namespaces?namespace=bad%20ns")
13525 .body(Body::empty())
13526 .unwrap(),
13527 )
13528 .await
13529 .unwrap();
13530 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13531 }
13532
13533 #[tokio::test]
13536 async fn http_set_namespace_standard_qs_happy_path_creates_placeholder() {
13537 let state = test_state();
13541 let app = Router::new()
13542 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13543 .with_state(test_app_state(state.clone()));
13544 let body = json!({"namespace": "qs-set-happy"});
13545 let resp = app
13546 .oneshot(
13547 axum::http::Request::builder()
13548 .uri("/api/v1/namespaces")
13549 .method("POST")
13550 .header("content-type", "application/json")
13551 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13552 .unwrap(),
13553 )
13554 .await
13555 .unwrap();
13556 assert_eq!(resp.status(), StatusCode::CREATED);
13557 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13558 .await
13559 .unwrap();
13560 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13561 assert_eq!(v["namespace"], "qs-set-happy");
13562 assert_eq!(v["set"], true);
13563 assert!(v["standard_id"].is_string());
13564 }
13565
13566 #[tokio::test]
13567 async fn http_set_namespace_standard_qs_missing_namespace_returns_400() {
13568 let state = test_state();
13571 let app = Router::new()
13572 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13573 .with_state(test_app_state(state));
13574 let body = json!({"governance": {"approver": "human"}});
13575 let resp = app
13576 .oneshot(
13577 axum::http::Request::builder()
13578 .uri("/api/v1/namespaces")
13579 .method("POST")
13580 .header("content-type", "application/json")
13581 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13582 .unwrap(),
13583 )
13584 .await
13585 .unwrap();
13586 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13587 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13588 .await
13589 .unwrap();
13590 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13591 assert!(
13592 v["error"].as_str().unwrap_or("").contains("namespace"),
13593 "error must mention the missing namespace, got {v:?}"
13594 );
13595 }
13596
13597 #[tokio::test]
13598 async fn http_set_namespace_standard_qs_invalid_governance_returns_400() {
13599 let state = test_state();
13602 let mem_id = {
13603 let lock = state.lock().await;
13604 let now = Utc::now().to_rfc3339();
13605 let mem = Memory {
13606 id: Uuid::new_v4().to_string(),
13607 tier: Tier::Long,
13608 namespace: "qs-set-bad-policy".into(),
13609 title: "anchor".into(),
13610 content: "anchor".into(),
13611 tags: vec![],
13612 priority: 5,
13613 confidence: 1.0,
13614 source: "test".into(),
13615 access_count: 0,
13616 created_at: now.clone(),
13617 updated_at: now,
13618 last_accessed_at: None,
13619 expires_at: None,
13620 metadata: serde_json::json!({}),
13621 };
13622 db::insert(&lock.0, &mem).unwrap()
13623 };
13624 let app = Router::new()
13625 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13626 .with_state(test_app_state(state));
13627 let body = json!({
13629 "namespace": "qs-set-bad-policy",
13630 "id": mem_id,
13631 "governance": {
13632 "approver": {"consensus": 0},
13633 "write": "approve",
13634 "promote": "log",
13635 "delete": "log"
13636 }
13637 });
13638 let resp = app
13639 .oneshot(
13640 axum::http::Request::builder()
13641 .uri("/api/v1/namespaces")
13642 .method("POST")
13643 .header("content-type", "application/json")
13644 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13645 .unwrap(),
13646 )
13647 .await
13648 .unwrap();
13649 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13650 }
13651
13652 #[tokio::test]
13653 async fn http_set_namespace_standard_qs_nested_standard_payload_works() {
13654 let state = test_state();
13658 let app = Router::new()
13659 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13660 .with_state(test_app_state(state));
13661 let body = json!({"standard": {"namespace": "qs-nested-ns"}});
13662 let resp = app
13663 .oneshot(
13664 axum::http::Request::builder()
13665 .uri("/api/v1/namespaces")
13666 .method("POST")
13667 .header("content-type", "application/json")
13668 .body(Body::from(serde_json::to_vec(&body).unwrap()))
13669 .unwrap(),
13670 )
13671 .await
13672 .unwrap();
13673 assert_eq!(resp.status(), StatusCode::CREATED);
13674 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13675 .await
13676 .unwrap();
13677 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13678 assert_eq!(v["namespace"], "qs-nested-ns");
13679 }
13680
13681 #[tokio::test]
13684 async fn http_clear_namespace_standard_qs_happy_path_after_set() {
13685 let state = test_state();
13687 let app_state = test_app_state(state.clone());
13688 let set_router = Router::new()
13689 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13690 .with_state(app_state.clone());
13691 let _ = set_router
13692 .oneshot(
13693 axum::http::Request::builder()
13694 .uri("/api/v1/namespaces")
13695 .method("POST")
13696 .header("content-type", "application/json")
13697 .body(Body::from(
13698 serde_json::to_vec(&json!({"namespace": "qs-clear-happy"})).unwrap(),
13699 ))
13700 .unwrap(),
13701 )
13702 .await
13703 .unwrap();
13704
13705 let clear_router = Router::new()
13706 .route(
13707 "/api/v1/namespaces",
13708 axum::routing::delete(clear_namespace_standard_qs),
13709 )
13710 .with_state(app_state);
13711 let resp = clear_router
13712 .oneshot(
13713 axum::http::Request::builder()
13714 .uri("/api/v1/namespaces?namespace=qs-clear-happy")
13715 .method("DELETE")
13716 .body(Body::empty())
13717 .unwrap(),
13718 )
13719 .await
13720 .unwrap();
13721 assert_eq!(resp.status(), StatusCode::OK);
13722 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13723 .await
13724 .unwrap();
13725 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13726 assert_eq!(v["namespace"], "qs-clear-happy");
13727 }
13728
13729 #[tokio::test]
13730 async fn http_clear_namespace_standard_qs_idempotent_on_unset() {
13731 let state = test_state();
13735 let app = Router::new()
13736 .route(
13737 "/api/v1/namespaces",
13738 axum::routing::delete(clear_namespace_standard_qs),
13739 )
13740 .with_state(test_app_state(state));
13741 let resp = app
13742 .oneshot(
13743 axum::http::Request::builder()
13744 .uri("/api/v1/namespaces?namespace=qs-clear-noop")
13745 .method("DELETE")
13746 .body(Body::empty())
13747 .unwrap(),
13748 )
13749 .await
13750 .unwrap();
13751 assert_eq!(resp.status(), StatusCode::OK);
13752 }
13753
13754 #[tokio::test]
13755 async fn http_clear_namespace_standard_qs_missing_namespace_returns_400() {
13756 let state = test_state();
13759 let app = Router::new()
13760 .route(
13761 "/api/v1/namespaces",
13762 axum::routing::delete(clear_namespace_standard_qs),
13763 )
13764 .with_state(test_app_state(state));
13765 let resp = app
13766 .oneshot(
13767 axum::http::Request::builder()
13768 .uri("/api/v1/namespaces")
13769 .method("DELETE")
13770 .body(Body::empty())
13771 .unwrap(),
13772 )
13773 .await
13774 .unwrap();
13775 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
13776 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13777 .await
13778 .unwrap();
13779 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13780 assert!(
13781 v["error"].as_str().unwrap_or("").contains("namespace"),
13782 "error must mention namespace, got {v:?}"
13783 );
13784 }
13785
13786 #[tokio::test]
13789 async fn http_set_qs_fanout_503_when_all_peers_down() {
13790 let state = test_state();
13793 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13794 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13795 let app = Router::new()
13796 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13797 .with_state(app_state);
13798 let resp = app
13799 .oneshot(
13800 axum::http::Request::builder()
13801 .uri("/api/v1/namespaces")
13802 .method("POST")
13803 .header("content-type", "application/json")
13804 .body(Body::from(
13805 serde_json::to_vec(&json!({"namespace": "qs-fed-down"})).unwrap(),
13806 ))
13807 .unwrap(),
13808 )
13809 .await
13810 .unwrap();
13811 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13812 }
13813
13814 #[tokio::test]
13815 async fn http_set_qs_fanout_503_payload_shape_includes_quorum_fields() {
13816 let state = test_state();
13821 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13822 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13823 let app = Router::new()
13824 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13825 .with_state(app_state);
13826 let resp = app
13827 .oneshot(
13828 axum::http::Request::builder()
13829 .uri("/api/v1/namespaces")
13830 .method("POST")
13831 .header("content-type", "application/json")
13832 .body(Body::from(
13833 serde_json::to_vec(&json!({"namespace": "qs-503-shape"})).unwrap(),
13834 ))
13835 .unwrap(),
13836 )
13837 .await
13838 .unwrap();
13839 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13840 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13841 .await
13842 .unwrap();
13843 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13844 assert_eq!(v["error"], "quorum_not_met");
13845 assert!(v["got"].as_u64().is_some(), "got must be a number");
13846 assert!(v["needed"].as_u64().is_some(), "needed must be a number");
13847 assert!(v["reason"].is_string(), "reason must be a string");
13848 assert_eq!(v["needed"].as_u64().unwrap(), 2);
13850 }
13851
13852 #[tokio::test]
13853 async fn http_set_qs_fanout_503_includes_retry_after_header() {
13854 let state = test_state();
13857 let (peer_url, _count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13858 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13859 let app = Router::new()
13860 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13861 .with_state(app_state);
13862 let resp = app
13863 .oneshot(
13864 axum::http::Request::builder()
13865 .uri("/api/v1/namespaces")
13866 .method("POST")
13867 .header("content-type", "application/json")
13868 .body(Body::from(
13869 serde_json::to_vec(&json!({"namespace": "qs-503-retry-after"})).unwrap(),
13870 ))
13871 .unwrap(),
13872 )
13873 .await
13874 .unwrap();
13875 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13876 let retry = resp
13877 .headers()
13878 .get("retry-after")
13879 .and_then(|v| v.to_str().ok())
13880 .unwrap_or("");
13881 assert_eq!(retry, "2", "503 must include Retry-After: 2");
13882 }
13883
13884 #[tokio::test]
13885 async fn http_set_qs_fanout_quorum_met_with_one_peer_down() {
13886 let state = test_state();
13890 let (peer_up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
13891 let (peer_down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13892 let app_state = h8d_app_state_with_fed(state, vec![peer_up, peer_down], 2, 1500);
13893 let app = Router::new()
13894 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13895 .with_state(app_state);
13896 let resp = app
13897 .oneshot(
13898 axum::http::Request::builder()
13899 .uri("/api/v1/namespaces")
13900 .method("POST")
13901 .header("content-type", "application/json")
13902 .body(Body::from(
13903 serde_json::to_vec(&json!({"namespace": "qs-quorum-met"})).unwrap(),
13904 ))
13905 .unwrap(),
13906 )
13907 .await
13908 .unwrap();
13909 assert_eq!(resp.status(), StatusCode::CREATED);
13910 }
13911
13912 #[tokio::test]
13913 async fn http_set_qs_fanout_quorum_not_met_strict_n_equals_w() {
13914 let state = test_state();
13917 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13918 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
13919 let app = Router::new()
13920 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13921 .with_state(app_state);
13922 let resp = app
13923 .oneshot(
13924 axum::http::Request::builder()
13925 .uri("/api/v1/namespaces")
13926 .method("POST")
13927 .header("content-type", "application/json")
13928 .body(Body::from(
13929 serde_json::to_vec(&json!({"namespace": "qs-strict-quorum"})).unwrap(),
13930 ))
13931 .unwrap(),
13932 )
13933 .await
13934 .unwrap();
13935 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13936 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13937 .await
13938 .unwrap();
13939 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
13940 assert_eq!(v["needed"].as_u64().unwrap(), 2);
13941 assert!(v["got"].as_u64().unwrap() < v["needed"].as_u64().unwrap());
13943 }
13944
13945 #[tokio::test]
13946 async fn http_set_qs_fanout_quorum_w_equals_one_any_success_writes_succeed() {
13947 let state = test_state();
13950 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
13951 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 1, 1500);
13952 let app = Router::new()
13953 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13954 .with_state(app_state);
13955 let resp = app
13956 .oneshot(
13957 axum::http::Request::builder()
13958 .uri("/api/v1/namespaces")
13959 .method("POST")
13960 .header("content-type", "application/json")
13961 .body(Body::from(
13962 serde_json::to_vec(&json!({"namespace": "qs-w1-any"})).unwrap(),
13963 ))
13964 .unwrap(),
13965 )
13966 .await
13967 .unwrap();
13968 assert_eq!(resp.status(), StatusCode::CREATED);
13969 }
13970
13971 #[tokio::test]
13972 async fn http_set_qs_fanout_503_when_peer_hangs_past_deadline() {
13973 let state = test_state();
13977 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Hang).await;
13978 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 200);
13979 let app = Router::new()
13980 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
13981 .with_state(app_state);
13982 let resp = app
13983 .oneshot(
13984 axum::http::Request::builder()
13985 .uri("/api/v1/namespaces")
13986 .method("POST")
13987 .header("content-type", "application/json")
13988 .body(Body::from(
13989 serde_json::to_vec(&json!({"namespace": "qs-hang"})).unwrap(),
13990 ))
13991 .unwrap(),
13992 )
13993 .await
13994 .unwrap();
13995 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
13996 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
13997 .await
13998 .unwrap();
13999 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14000 let reason = v["reason"].as_str().unwrap_or("");
14001 assert!(
14002 reason == "timeout" || reason == "unreachable",
14003 "expected timeout/unreachable, got {reason:?}"
14004 );
14005 }
14006
14007 #[tokio::test]
14008 async fn http_set_qs_fanout_503_when_peer_returns_503() {
14009 let state = test_state();
14014 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail503).await;
14015 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14016 let app = Router::new()
14017 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14018 .with_state(app_state);
14019 let resp = app
14020 .oneshot(
14021 axum::http::Request::builder()
14022 .uri("/api/v1/namespaces")
14023 .method("POST")
14024 .header("content-type", "application/json")
14025 .body(Body::from(
14026 serde_json::to_vec(&json!({"namespace": "qs-peer-503"})).unwrap(),
14027 ))
14028 .unwrap(),
14029 )
14030 .await
14031 .unwrap();
14032 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14033 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14034 .await
14035 .unwrap();
14036 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14037 assert_eq!(v["error"], "quorum_not_met");
14038 }
14039
14040 #[tokio::test]
14041 async fn http_set_qs_fanout_503_when_peer_returns_4xx() {
14042 let state = test_state();
14046 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail400).await;
14047 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14048 let app = Router::new()
14049 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14050 .with_state(app_state);
14051 let resp = app
14052 .oneshot(
14053 axum::http::Request::builder()
14054 .uri("/api/v1/namespaces")
14055 .method("POST")
14056 .header("content-type", "application/json")
14057 .body(Body::from(
14058 serde_json::to_vec(&json!({"namespace": "qs-peer-400"})).unwrap(),
14059 ))
14060 .unwrap(),
14061 )
14062 .await
14063 .unwrap();
14064 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14065 }
14066
14067 #[tokio::test]
14068 async fn http_set_qs_fanout_503_partition_minority_fails() {
14069 let state = test_state();
14072 let (up, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14073 let (down1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14074 let (down2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14075 let app_state = h8d_app_state_with_fed(state, vec![up, down1, down2], 3, 1500);
14076 let app = Router::new()
14077 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14078 .with_state(app_state);
14079 let resp = app
14080 .oneshot(
14081 axum::http::Request::builder()
14082 .uri("/api/v1/namespaces")
14083 .method("POST")
14084 .header("content-type", "application/json")
14085 .body(Body::from(
14086 serde_json::to_vec(&json!({"namespace": "qs-minority"})).unwrap(),
14087 ))
14088 .unwrap(),
14089 )
14090 .await
14091 .unwrap();
14092 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14093 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14094 .await
14095 .unwrap();
14096 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14097 assert_eq!(v["needed"].as_u64().unwrap(), 3);
14098 assert!(v["got"].as_u64().unwrap() < 3);
14099 }
14100
14101 #[tokio::test]
14102 async fn http_set_qs_fanout_majority_tolerates_minority_partition() {
14103 let state = test_state();
14107 let (up1, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14108 let (up2, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14109 let (down, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14110 let app_state = h8d_app_state_with_fed(state, vec![up1, up2, down], 3, 1500);
14111 let app = Router::new()
14112 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14113 .with_state(app_state);
14114 let resp = app
14115 .oneshot(
14116 axum::http::Request::builder()
14117 .uri("/api/v1/namespaces")
14118 .method("POST")
14119 .header("content-type", "application/json")
14120 .body(Body::from(
14121 serde_json::to_vec(&json!({"namespace": "qs-majority"})).unwrap(),
14122 ))
14123 .unwrap(),
14124 )
14125 .await
14126 .unwrap();
14127 assert_eq!(resp.status(), StatusCode::CREATED);
14128 }
14129
14130 #[tokio::test]
14131 async fn http_clear_qs_fanout_503_when_peer_down() {
14132 let state = test_state();
14137 let local_app_state = test_app_state(state.clone());
14140 let set_router = Router::new()
14141 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14142 .with_state(local_app_state);
14143 let _ = set_router
14144 .oneshot(
14145 axum::http::Request::builder()
14146 .uri("/api/v1/namespaces")
14147 .method("POST")
14148 .header("content-type", "application/json")
14149 .body(Body::from(
14150 serde_json::to_vec(&json!({"namespace": "qs-clear-fed"})).unwrap(),
14151 ))
14152 .unwrap(),
14153 )
14154 .await
14155 .unwrap();
14156
14157 let (peer_url, _) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14158 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14159 let app = Router::new()
14160 .route(
14161 "/api/v1/namespaces",
14162 axum::routing::delete(clear_namespace_standard_qs),
14163 )
14164 .with_state(app_state);
14165 let resp = app
14166 .oneshot(
14167 axum::http::Request::builder()
14168 .uri("/api/v1/namespaces?namespace=qs-clear-fed")
14169 .method("DELETE")
14170 .body(Body::empty())
14171 .unwrap(),
14172 )
14173 .await
14174 .unwrap();
14175 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14176 let retry = resp
14177 .headers()
14178 .get("retry-after")
14179 .and_then(|v| v.to_str().ok())
14180 .unwrap_or("");
14181 assert_eq!(retry, "2", "clear 503 must include Retry-After: 2");
14182 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14183 .await
14184 .unwrap();
14185 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14186 assert_eq!(v["error"], "quorum_not_met");
14187 }
14188
14189 #[tokio::test]
14190 async fn http_set_qs_fanout_no_federation_returns_201_without_peers() {
14191 let state = test_state();
14195 let app = Router::new()
14196 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14197 .with_state(test_app_state(state));
14198 let resp = app
14199 .oneshot(
14200 axum::http::Request::builder()
14201 .uri("/api/v1/namespaces")
14202 .method("POST")
14203 .header("content-type", "application/json")
14204 .body(Body::from(
14205 serde_json::to_vec(&json!({"namespace": "qs-no-fed"})).unwrap(),
14206 ))
14207 .unwrap(),
14208 )
14209 .await
14210 .unwrap();
14211 assert_eq!(resp.status(), StatusCode::CREATED);
14212 }
14213
14214 #[tokio::test]
14215 async fn http_set_qs_fanout_peer_called_at_least_once_on_quorum_failure() {
14216 use std::sync::atomic::Ordering;
14220
14221 let state = test_state();
14222 let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Fail500).await;
14223 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14224 let app = Router::new()
14225 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14226 .with_state(app_state);
14227 let resp = app
14228 .oneshot(
14229 axum::http::Request::builder()
14230 .uri("/api/v1/namespaces")
14231 .method("POST")
14232 .header("content-type", "application/json")
14233 .body(Body::from(
14234 serde_json::to_vec(&json!({"namespace": "qs-fanout-attempt"})).unwrap(),
14235 ))
14236 .unwrap(),
14237 )
14238 .await
14239 .unwrap();
14240 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
14241 for _ in 0..50 {
14243 if count.load(Ordering::Relaxed) >= 1 {
14244 break;
14245 }
14246 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
14247 }
14248 assert!(
14249 count.load(Ordering::Relaxed) >= 1,
14250 "leader must have attempted the fanout POST at least once"
14251 );
14252 }
14253
14254 #[tokio::test]
14255 async fn http_set_qs_fanout_peer_receives_post_on_happy_path() {
14256 use std::sync::atomic::Ordering;
14260
14261 let state = test_state();
14262 let (peer_url, count) = h8d_spawn_mock_peer(H8dPeerBehaviour::Ack).await;
14263 let app_state = h8d_app_state_with_fed(state, vec![peer_url], 2, 1500);
14264 let app = Router::new()
14265 .route("/api/v1/namespaces", axum_post(set_namespace_standard_qs))
14266 .with_state(app_state);
14267 let resp = app
14268 .oneshot(
14269 axum::http::Request::builder()
14270 .uri("/api/v1/namespaces")
14271 .method("POST")
14272 .header("content-type", "application/json")
14273 .body(Body::from(
14274 serde_json::to_vec(&json!({"namespace": "qs-fanout-happy"})).unwrap(),
14275 ))
14276 .unwrap(),
14277 )
14278 .await
14279 .unwrap();
14280 assert_eq!(resp.status(), StatusCode::CREATED);
14281 for _ in 0..50 {
14286 if count.load(Ordering::Relaxed) >= 1 {
14287 break;
14288 }
14289 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
14290 }
14291 assert!(count.load(Ordering::Relaxed) >= 1);
14292 }
14293
14294 #[test]
14306 fn percent_decode_lossy_passes_through_plain_ascii() {
14307 let s = percent_decode_lossy("hello-world_123");
14308 assert_eq!(s, "hello-world_123");
14309 }
14310
14311 #[test]
14312 fn percent_decode_lossy_decodes_basic_escape() {
14313 let s = percent_decode_lossy("a%20b");
14314 assert_eq!(s, "a b");
14315 }
14316
14317 #[test]
14318 fn percent_decode_lossy_decodes_plus_and_ampersand() {
14319 let s = percent_decode_lossy("a%2Bb%26c");
14321 assert_eq!(s, "a+b&c");
14322 }
14323
14324 #[test]
14325 fn percent_decode_lossy_handles_invalid_hex_passthrough() {
14326 let s = percent_decode_lossy("a%ZZb");
14328 assert_eq!(s, "a%ZZb");
14329 }
14330
14331 #[test]
14332 fn percent_decode_lossy_handles_truncated_escape() {
14333 let s = percent_decode_lossy("a%2");
14335 assert_eq!(s, "a%2");
14336 let s2 = percent_decode_lossy("%");
14337 assert_eq!(s2, "%");
14338 }
14339
14340 #[test]
14341 fn percent_decode_lossy_decodes_full_byte_range() {
14342 let s = percent_decode_lossy("%41%42%43");
14344 assert_eq!(s, "ABC");
14345 }
14346
14347 #[test]
14348 fn percent_decode_lossy_empty_input_returns_empty() {
14349 let s = percent_decode_lossy("");
14350 assert_eq!(s, "");
14351 }
14352
14353 #[test]
14354 fn constant_time_eq_returns_true_for_equal_bytes() {
14355 assert!(constant_time_eq(b"hello", b"hello"));
14356 assert!(constant_time_eq(b"", b""));
14357 }
14358
14359 #[test]
14360 fn constant_time_eq_returns_false_for_different_bytes() {
14361 assert!(!constant_time_eq(b"hello", b"world"));
14362 }
14363
14364 #[test]
14365 fn constant_time_eq_returns_false_for_different_lengths() {
14366 assert!(!constant_time_eq(b"a", b"ab"));
14367 assert!(!constant_time_eq(b"abc", b""));
14368 }
14369
14370 #[test]
14371 fn constant_time_eq_compares_high_bytes_correctly() {
14372 let a = [0x80u8, 0x81, 0x82, 0xFF];
14374 let b = [0x80u8, 0x81, 0x82, 0xFF];
14375 assert!(constant_time_eq(&a, &b));
14376 let c = [0x80u8, 0x81, 0x82, 0xFE];
14377 assert!(!constant_time_eq(&a, &c));
14378 }
14379
14380 #[tokio::test]
14383 async fn api_key_query_param_with_percent_encoded_chars_matches() {
14384 let app = auth_app(Some("a+b"));
14388 let resp = app
14389 .oneshot(
14390 axum::http::Request::builder()
14391 .uri("/api/v1/memories?api_key=a%2Bb")
14392 .body(Body::empty())
14393 .unwrap(),
14394 )
14395 .await
14396 .unwrap();
14397 assert_eq!(resp.status(), StatusCode::OK);
14398 }
14399
14400 #[tokio::test]
14401 async fn api_key_query_param_wrong_value_rejected() {
14402 let app = auth_app(Some("secret"));
14403 let resp = app
14404 .oneshot(
14405 axum::http::Request::builder()
14406 .uri("/api/v1/memories?api_key=wrong")
14407 .body(Body::empty())
14408 .unwrap(),
14409 )
14410 .await
14411 .unwrap();
14412 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14413 }
14414
14415 #[tokio::test]
14416 async fn api_key_query_param_with_other_pairs_still_matches() {
14417 let app = auth_app(Some("secret"));
14421 let resp = app
14422 .oneshot(
14423 axum::http::Request::builder()
14424 .uri("/api/v1/memories?other=val&api_key=secret&trailing=x")
14425 .body(Body::empty())
14426 .unwrap(),
14427 )
14428 .await
14429 .unwrap();
14430 assert_eq!(resp.status(), StatusCode::OK);
14431 }
14432
14433 #[tokio::test]
14434 async fn api_key_header_with_invalid_utf8_falls_through() {
14435 let app = auth_app(Some("secret"));
14439 let bytes = [0x80u8, 0x81u8];
14441 let req = axum::http::Request::builder()
14442 .uri("/api/v1/memories")
14443 .header(
14444 "x-api-key",
14445 axum::http::HeaderValue::from_bytes(&bytes).unwrap(),
14446 )
14447 .body(Body::empty())
14448 .unwrap();
14449 let resp = app.oneshot(req).await.unwrap();
14450 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
14451 }
14452
14453 #[tokio::test]
14456 async fn http_health_route_returns_200_with_status_ok() {
14457 let state = test_state();
14458 let app = Router::new()
14459 .route("/api/v1/health", axum_get(health))
14460 .with_state(test_app_state(state));
14461 let resp = app
14462 .oneshot(
14463 axum::http::Request::builder()
14464 .uri("/api/v1/health")
14465 .body(Body::empty())
14466 .unwrap(),
14467 )
14468 .await
14469 .unwrap();
14470 assert_eq!(resp.status(), StatusCode::OK);
14471 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14472 .await
14473 .unwrap();
14474 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14475 assert_eq!(v["status"], "ok");
14476 assert_eq!(v["service"], "ai-memory");
14477 assert_eq!(v["embedder_ready"], false);
14480 assert_eq!(v["federation_enabled"], false);
14481 }
14482
14483 #[tokio::test]
14486 async fn http_prometheus_metrics_returns_text_body() {
14487 let state = test_state();
14488 let app = Router::new()
14489 .route("/api/v1/metrics", axum_get(prometheus_metrics))
14490 .with_state(state);
14491 let resp = app
14492 .oneshot(
14493 axum::http::Request::builder()
14494 .uri("/api/v1/metrics")
14495 .body(Body::empty())
14496 .unwrap(),
14497 )
14498 .await
14499 .unwrap();
14500 assert_eq!(resp.status(), StatusCode::OK);
14501 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14504 .await
14505 .unwrap();
14506 assert!(!bytes.is_empty());
14507 }
14508
14509 #[tokio::test]
14512 async fn http_list_namespaces_returns_seeded_namespaces() {
14513 let state = test_state();
14514 let _ = insert_test_memory(&state, "ns-foo", "t1").await;
14515 let _ = insert_test_memory(&state, "ns-bar", "t2").await;
14516 let app = Router::new()
14517 .route("/api/v1/namespaces", axum_get(list_namespaces))
14518 .with_state(state);
14519 let resp = app
14520 .oneshot(
14521 axum::http::Request::builder()
14522 .uri("/api/v1/namespaces")
14523 .body(Body::empty())
14524 .unwrap(),
14525 )
14526 .await
14527 .unwrap();
14528 assert_eq!(resp.status(), StatusCode::OK);
14529 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14530 .await
14531 .unwrap();
14532 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14533 let ns = v["namespaces"].as_array().expect("namespaces array");
14534 assert!(!ns.is_empty());
14535 }
14536
14537 #[tokio::test]
14540 async fn http_get_taxonomy_no_prefix_returns_tree() {
14541 let state = test_state();
14542 let _ = insert_test_memory(&state, "tax/a", "t1").await;
14543 let _ = insert_test_memory(&state, "tax/b", "t2").await;
14544 let app = Router::new()
14545 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14546 .with_state(state);
14547 let resp = app
14548 .oneshot(
14549 axum::http::Request::builder()
14550 .uri("/api/v1/taxonomy")
14551 .body(Body::empty())
14552 .unwrap(),
14553 )
14554 .await
14555 .unwrap();
14556 assert_eq!(resp.status(), StatusCode::OK);
14557 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14558 .await
14559 .unwrap();
14560 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14561 assert!(v["tree"].is_array() || v["tree"].is_object());
14562 }
14563
14564 #[tokio::test]
14565 async fn http_get_taxonomy_invalid_prefix_returns_400() {
14566 let state = test_state();
14567 let app = Router::new()
14568 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14569 .with_state(state);
14570 let resp = app
14576 .oneshot(
14577 axum::http::Request::builder()
14578 .uri("/api/v1/taxonomy?prefix=foo%2F%2Fbar")
14579 .body(Body::empty())
14580 .unwrap(),
14581 )
14582 .await
14583 .unwrap();
14584 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14585 }
14586
14587 #[tokio::test]
14588 async fn http_get_taxonomy_with_depth_and_limit() {
14589 let state = test_state();
14590 let _ = insert_test_memory(&state, "tax2/a/b", "t").await;
14591 let app = Router::new()
14592 .route("/api/v1/taxonomy", axum_get(get_taxonomy))
14593 .with_state(state);
14594 let resp = app
14595 .oneshot(
14596 axum::http::Request::builder()
14597 .uri("/api/v1/taxonomy?prefix=tax2&depth=4&limit=100")
14598 .body(Body::empty())
14599 .unwrap(),
14600 )
14601 .await
14602 .unwrap();
14603 assert_eq!(resp.status(), StatusCode::OK);
14604 }
14605
14606 #[tokio::test]
14609 async fn http_get_memory_invalid_id_returns_400() {
14610 let state = test_state();
14611 let app = Router::new()
14612 .route("/api/v1/memories/{id}", axum_get(get_memory))
14613 .with_state(state);
14614 let big = "a".repeat(200);
14616 let resp = app
14617 .oneshot(
14618 axum::http::Request::builder()
14619 .uri(format!("/api/v1/memories/{big}"))
14620 .body(Body::empty())
14621 .unwrap(),
14622 )
14623 .await
14624 .unwrap();
14625 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14626 }
14627
14628 #[tokio::test]
14629 async fn http_get_memory_unknown_id_returns_404() {
14630 let state = test_state();
14631 let app = Router::new()
14632 .route("/api/v1/memories/{id}", axum_get(get_memory))
14633 .with_state(state);
14634 let id = "deadbeefdeadbeefdeadbeefdeadbeef";
14636 let resp = app
14637 .oneshot(
14638 axum::http::Request::builder()
14639 .uri(format!("/api/v1/memories/{id}"))
14640 .body(Body::empty())
14641 .unwrap(),
14642 )
14643 .await
14644 .unwrap();
14645 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14646 }
14647
14648 #[tokio::test]
14649 async fn http_get_memory_after_insert_returns_payload() {
14650 let state = test_state();
14651 let id = insert_test_memory(&state, "ns-get", "t-get").await;
14652 let app = Router::new()
14653 .route("/api/v1/memories/{id}", axum_get(get_memory))
14654 .with_state(state);
14655 let resp = app
14656 .oneshot(
14657 axum::http::Request::builder()
14658 .uri(format!("/api/v1/memories/{id}"))
14659 .body(Body::empty())
14660 .unwrap(),
14661 )
14662 .await
14663 .unwrap();
14664 assert_eq!(resp.status(), StatusCode::OK);
14665 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14666 .await
14667 .unwrap();
14668 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14669 assert_eq!(v["memory"]["id"], id);
14670 assert!(v["links"].is_array());
14671 }
14672
14673 #[tokio::test]
14676 async fn http_delete_memory_invalid_id_returns_400() {
14677 let state = test_state();
14678 let app = Router::new()
14679 .route(
14680 "/api/v1/memories/{id}",
14681 axum::routing::delete(delete_memory),
14682 )
14683 .with_state(test_app_state(state));
14684 let big = "b".repeat(200);
14685 let resp = app
14686 .oneshot(
14687 axum::http::Request::builder()
14688 .uri(format!("/api/v1/memories/{big}"))
14689 .method("DELETE")
14690 .body(Body::empty())
14691 .unwrap(),
14692 )
14693 .await
14694 .unwrap();
14695 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14696 }
14697
14698 #[tokio::test]
14699 async fn http_delete_memory_unknown_id_returns_404() {
14700 let state = test_state();
14701 let app = Router::new()
14702 .route(
14703 "/api/v1/memories/{id}",
14704 axum::routing::delete(delete_memory),
14705 )
14706 .with_state(test_app_state(state));
14707 let id = "cafebabecafebabecafebabecafebabe";
14708 let resp = app
14709 .oneshot(
14710 axum::http::Request::builder()
14711 .uri(format!("/api/v1/memories/{id}"))
14712 .method("DELETE")
14713 .body(Body::empty())
14714 .unwrap(),
14715 )
14716 .await
14717 .unwrap();
14718 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14719 }
14720
14721 #[tokio::test]
14722 async fn http_delete_memory_happy_path_returns_deleted_true() {
14723 let state = test_state();
14724 let id = insert_test_memory(&state, "ns-del", "t-del").await;
14725 let app = Router::new()
14726 .route(
14727 "/api/v1/memories/{id}",
14728 axum::routing::delete(delete_memory),
14729 )
14730 .with_state(test_app_state(state));
14731 let resp = app
14732 .oneshot(
14733 axum::http::Request::builder()
14734 .uri(format!("/api/v1/memories/{id}"))
14735 .method("DELETE")
14736 .body(Body::empty())
14737 .unwrap(),
14738 )
14739 .await
14740 .unwrap();
14741 assert_eq!(resp.status(), StatusCode::OK);
14742 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14743 .await
14744 .unwrap();
14745 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14746 assert_eq!(v["deleted"], true);
14747 }
14748
14749 #[tokio::test]
14750 async fn http_delete_memory_invalid_x_agent_id_returns_400() {
14751 let state = test_state();
14752 let id = insert_test_memory(&state, "ns-del-bad", "t").await;
14753 let app = Router::new()
14754 .route(
14755 "/api/v1/memories/{id}",
14756 axum::routing::delete(delete_memory),
14757 )
14758 .with_state(test_app_state(state));
14759 let resp = app
14761 .oneshot(
14762 axum::http::Request::builder()
14763 .uri(format!("/api/v1/memories/{id}"))
14764 .method("DELETE")
14765 .header("x-agent-id", "bad agent id")
14766 .body(Body::empty())
14767 .unwrap(),
14768 )
14769 .await
14770 .unwrap();
14771 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14772 }
14773
14774 #[tokio::test]
14777 async fn http_promote_memory_invalid_id_returns_400() {
14778 let state = test_state();
14779 let app = Router::new()
14780 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14781 .with_state(test_app_state(state));
14782 let big = "p".repeat(200);
14783 let resp = app
14784 .oneshot(
14785 axum::http::Request::builder()
14786 .uri(format!("/api/v1/memories/{big}/promote"))
14787 .method("POST")
14788 .body(Body::empty())
14789 .unwrap(),
14790 )
14791 .await
14792 .unwrap();
14793 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14794 }
14795
14796 #[tokio::test]
14797 async fn http_promote_memory_unknown_id_returns_404() {
14798 let state = test_state();
14799 let app = Router::new()
14800 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14801 .with_state(test_app_state(state));
14802 let id = "facefacefacefacefacefacefaceface";
14803 let resp = app
14804 .oneshot(
14805 axum::http::Request::builder()
14806 .uri(format!("/api/v1/memories/{id}/promote"))
14807 .method("POST")
14808 .body(Body::empty())
14809 .unwrap(),
14810 )
14811 .await
14812 .unwrap();
14813 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14814 }
14815
14816 #[tokio::test]
14817 async fn http_promote_memory_happy_path_clears_expires_at() {
14818 let state = test_state();
14819 let id = {
14821 let lock = state.lock().await;
14822 let now = Utc::now();
14823 let mem = Memory {
14824 id: Uuid::new_v4().to_string(),
14825 tier: Tier::Short,
14826 namespace: "ns-promote".into(),
14827 title: "to-promote".into(),
14828 content: "content".into(),
14829 tags: vec![],
14830 priority: 5,
14831 confidence: 1.0,
14832 source: "test".into(),
14833 access_count: 0,
14834 created_at: now.to_rfc3339(),
14835 updated_at: now.to_rfc3339(),
14836 last_accessed_at: None,
14837 expires_at: Some((now + Duration::seconds(3600)).to_rfc3339()),
14838 metadata: serde_json::json!({}),
14839 };
14840 db::insert(&lock.0, &mem).unwrap()
14841 };
14842 let app = Router::new()
14843 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
14844 .with_state(test_app_state(state.clone()));
14845 let resp = app
14846 .oneshot(
14847 axum::http::Request::builder()
14848 .uri(format!("/api/v1/memories/{id}/promote"))
14849 .method("POST")
14850 .body(Body::empty())
14851 .unwrap(),
14852 )
14853 .await
14854 .unwrap();
14855 assert_eq!(resp.status(), StatusCode::OK);
14856 let lock = state.lock().await;
14858 let m = db::get(&lock.0, &id).unwrap().unwrap();
14859 assert_eq!(m.tier, Tier::Long);
14860 assert!(m.expires_at.is_none());
14861 }
14862
14863 #[tokio::test]
14866 async fn http_update_memory_unknown_id_returns_404() {
14867 let state = test_state();
14868 let app = Router::new()
14869 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14870 .with_state(test_app_state(state));
14871 let id = "1234567812345678123456781234567a";
14872 let body = serde_json::json!({"title": "new title"});
14873 let resp = app
14874 .oneshot(
14875 axum::http::Request::builder()
14876 .uri(format!("/api/v1/memories/{id}"))
14877 .method("PUT")
14878 .header("content-type", "application/json")
14879 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14880 .unwrap(),
14881 )
14882 .await
14883 .unwrap();
14884 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14885 }
14886
14887 #[tokio::test]
14888 async fn http_update_memory_happy_path_returns_updated_payload() {
14889 let state = test_state();
14890 let id = insert_test_memory(&state, "ns-upd", "old title").await;
14891 let app = Router::new()
14892 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
14893 .with_state(test_app_state(state.clone()));
14894 let body = serde_json::json!({"title": "new title", "content": "new content"});
14895 let resp = app
14896 .oneshot(
14897 axum::http::Request::builder()
14898 .uri(format!("/api/v1/memories/{id}"))
14899 .method("PUT")
14900 .header("content-type", "application/json")
14901 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14902 .unwrap(),
14903 )
14904 .await
14905 .unwrap();
14906 assert_eq!(resp.status(), StatusCode::OK);
14907 let lock = state.lock().await;
14908 let m = db::get(&lock.0, &id).unwrap().unwrap();
14909 assert_eq!(m.title, "new title");
14910 assert_eq!(m.content, "new content");
14911 }
14912
14913 #[tokio::test]
14916 async fn http_create_link_happy_path_returns_201() {
14917 let state = test_state();
14918 let src = insert_test_memory(&state, "ns-link", "src").await;
14919 let tgt = insert_test_memory(&state, "ns-link", "tgt").await;
14920 let app = Router::new()
14921 .route("/api/v1/links", axum_post(create_link))
14922 .with_state(test_app_state(state));
14923 let body = serde_json::json!({
14924 "source_id": src,
14925 "target_id": tgt,
14926 "relation": "related_to",
14927 });
14928 let resp = app
14929 .oneshot(
14930 axum::http::Request::builder()
14931 .uri("/api/v1/links")
14932 .method("POST")
14933 .header("content-type", "application/json")
14934 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14935 .unwrap(),
14936 )
14937 .await
14938 .unwrap();
14939 assert_eq!(resp.status(), StatusCode::CREATED);
14940 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
14941 .await
14942 .unwrap();
14943 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
14944 assert_eq!(v["linked"], true);
14945 }
14946
14947 #[tokio::test]
14948 async fn http_create_link_invalid_link_returns_400() {
14949 let state = test_state();
14950 let app = Router::new()
14951 .route("/api/v1/links", axum_post(create_link))
14952 .with_state(test_app_state(state));
14953 let body = serde_json::json!({
14955 "source_id": "abc",
14956 "target_id": "abc",
14957 "relation": "related_to",
14958 });
14959 let resp = app
14960 .oneshot(
14961 axum::http::Request::builder()
14962 .uri("/api/v1/links")
14963 .method("POST")
14964 .header("content-type", "application/json")
14965 .body(Body::from(serde_json::to_vec(&body).unwrap()))
14966 .unwrap(),
14967 )
14968 .await
14969 .unwrap();
14970 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14971 }
14972
14973 #[tokio::test]
14974 async fn http_get_links_invalid_id_returns_400() {
14975 let state = test_state();
14976 let app = Router::new()
14977 .route("/api/v1/memories/{id}/links", axum_get(get_links))
14978 .with_state(state);
14979 let big = "x".repeat(200);
14980 let resp = app
14981 .oneshot(
14982 axum::http::Request::builder()
14983 .uri(format!("/api/v1/memories/{big}/links"))
14984 .body(Body::empty())
14985 .unwrap(),
14986 )
14987 .await
14988 .unwrap();
14989 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
14990 }
14991
14992 #[tokio::test]
14993 async fn http_get_links_after_create_returns_link() {
14994 let state = test_state();
14995 let src = insert_test_memory(&state, "ns-getlinks", "src").await;
14996 let tgt = insert_test_memory(&state, "ns-getlinks", "tgt").await;
14997 {
14998 let lock = state.lock().await;
14999 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15000 }
15001 let app = Router::new()
15002 .route("/api/v1/memories/{id}/links", axum_get(get_links))
15003 .with_state(state);
15004 let resp = app
15005 .oneshot(
15006 axum::http::Request::builder()
15007 .uri(format!("/api/v1/memories/{src}/links"))
15008 .body(Body::empty())
15009 .unwrap(),
15010 )
15011 .await
15012 .unwrap();
15013 assert_eq!(resp.status(), StatusCode::OK);
15014 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15015 .await
15016 .unwrap();
15017 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15018 let links = v["links"].as_array().expect("links array");
15019 assert!(!links.is_empty());
15020 }
15021
15022 #[tokio::test]
15023 async fn http_delete_link_after_create_returns_deleted_true() {
15024 let state = test_state();
15025 let src = insert_test_memory(&state, "ns-dellink", "src").await;
15026 let tgt = insert_test_memory(&state, "ns-dellink", "tgt").await;
15027 {
15028 let lock = state.lock().await;
15029 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15030 }
15031 let app = Router::new()
15032 .route("/api/v1/links", axum::routing::delete(delete_link))
15033 .with_state(test_app_state(state));
15034 let body = serde_json::json!({
15035 "source_id": src,
15036 "target_id": tgt,
15037 "relation": "related_to",
15038 });
15039 let resp = app
15040 .oneshot(
15041 axum::http::Request::builder()
15042 .uri("/api/v1/links")
15043 .method("DELETE")
15044 .header("content-type", "application/json")
15045 .body(Body::from(serde_json::to_vec(&body).unwrap()))
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["deleted"], true);
15056 }
15057
15058 #[tokio::test]
15061 async fn http_get_stats_with_data_returns_total() {
15062 let state = test_state();
15063 let _ = insert_test_memory(&state, "ns-stats", "t1").await;
15064 let _ = insert_test_memory(&state, "ns-stats", "t2").await;
15065 let app = Router::new()
15066 .route("/api/v1/stats", axum_get(get_stats))
15067 .with_state(state);
15068 let resp = app
15069 .oneshot(
15070 axum::http::Request::builder()
15071 .uri("/api/v1/stats")
15072 .body(Body::empty())
15073 .unwrap(),
15074 )
15075 .await
15076 .unwrap();
15077 assert_eq!(resp.status(), StatusCode::OK);
15078 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15079 .await
15080 .unwrap();
15081 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15082 assert_eq!(v["total"], 2);
15083 }
15084
15085 #[tokio::test]
15086 async fn http_export_memories_with_data_returns_count() {
15087 let state = test_state();
15088 let _ = insert_test_memory(&state, "ns-export", "t1").await;
15089 let _ = insert_test_memory(&state, "ns-export", "t2").await;
15090 let app = Router::new()
15091 .route("/api/v1/export", axum_get(export_memories))
15092 .with_state(state);
15093 let resp = app
15094 .oneshot(
15095 axum::http::Request::builder()
15096 .uri("/api/v1/export")
15097 .body(Body::empty())
15098 .unwrap(),
15099 )
15100 .await
15101 .unwrap();
15102 assert_eq!(resp.status(), StatusCode::OK);
15103 let bytes = axum::body::to_bytes(resp.into_body(), 256 * 1024)
15104 .await
15105 .unwrap();
15106 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15107 assert_eq!(v["count"], 2);
15108 assert!(v["exported_at"].is_string());
15109 }
15110
15111 #[tokio::test]
15114 async fn http_import_memories_inserts_valid_rows() {
15115 let state = test_state();
15116 let app = Router::new()
15117 .route("/api/v1/import", axum_post(import_memories))
15118 .with_state(state);
15119 let now = Utc::now().to_rfc3339();
15120 let mem = serde_json::json!({
15121 "id": Uuid::new_v4().to_string(),
15122 "tier": "long",
15123 "namespace": "imported",
15124 "title": "imported-row",
15125 "content": "imported content",
15126 "tags": [],
15127 "priority": 5,
15128 "confidence": 1.0,
15129 "source": "import",
15130 "access_count": 0,
15131 "created_at": now,
15132 "updated_at": now,
15133 "last_accessed_at": null,
15134 "expires_at": null,
15135 "metadata": {},
15136 });
15137 let body = serde_json::json!({"memories": [mem]});
15138 let resp = app
15139 .oneshot(
15140 axum::http::Request::builder()
15141 .uri("/api/v1/import")
15142 .method("POST")
15143 .header("content-type", "application/json")
15144 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15145 .unwrap(),
15146 )
15147 .await
15148 .unwrap();
15149 assert_eq!(resp.status(), StatusCode::OK);
15150 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15151 .await
15152 .unwrap();
15153 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15154 assert_eq!(v["imported"], 1);
15155 }
15156
15157 #[tokio::test]
15160 async fn http_recall_get_invalid_as_agent_returns_400() {
15161 let state = test_state();
15162 let app = Router::new()
15163 .route("/api/v1/recall", axum_get(recall_memories_get))
15164 .with_state(test_app_state(state));
15165 let resp = app
15167 .oneshot(
15168 axum::http::Request::builder()
15169 .uri("/api/v1/recall?context=hello&as_agent=bad%20agent")
15170 .body(Body::empty())
15171 .unwrap(),
15172 )
15173 .await
15174 .unwrap();
15175 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15176 }
15177
15178 #[tokio::test]
15179 async fn http_recall_post_invalid_as_agent_returns_400() {
15180 let state = test_state();
15181 let app = Router::new()
15182 .route("/api/v1/recall", axum_post(recall_memories_post))
15183 .with_state(test_app_state(state));
15184 let body = serde_json::json!({"context": "x", "as_agent": "bad agent"});
15185 let resp = app
15186 .oneshot(
15187 axum::http::Request::builder()
15188 .uri("/api/v1/recall")
15189 .method("POST")
15190 .header("content-type", "application/json")
15191 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15192 .unwrap(),
15193 )
15194 .await
15195 .unwrap();
15196 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15197 }
15198
15199 #[tokio::test]
15200 async fn http_recall_post_zero_budget_tokens_returns_200() {
15201 let state = test_state();
15205 let app = Router::new()
15206 .route("/api/v1/recall", axum_post(recall_memories_post))
15207 .with_state(test_app_state(state));
15208 let body = serde_json::json!({"context": "x", "budget_tokens": 0});
15209 let resp = app
15210 .oneshot(
15211 axum::http::Request::builder()
15212 .uri("/api/v1/recall")
15213 .method("POST")
15214 .header("content-type", "application/json")
15215 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15216 .unwrap(),
15217 )
15218 .await
15219 .unwrap();
15220 assert_eq!(resp.status(), StatusCode::OK);
15221 }
15222
15223 #[tokio::test]
15226 async fn http_search_invalid_as_agent_returns_400() {
15227 let state = test_state();
15228 let app = Router::new()
15229 .route("/api/v1/search", axum_get(search_memories))
15230 .with_state(state);
15231 let resp = app
15233 .oneshot(
15234 axum::http::Request::builder()
15235 .uri("/api/v1/search?q=hello&as_agent=bad%20agent")
15236 .body(Body::empty())
15237 .unwrap(),
15238 )
15239 .await
15240 .unwrap();
15241 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15242 }
15243
15244 #[tokio::test]
15247 async fn http_forget_memories_with_nothing_to_match_returns_zero() {
15248 let state = test_state();
15249 let app = Router::new()
15250 .route("/api/v1/forget", axum_post(forget_memories))
15251 .with_state(state);
15252 let body = serde_json::json!({"namespace": "no-such-ns"});
15253 let resp = app
15254 .oneshot(
15255 axum::http::Request::builder()
15256 .uri("/api/v1/forget")
15257 .method("POST")
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::OK);
15265 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15266 .await
15267 .unwrap();
15268 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15269 assert_eq!(v["deleted"], 0);
15270 }
15271
15272 #[tokio::test]
15275 async fn http_run_gc_after_insert_returns_zero_when_nothing_expired() {
15276 let state = test_state();
15277 let _ = insert_test_memory(&state, "gc-ns", "title").await;
15278 let app = Router::new()
15279 .route("/api/v1/gc", axum_post(run_gc))
15280 .with_state(state);
15281 let resp = app
15282 .oneshot(
15283 axum::http::Request::builder()
15284 .uri("/api/v1/gc")
15285 .method("POST")
15286 .body(Body::empty())
15287 .unwrap(),
15288 )
15289 .await
15290 .unwrap();
15291 assert_eq!(resp.status(), StatusCode::OK);
15292 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15293 .await
15294 .unwrap();
15295 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15296 assert_eq!(v["expired_deleted"], 0);
15297 }
15298
15299 #[tokio::test]
15302 async fn http_list_pending_default_limit_returns_count_zero_for_empty() {
15303 let state = test_state();
15304 let app = Router::new()
15305 .route("/api/v1/pending", axum_get(list_pending))
15306 .with_state(state);
15307 let resp = app
15308 .oneshot(
15309 axum::http::Request::builder()
15310 .uri("/api/v1/pending")
15311 .body(Body::empty())
15312 .unwrap(),
15313 )
15314 .await
15315 .unwrap();
15316 assert_eq!(resp.status(), StatusCode::OK);
15317 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15318 .await
15319 .unwrap();
15320 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15321 assert_eq!(v["count"], 0);
15322 }
15323
15324 #[tokio::test]
15327 async fn http_restore_archive_invalid_id_returns_400() {
15328 let state = test_state();
15329 let app = Router::new()
15330 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15331 .with_state(test_app_state(state));
15332 let big = "r".repeat(200);
15333 let resp = app
15334 .oneshot(
15335 axum::http::Request::builder()
15336 .uri(format!("/api/v1/archive/{big}/restore"))
15337 .method("POST")
15338 .body(Body::empty())
15339 .unwrap(),
15340 )
15341 .await
15342 .unwrap();
15343 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15344 }
15345
15346 #[tokio::test]
15347 async fn http_restore_archive_unknown_id_returns_404() {
15348 let state = test_state();
15349 let app = Router::new()
15350 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15351 .with_state(test_app_state(state));
15352 let id = "0123456701234567012345670123456a";
15353 let resp = app
15354 .oneshot(
15355 axum::http::Request::builder()
15356 .uri(format!("/api/v1/archive/{id}/restore"))
15357 .method("POST")
15358 .body(Body::empty())
15359 .unwrap(),
15360 )
15361 .await
15362 .unwrap();
15363 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
15364 }
15365
15366 #[tokio::test]
15367 async fn http_restore_archive_happy_path_returns_restored_true() {
15368 let state = test_state();
15369 let id = insert_test_memory(&state, "ns-restore", "row").await;
15370 {
15371 let lock = state.lock().await;
15372 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15373 }
15374 let app = Router::new()
15375 .route("/api/v1/archive/{id}/restore", axum_post(restore_archive))
15376 .with_state(test_app_state(state));
15377 let resp = app
15378 .oneshot(
15379 axum::http::Request::builder()
15380 .uri(format!("/api/v1/archive/{id}/restore"))
15381 .method("POST")
15382 .body(Body::empty())
15383 .unwrap(),
15384 )
15385 .await
15386 .unwrap();
15387 assert_eq!(resp.status(), StatusCode::OK);
15388 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15389 .await
15390 .unwrap();
15391 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15392 assert_eq!(v["restored"], true);
15393 }
15394
15395 #[tokio::test]
15398 async fn http_entity_get_by_alias_with_namespace_filter_returns_found_false() {
15399 let state = test_state();
15400 let app = Router::new()
15401 .route("/api/v1/entities/by_alias", axum_get(entity_get_by_alias))
15402 .with_state(state);
15403 let resp = app
15404 .oneshot(
15405 axum::http::Request::builder()
15406 .uri("/api/v1/entities/by_alias?alias=Acme&namespace=corp")
15407 .body(Body::empty())
15408 .unwrap(),
15409 )
15410 .await
15411 .unwrap();
15412 assert_eq!(resp.status(), StatusCode::OK);
15413 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15414 .await
15415 .unwrap();
15416 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15417 assert_eq!(v["found"], false);
15418 }
15419
15420 #[tokio::test]
15423 async fn http_kg_timeline_with_valid_since_and_until_succeeds() {
15424 let state = test_state();
15425 let id = insert_test_memory(&state, "kg-tl", "src").await;
15426 let app = Router::new()
15427 .route("/api/v1/kg/timeline", axum_get(kg_timeline))
15428 .with_state(state);
15429 let resp = app
15430 .oneshot(
15431 axum::http::Request::builder()
15432 .uri(format!(
15433 "/api/v1/kg/timeline?source_id={id}&since=2020-01-01T00:00:00Z&until=2030-01-01T00:00:00Z&limit=100"
15434 ))
15435 .body(Body::empty())
15436 .unwrap(),
15437 )
15438 .await
15439 .unwrap();
15440 assert_eq!(resp.status(), StatusCode::OK);
15441 }
15442
15443 #[tokio::test]
15446 async fn http_session_start_with_namespace_returns_session_id() {
15447 let state = test_state();
15448 let _ = insert_test_memory(&state, "session-ns", "row").await;
15449 let app = Router::new()
15450 .route("/api/v1/session/start", axum_post(session_start))
15451 .with_state(state);
15452 let body =
15453 serde_json::json!({"namespace": "session-ns", "limit": 5, "agent_id": "ai:tester"});
15454 let resp = app
15455 .oneshot(
15456 axum::http::Request::builder()
15457 .uri("/api/v1/session/start")
15458 .method("POST")
15459 .header("content-type", "application/json")
15460 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15461 .unwrap(),
15462 )
15463 .await
15464 .unwrap();
15465 assert_eq!(resp.status(), StatusCode::OK);
15466 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15467 .await
15468 .unwrap();
15469 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15470 assert!(v["session_id"].is_string());
15471 assert_eq!(v["agent_id"], "ai:tester");
15472 }
15473
15474 #[tokio::test]
15477 async fn http_notify_missing_payload_and_content_returns_400() {
15478 let state = test_state();
15479 let app = Router::new()
15480 .route("/api/v1/notify", axum_post(notify))
15481 .with_state(test_app_state(state));
15482 let body = serde_json::json!({
15483 "target_agent_id": "ai:bob",
15484 "title": "ping",
15485 });
15486 let resp = app
15487 .oneshot(
15488 axum::http::Request::builder()
15489 .uri("/api/v1/notify")
15490 .method("POST")
15491 .header("x-agent-id", "ai:alice")
15492 .header("content-type", "application/json")
15493 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15494 .unwrap(),
15495 )
15496 .await
15497 .unwrap();
15498 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15499 }
15500
15501 #[tokio::test]
15502 async fn http_notify_with_payload_field_returns_201() {
15503 let state = test_state();
15504 {
15506 let lock = state.lock().await;
15507 db::register_agent(&lock.0, "ai:alice", "ai:human", &[]).unwrap();
15508 db::register_agent(&lock.0, "ai:bob", "ai:human", &[]).unwrap();
15509 }
15510 let app = Router::new()
15511 .route("/api/v1/notify", axum_post(notify))
15512 .with_state(test_app_state(state));
15513 let body = serde_json::json!({
15514 "target_agent_id": "ai:bob",
15515 "title": "ping",
15516 "payload": "hi bob",
15517 });
15518 let resp = app
15519 .oneshot(
15520 axum::http::Request::builder()
15521 .uri("/api/v1/notify")
15522 .method("POST")
15523 .header("x-agent-id", "ai:alice")
15524 .header("content-type", "application/json")
15525 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15526 .unwrap(),
15527 )
15528 .await
15529 .unwrap();
15530 assert_eq!(resp.status(), StatusCode::CREATED);
15531 }
15532
15533 #[tokio::test]
15536 async fn http_subscribe_missing_url_and_namespace_returns_400() {
15537 let state = test_state();
15538 let app = Router::new()
15539 .route("/api/v1/subscribe", axum_post(subscribe))
15540 .with_state(test_app_state(state));
15541 let body = serde_json::json!({"agent_id": "ai:alice"});
15543 let resp = app
15544 .oneshot(
15545 axum::http::Request::builder()
15546 .uri("/api/v1/subscribe")
15547 .method("POST")
15548 .header("content-type", "application/json")
15549 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15550 .unwrap(),
15551 )
15552 .await
15553 .unwrap();
15554 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15555 }
15556
15557 #[tokio::test]
15558 async fn http_subscribe_with_namespace_synthesizes_loopback_url_and_returns_201() {
15559 let state = test_state();
15560 let app = Router::new()
15561 .route("/api/v1/subscribe", axum_post(subscribe))
15562 .with_state(test_app_state(state));
15563 let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15564 let resp = app
15565 .oneshot(
15566 axum::http::Request::builder()
15567 .uri("/api/v1/subscribe")
15568 .method("POST")
15569 .header("content-type", "application/json")
15570 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15571 .unwrap(),
15572 )
15573 .await
15574 .unwrap();
15575 assert_eq!(resp.status(), StatusCode::CREATED);
15576 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15577 .await
15578 .unwrap();
15579 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15580 assert_eq!(v["namespace"], "team/alice");
15581 assert_eq!(v["agent_id"], "ai:alice");
15582 }
15583
15584 #[tokio::test]
15585 async fn http_unsubscribe_missing_id_and_namespace_returns_400() {
15586 let state = test_state();
15587 let app = Router::new()
15588 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15589 .with_state(test_app_state(state));
15590 let resp = app
15592 .oneshot(
15593 axum::http::Request::builder()
15594 .uri("/api/v1/subscribe")
15595 .method("DELETE")
15596 .header("x-agent-id", "ai:alice")
15597 .body(Body::empty())
15598 .unwrap(),
15599 )
15600 .await
15601 .unwrap();
15602 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15603 }
15604
15605 #[tokio::test]
15606 async fn http_unsubscribe_by_agent_namespace_after_subscribe_returns_removed() {
15607 let state = test_state();
15608 let sub_app = Router::new()
15611 .route("/api/v1/subscribe", axum_post(subscribe))
15612 .with_state(test_app_state(state.clone()));
15613 let body = serde_json::json!({"agent_id": "ai:alice", "namespace": "team/alice"});
15614 let resp = sub_app
15615 .oneshot(
15616 axum::http::Request::builder()
15617 .uri("/api/v1/subscribe")
15618 .method("POST")
15619 .header("content-type", "application/json")
15620 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15621 .unwrap(),
15622 )
15623 .await
15624 .unwrap();
15625 assert_eq!(resp.status(), StatusCode::CREATED);
15626
15627 let app = Router::new()
15628 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
15629 .with_state(test_app_state(state));
15630 let resp = app
15631 .oneshot(
15632 axum::http::Request::builder()
15633 .uri("/api/v1/subscribe?agent_id=ai:alice&namespace=team/alice")
15634 .method("DELETE")
15635 .body(Body::empty())
15636 .unwrap(),
15637 )
15638 .await
15639 .unwrap();
15640 assert_eq!(resp.status(), StatusCode::OK);
15641 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15642 .await
15643 .unwrap();
15644 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15645 assert_eq!(v["removed"], true);
15646 }
15647
15648 #[tokio::test]
15651 async fn http_list_subscriptions_returns_subscription_rows() {
15652 let state = test_state();
15653 let sub_app = Router::new()
15655 .route("/api/v1/subscribe", axum_post(subscribe))
15656 .with_state(test_app_state(state.clone()));
15657 let body = serde_json::json!({"agent_id": "ai:carol", "namespace": "team/carol"});
15658 let resp = sub_app
15659 .oneshot(
15660 axum::http::Request::builder()
15661 .uri("/api/v1/subscribe")
15662 .method("POST")
15663 .header("content-type", "application/json")
15664 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15665 .unwrap(),
15666 )
15667 .await
15668 .unwrap();
15669 assert_eq!(resp.status(), StatusCode::CREATED);
15670
15671 let app = Router::new()
15672 .route("/api/v1/subscriptions", axum_get(list_subscriptions))
15673 .with_state(state);
15674 let resp = app
15675 .oneshot(
15676 axum::http::Request::builder()
15677 .uri("/api/v1/subscriptions")
15678 .body(Body::empty())
15679 .unwrap(),
15680 )
15681 .await
15682 .unwrap();
15683 assert_eq!(resp.status(), StatusCode::OK);
15684 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15685 .await
15686 .unwrap();
15687 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15688 assert!(v["count"].as_u64().unwrap() >= 1);
15689 }
15690
15691 #[tokio::test]
15694 async fn http_kg_query_after_create_link_returns_node() {
15695 let state = test_state();
15696 let src = insert_test_memory(&state, "kg-q", "src").await;
15697 let tgt = insert_test_memory(&state, "kg-q", "tgt").await;
15698 {
15699 let lock = state.lock().await;
15700 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15701 }
15702 let app = Router::new()
15703 .route("/api/v1/kg/query", axum_post(kg_query))
15704 .with_state(state);
15705 let body = serde_json::json!({"source_id": src, "max_depth": 1, "limit": 10});
15706 let resp = app
15707 .oneshot(
15708 axum::http::Request::builder()
15709 .uri("/api/v1/kg/query")
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::OK);
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_eq!(v["source_id"], src);
15723 let mems = v["memories"].as_array().expect("memories array");
15724 assert!(!mems.is_empty());
15725 }
15726
15727 #[tokio::test]
15728 async fn http_kg_invalidate_round_trip_marks_link() {
15729 let state = test_state();
15730 let src = insert_test_memory(&state, "kg-inv", "src").await;
15731 let tgt = insert_test_memory(&state, "kg-inv", "tgt").await;
15732 {
15733 let lock = state.lock().await;
15734 db::create_link(&lock.0, &src, &tgt, "related_to").unwrap();
15735 }
15736 let app = Router::new()
15737 .route("/api/v1/kg/invalidate", axum_post(kg_invalidate))
15738 .with_state(state);
15739 let body = serde_json::json!({
15740 "source_id": src,
15741 "target_id": tgt,
15742 "relation": "related_to",
15743 "valid_until": "2030-01-01T00:00:00Z",
15744 });
15745 let resp = app
15746 .oneshot(
15747 axum::http::Request::builder()
15748 .uri("/api/v1/kg/invalidate")
15749 .method("POST")
15750 .header("content-type", "application/json")
15751 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15752 .unwrap(),
15753 )
15754 .await
15755 .unwrap();
15756 assert_eq!(resp.status(), StatusCode::OK);
15757 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15758 .await
15759 .unwrap();
15760 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15761 assert_eq!(v["found"], true);
15762 }
15763
15764 #[tokio::test]
15767 async fn http_list_archive_returns_archived_rows() {
15768 let state = test_state();
15769 let id = insert_test_memory(&state, "ns-archive", "row").await;
15770 {
15771 let lock = state.lock().await;
15772 db::archive_memory(&lock.0, &id, Some("test")).unwrap();
15773 }
15774 let app = Router::new()
15775 .route("/api/v1/archive", axum_get(list_archive))
15776 .with_state(state);
15777 let resp = app
15778 .oneshot(
15779 axum::http::Request::builder()
15780 .uri("/api/v1/archive?namespace=ns-archive&limit=10&offset=0")
15781 .body(Body::empty())
15782 .unwrap(),
15783 )
15784 .await
15785 .unwrap();
15786 assert_eq!(resp.status(), StatusCode::OK);
15787 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15788 .await
15789 .unwrap();
15790 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15791 assert!(v["count"].as_u64().unwrap() >= 1);
15792 }
15793
15794 #[tokio::test]
15797 async fn http_archive_by_ids_with_explicit_reason_records_it() {
15798 let state = test_state();
15799 let id = insert_test_memory(&state, "ns-arch", "row").await;
15800 let app = Router::new()
15801 .route("/api/v1/archive", axum_post(archive_by_ids))
15802 .with_state(test_app_state(state));
15803 let body = serde_json::json!({"ids": [id], "reason": "user requested"});
15804 let resp = app
15805 .oneshot(
15806 axum::http::Request::builder()
15807 .uri("/api/v1/archive")
15808 .method("POST")
15809 .header("content-type", "application/json")
15810 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15811 .unwrap(),
15812 )
15813 .await
15814 .unwrap();
15815 assert_eq!(resp.status(), StatusCode::OK);
15816 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15817 .await
15818 .unwrap();
15819 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15820 assert_eq!(v["reason"], "user requested");
15821 assert_eq!(v["count"], 1);
15822 }
15823
15824 fn over_max_string_vec(n: usize) -> Vec<String> {
15827 (0..n).map(|i| format!("id-{i:040}")).collect()
15828 }
15829
15830 #[tokio::test]
15831 async fn http_sync_push_oversize_deletions_returns_400() {
15832 let state = test_state();
15833 let app = Router::new()
15834 .route("/api/v1/sync/push", axum_post(sync_push))
15835 .with_state(test_app_state(state));
15836 let body = serde_json::json!({
15837 "sender_agent_id": "ai:peer",
15838 "memories": [],
15839 "deletions": over_max_string_vec(MAX_BULK_SIZE + 1),
15840 });
15841 let resp = app
15842 .oneshot(
15843 axum::http::Request::builder()
15844 .uri("/api/v1/sync/push")
15845 .method("POST")
15846 .header("content-type", "application/json")
15847 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15848 .unwrap(),
15849 )
15850 .await
15851 .unwrap();
15852 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15853 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15854 .await
15855 .unwrap();
15856 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15857 assert!(
15858 v["error"]
15859 .as_str()
15860 .unwrap()
15861 .contains("deletions per request"),
15862 "{v:?}"
15863 );
15864 }
15865
15866 #[tokio::test]
15867 async fn http_sync_push_oversize_archives_returns_400() {
15868 let state = test_state();
15869 let app = Router::new()
15870 .route("/api/v1/sync/push", axum_post(sync_push))
15871 .with_state(test_app_state(state));
15872 let body = serde_json::json!({
15873 "sender_agent_id": "ai:peer",
15874 "memories": [],
15875 "archives": over_max_string_vec(MAX_BULK_SIZE + 1),
15876 });
15877 let resp = app
15878 .oneshot(
15879 axum::http::Request::builder()
15880 .uri("/api/v1/sync/push")
15881 .method("POST")
15882 .header("content-type", "application/json")
15883 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15884 .unwrap(),
15885 )
15886 .await
15887 .unwrap();
15888 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15889 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15890 .await
15891 .unwrap();
15892 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15893 assert!(v["error"].as_str().unwrap().contains("archives"));
15894 }
15895
15896 #[tokio::test]
15897 async fn http_sync_push_oversize_restores_returns_400() {
15898 let state = test_state();
15899 let app = Router::new()
15900 .route("/api/v1/sync/push", axum_post(sync_push))
15901 .with_state(test_app_state(state));
15902 let body = serde_json::json!({
15903 "sender_agent_id": "ai:peer",
15904 "memories": [],
15905 "restores": over_max_string_vec(MAX_BULK_SIZE + 1),
15906 });
15907 let resp = app
15908 .oneshot(
15909 axum::http::Request::builder()
15910 .uri("/api/v1/sync/push")
15911 .method("POST")
15912 .header("content-type", "application/json")
15913 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15914 .unwrap(),
15915 )
15916 .await
15917 .unwrap();
15918 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15919 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15920 .await
15921 .unwrap();
15922 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15923 assert!(v["error"].as_str().unwrap().contains("restores"));
15924 }
15925
15926 #[tokio::test]
15927 async fn http_sync_push_oversize_namespace_meta_clears_returns_400() {
15928 let state = test_state();
15929 let app = Router::new()
15930 .route("/api/v1/sync/push", axum_post(sync_push))
15931 .with_state(test_app_state(state));
15932 let body = serde_json::json!({
15933 "sender_agent_id": "ai:peer",
15934 "memories": [],
15935 "namespace_meta_clears": over_max_string_vec(MAX_BULK_SIZE + 1),
15936 });
15937 let resp = app
15938 .oneshot(
15939 axum::http::Request::builder()
15940 .uri("/api/v1/sync/push")
15941 .method("POST")
15942 .header("content-type", "application/json")
15943 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15944 .unwrap(),
15945 )
15946 .await
15947 .unwrap();
15948 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15949 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15950 .await
15951 .unwrap();
15952 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15953 assert!(
15954 v["error"]
15955 .as_str()
15956 .unwrap()
15957 .contains("namespace_meta_clears")
15958 );
15959 }
15960
15961 #[tokio::test]
15962 async fn http_sync_push_invalid_sender_agent_id_returns_400() {
15963 let state = test_state();
15964 let app = Router::new()
15965 .route("/api/v1/sync/push", axum_post(sync_push))
15966 .with_state(test_app_state(state));
15967 let body = serde_json::json!({
15969 "sender_agent_id": "bad agent id",
15970 "memories": [],
15971 });
15972 let resp = app
15973 .oneshot(
15974 axum::http::Request::builder()
15975 .uri("/api/v1/sync/push")
15976 .method("POST")
15977 .header("content-type", "application/json")
15978 .body(Body::from(serde_json::to_vec(&body).unwrap()))
15979 .unwrap(),
15980 )
15981 .await
15982 .unwrap();
15983 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
15984 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
15985 .await
15986 .unwrap();
15987 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
15988 assert!(v["error"].as_str().unwrap().contains("sender_agent_id"));
15989 }
15990
15991 #[tokio::test]
15992 async fn http_sync_push_invalid_x_agent_id_header_returns_400() {
15993 let state = test_state();
15994 let app = Router::new()
15995 .route("/api/v1/sync/push", axum_post(sync_push))
15996 .with_state(test_app_state(state));
15997 let body = serde_json::json!({
15998 "sender_agent_id": "ai:peer",
15999 "memories": [],
16000 });
16001 let resp = app
16002 .oneshot(
16003 axum::http::Request::builder()
16004 .uri("/api/v1/sync/push")
16005 .method("POST")
16006 .header("content-type", "application/json")
16007 .header("x-agent-id", "bad agent id")
16008 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16009 .unwrap(),
16010 )
16011 .await
16012 .unwrap();
16013 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16014 }
16015
16016 #[tokio::test]
16019 async fn http_sync_push_pending_invalid_id_skipped() {
16020 let state = test_state();
16021 let app = Router::new()
16022 .route("/api/v1/sync/push", axum_post(sync_push))
16023 .with_state(test_app_state(state));
16024 let bad_id = "x".repeat(200); let body = serde_json::json!({
16026 "sender_agent_id": "ai:peer",
16027 "memories": [],
16028 "pendings": [{
16029 "id": bad_id,
16030 "action_type": "store",
16031 "memory_id": null,
16032 "namespace": "ns",
16033 "payload": {},
16034 "requested_by": "ai:peer",
16035 "requested_at": "2024-01-01T00:00:00Z",
16036 "status": "pending",
16037 "approvals": [],
16038 }],
16039 });
16040 let resp = app
16041 .oneshot(
16042 axum::http::Request::builder()
16043 .uri("/api/v1/sync/push")
16044 .method("POST")
16045 .header("content-type", "application/json")
16046 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16047 .unwrap(),
16048 )
16049 .await
16050 .unwrap();
16051 assert_eq!(resp.status(), StatusCode::OK);
16052 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16053 .await
16054 .unwrap();
16055 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16056 assert_eq!(v["skipped"], 1);
16057 assert_eq!(v["pendings_applied"], 0);
16058 }
16059
16060 #[tokio::test]
16061 async fn http_sync_push_links_invalid_id_skipped() {
16062 let state = test_state();
16063 let app = Router::new()
16064 .route("/api/v1/sync/push", axum_post(sync_push))
16065 .with_state(test_app_state(state));
16066 let body = serde_json::json!({
16068 "sender_agent_id": "ai:peer",
16069 "memories": [],
16070 "links": [{
16071 "source_id": "abc",
16072 "target_id": "abc",
16073 "relation": "related_to",
16074 "created_at": "2024-01-01T00:00:00Z",
16075 }],
16076 });
16077 let resp = app
16078 .oneshot(
16079 axum::http::Request::builder()
16080 .uri("/api/v1/sync/push")
16081 .method("POST")
16082 .header("content-type", "application/json")
16083 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16084 .unwrap(),
16085 )
16086 .await
16087 .unwrap();
16088 assert_eq!(resp.status(), StatusCode::OK);
16089 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16090 .await
16091 .unwrap();
16092 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16093 assert_eq!(v["skipped"], 1);
16094 assert_eq!(v["links_applied"], 0);
16095 }
16096
16097 #[tokio::test]
16098 async fn http_sync_push_dry_run_links_no_apply() {
16099 let state = test_state();
16100 let src = insert_test_memory(&state, "dryrun-links", "src").await;
16101 let tgt = insert_test_memory(&state, "dryrun-links", "tgt").await;
16102 let app = Router::new()
16103 .route("/api/v1/sync/push", axum_post(sync_push))
16104 .with_state(test_app_state(state));
16105 let body = serde_json::json!({
16106 "sender_agent_id": "ai:peer",
16107 "memories": [],
16108 "links": [{
16109 "source_id": src,
16110 "target_id": tgt,
16111 "relation": "related_to",
16112 "created_at": "2024-01-01T00:00:00Z",
16113 }],
16114 "dry_run": true,
16115 });
16116 let resp = app
16117 .oneshot(
16118 axum::http::Request::builder()
16119 .uri("/api/v1/sync/push")
16120 .method("POST")
16121 .header("content-type", "application/json")
16122 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16123 .unwrap(),
16124 )
16125 .await
16126 .unwrap();
16127 assert_eq!(resp.status(), StatusCode::OK);
16128 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16129 .await
16130 .unwrap();
16131 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16132 assert_eq!(v["links_applied"], 0);
16133 assert_eq!(v["dry_run"], true);
16134 }
16135
16136 #[tokio::test]
16139 async fn http_consolidate_invalid_title_returns_400() {
16140 let state = test_state();
16141 let id1 = insert_test_memory(&state, "ns-cons", "a").await;
16142 let id2 = insert_test_memory(&state, "ns-cons", "b").await;
16143 let app = Router::new()
16144 .route("/api/v1/consolidate", axum_post(consolidate_memories))
16145 .with_state(test_app_state(state));
16146 let body = serde_json::json!({
16147 "ids": [id1, id2],
16148 "title": "",
16149 "summary": "Summary text",
16150 "namespace": "ns-cons",
16151 });
16152 let resp = app
16153 .oneshot(
16154 axum::http::Request::builder()
16155 .uri("/api/v1/consolidate")
16156 .method("POST")
16157 .header("content-type", "application/json")
16158 .header("x-agent-id", "ai:tester")
16159 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16160 .unwrap(),
16161 )
16162 .await
16163 .unwrap();
16164 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16165 }
16166
16167 #[tokio::test]
16170 async fn http_bulk_create_zero_body_returns_zero_created() {
16171 let state = test_state();
16172 let app = Router::new()
16173 .route("/api/v1/memories/bulk", axum_post(bulk_create))
16174 .with_state(test_app_state(state));
16175 let body: Vec<serde_json::Value> = Vec::new();
16176 let resp = app
16177 .oneshot(
16178 axum::http::Request::builder()
16179 .uri("/api/v1/memories/bulk")
16180 .method("POST")
16181 .header("content-type", "application/json")
16182 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16183 .unwrap(),
16184 )
16185 .await
16186 .unwrap();
16187 assert_eq!(resp.status(), StatusCode::OK);
16188 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16189 .await
16190 .unwrap();
16191 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16192 assert_eq!(v["created"], 0);
16193 }
16194
16195 #[tokio::test]
16198 async fn http_entity_register_with_x_agent_id_header_succeeds() {
16199 let state = test_state();
16200 let app = Router::new()
16201 .route("/api/v1/entities", axum_post(entity_register))
16202 .with_state(state);
16203 let body = serde_json::json!({
16204 "canonical_name": "Acme Inc",
16205 "namespace": "corp",
16206 "aliases": ["acme", "ACME"],
16207 });
16208 let resp = app
16209 .oneshot(
16210 axum::http::Request::builder()
16211 .uri("/api/v1/entities")
16212 .method("POST")
16213 .header("content-type", "application/json")
16214 .header("x-agent-id", "ai:tester")
16215 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16216 .unwrap(),
16217 )
16218 .await
16219 .unwrap();
16220 assert_eq!(resp.status(), StatusCode::CREATED);
16221 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16222 .await
16223 .unwrap();
16224 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16225 assert_eq!(v["created"], true);
16226 assert_eq!(v["canonical_name"], "Acme Inc");
16227 }
16228
16229 #[tokio::test]
16232 async fn http_get_inbox_without_caller_uses_anonymous_default() {
16233 let state = test_state();
16237 let app = Router::new()
16238 .route("/api/v1/inbox", axum_get(get_inbox))
16239 .with_state(test_app_state(state));
16240 let resp = app
16241 .oneshot(
16242 axum::http::Request::builder()
16243 .uri("/api/v1/inbox")
16244 .body(Body::empty())
16245 .unwrap(),
16246 )
16247 .await
16248 .unwrap();
16249 assert_eq!(resp.status(), StatusCode::OK);
16250 }
16251
16252 #[tokio::test]
16255 async fn http_approve_pending_with_bad_header_agent_id_returns_400() {
16256 let state = test_state();
16257 let app = Router::new()
16258 .route("/api/v1/pending/{id}/approve", axum_post(approve_pending))
16259 .with_state(test_app_state(state));
16260 let id = "abcdef0123456789abcdef0123456789";
16261 let resp = app
16262 .oneshot(
16263 axum::http::Request::builder()
16264 .uri(format!("/api/v1/pending/{id}/approve"))
16265 .method("POST")
16266 .header("x-agent-id", "bad agent id")
16267 .body(Body::empty())
16268 .unwrap(),
16269 )
16270 .await
16271 .unwrap();
16272 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16273 }
16274
16275 #[tokio::test]
16278 async fn http_reject_pending_with_bad_header_agent_id_returns_400() {
16279 let state = test_state();
16280 let app = Router::new()
16281 .route("/api/v1/pending/{id}/reject", axum_post(reject_pending))
16282 .with_state(test_app_state(state));
16283 let id = "abcdef0123456789abcdef0123456789";
16284 let resp = app
16285 .oneshot(
16286 axum::http::Request::builder()
16287 .uri(format!("/api/v1/pending/{id}/reject"))
16288 .method("POST")
16289 .header("x-agent-id", "bad agent id")
16290 .body(Body::empty())
16291 .unwrap(),
16292 )
16293 .await
16294 .unwrap();
16295 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16296 }
16297
16298 #[tokio::test]
16301 async fn http_create_memory_invalid_x_agent_id_header_returns_400() {
16302 let state = test_state();
16303 let app = Router::new()
16304 .route("/api/v1/memories", axum_post(create_memory))
16305 .with_state(test_app_state(state));
16306 let body = serde_json::json!({
16307 "tier": "long",
16308 "namespace": "test",
16309 "title": "t",
16310 "content": "c",
16311 "tags": [],
16312 "priority": 5,
16313 "confidence": 1.0,
16314 "source": "api",
16315 "metadata": {}
16316 });
16317 let resp = app
16318 .oneshot(
16319 axum::http::Request::builder()
16320 .uri("/api/v1/memories")
16321 .method("POST")
16322 .header("content-type", "application/json")
16323 .header("x-agent-id", "bad agent id")
16324 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16325 .unwrap(),
16326 )
16327 .await
16328 .unwrap();
16329 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16330 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16331 .await
16332 .unwrap();
16333 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16334 assert!(v["error"].as_str().unwrap().contains("agent_id"));
16335 }
16336
16337 #[tokio::test]
16340 async fn http_create_memory_invalid_scope_returns_400() {
16341 let state = test_state();
16342 let app = Router::new()
16343 .route("/api/v1/memories", axum_post(create_memory))
16344 .with_state(test_app_state(state));
16345 let body = serde_json::json!({
16348 "tier": "long",
16349 "namespace": "test",
16350 "title": "t",
16351 "content": "c",
16352 "tags": [],
16353 "priority": 5,
16354 "confidence": 1.0,
16355 "source": "api",
16356 "metadata": {},
16357 "scope": "not-a-valid-scope-token"
16358 });
16359 let resp = app
16360 .oneshot(
16361 axum::http::Request::builder()
16362 .uri("/api/v1/memories")
16363 .method("POST")
16364 .header("content-type", "application/json")
16365 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16366 .unwrap(),
16367 )
16368 .await
16369 .unwrap();
16370 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16371 }
16372
16373 #[tokio::test]
16376 async fn http_list_memories_invalid_agent_id_filter_returns_400() {
16377 let state = test_state();
16378 let app = Router::new()
16379 .route("/api/v1/memories", axum_get(list_memories))
16380 .with_state(state);
16381 let resp = app
16382 .oneshot(
16383 axum::http::Request::builder()
16384 .uri("/api/v1/memories?agent_id=bad%20id")
16385 .body(Body::empty())
16386 .unwrap(),
16387 )
16388 .await
16389 .unwrap();
16390 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16391 }
16392
16393 #[tokio::test]
16396 async fn http_check_duplicate_blank_namespace_treated_as_none() {
16397 let state = test_state();
16400 let app = Router::new()
16401 .route("/api/v1/check_duplicate", axum_post(check_duplicate))
16402 .with_state(test_app_state(state));
16403 let body = serde_json::json!({"title": "t", "content": "c", "namespace": " "});
16404 let resp = app
16405 .oneshot(
16406 axum::http::Request::builder()
16407 .uri("/api/v1/check_duplicate")
16408 .method("POST")
16409 .header("content-type", "application/json")
16410 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16411 .unwrap(),
16412 )
16413 .await
16414 .unwrap();
16415 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
16416 }
16417
16418 #[tokio::test]
16423 async fn http_archive_by_ids_with_no_reason_defaults_to_archive() {
16424 let state = test_state();
16425 let id = insert_test_memory(&state, "ns-arch-default", "row").await;
16426 let app = Router::new()
16427 .route("/api/v1/archive", axum_post(archive_by_ids))
16428 .with_state(test_app_state(state));
16429 let body = serde_json::json!({"ids": [id]});
16430 let resp = app
16431 .oneshot(
16432 axum::http::Request::builder()
16433 .uri("/api/v1/archive")
16434 .method("POST")
16435 .header("content-type", "application/json")
16436 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16437 .unwrap(),
16438 )
16439 .await
16440 .unwrap();
16441 assert_eq!(resp.status(), StatusCode::OK);
16442 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16443 .await
16444 .unwrap();
16445 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16446 assert_eq!(v["reason"], "archive");
16447 }
16448
16449 async fn seed_governance_policy(state: &Db, ns: &str, policy: serde_json::Value) {
16460 let lock = state.lock().await;
16461 let now = Utc::now().to_rfc3339();
16462 let standard = Memory {
16463 id: Uuid::new_v4().to_string(),
16464 tier: Tier::Long,
16465 namespace: ns.into(),
16466 title: format!("_standard:{ns}"),
16467 content: format!("standard for {ns}"),
16468 tags: vec!["_namespace_standard".to_string()],
16469 priority: 5,
16470 confidence: 1.0,
16471 source: "test".into(),
16472 access_count: 0,
16473 created_at: now.clone(),
16474 updated_at: now,
16475 last_accessed_at: None,
16476 expires_at: None,
16477 metadata: serde_json::json!({
16478 "agent_id": "ai:owner",
16479 "governance": policy,
16480 }),
16481 };
16482 let standard_id = db::insert(&lock.0, &standard).unwrap();
16483 db::set_namespace_standard(&lock.0, ns, &standard_id, None).unwrap();
16484 }
16485
16486 #[tokio::test]
16487 async fn http_create_memory_governance_pending_returns_202() {
16488 let state = test_state();
16489 seed_governance_policy(
16490 &state,
16491 "gov-create",
16492 serde_json::json!({
16493 "write": "approve",
16494 "delete": "owner",
16495 "promote": "any",
16496 "approver": "human",
16497 }),
16498 )
16499 .await;
16500 let app = Router::new()
16501 .route("/api/v1/memories", axum_post(create_memory))
16502 .with_state(test_app_state(state));
16503 let body = serde_json::json!({
16504 "tier": "long",
16505 "namespace": "gov-create",
16506 "title": "queued",
16507 "content": "should be queued, not stored",
16508 "tags": [],
16509 "priority": 5,
16510 "confidence": 1.0,
16511 "source": "api",
16512 "metadata": {},
16513 });
16514 let resp = app
16515 .oneshot(
16516 axum::http::Request::builder()
16517 .uri("/api/v1/memories")
16518 .method("POST")
16519 .header("content-type", "application/json")
16520 .header("x-agent-id", "ai:caller")
16521 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16522 .unwrap(),
16523 )
16524 .await
16525 .unwrap();
16526 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16527 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16528 .await
16529 .unwrap();
16530 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16531 assert_eq!(v["status"], "pending");
16532 assert_eq!(v["action"], "store");
16533 assert!(v["pending_id"].is_string());
16534 }
16535
16536 #[tokio::test]
16537 async fn http_create_memory_governance_deny_returns_403() {
16538 let state = test_state();
16540 seed_governance_policy(
16541 &state,
16542 "gov-deny",
16543 serde_json::json!({"write": "registered", "approver": "human"}),
16544 )
16545 .await;
16546 let app = Router::new()
16547 .route("/api/v1/memories", axum_post(create_memory))
16548 .with_state(test_app_state(state));
16549 let body = serde_json::json!({
16550 "tier": "long",
16551 "namespace": "gov-deny",
16552 "title": "rejected",
16553 "content": "rejected content",
16554 "tags": [],
16555 "priority": 5,
16556 "confidence": 1.0,
16557 "source": "api",
16558 "metadata": {},
16559 });
16560 let resp = app
16561 .oneshot(
16562 axum::http::Request::builder()
16563 .uri("/api/v1/memories")
16564 .method("POST")
16565 .header("content-type", "application/json")
16566 .header("x-agent-id", "ai:unregistered")
16567 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16568 .unwrap(),
16569 )
16570 .await
16571 .unwrap();
16572 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16573 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16574 .await
16575 .unwrap();
16576 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16577 assert!(v["error"].as_str().unwrap().contains("governance"));
16578 }
16579
16580 #[tokio::test]
16581 async fn http_delete_memory_governance_pending_returns_202() {
16582 let state = test_state();
16583 seed_governance_policy(
16584 &state,
16585 "gov-delete",
16586 serde_json::json!({
16587 "write": "any",
16588 "delete": "approve",
16589 "promote": "any",
16590 "approver": "human",
16591 }),
16592 )
16593 .await;
16594 let id = insert_test_memory(&state, "gov-delete", "to-delete").await;
16595 let app = Router::new()
16596 .route(
16597 "/api/v1/memories/{id}",
16598 axum::routing::delete(delete_memory),
16599 )
16600 .with_state(test_app_state(state));
16601 let resp = app
16602 .oneshot(
16603 axum::http::Request::builder()
16604 .uri(format!("/api/v1/memories/{id}"))
16605 .method("DELETE")
16606 .header("x-agent-id", "ai:caller")
16607 .body(Body::empty())
16608 .unwrap(),
16609 )
16610 .await
16611 .unwrap();
16612 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16613 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16614 .await
16615 .unwrap();
16616 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16617 assert_eq!(v["status"], "pending");
16618 assert_eq!(v["action"], "delete");
16619 assert_eq!(v["memory_id"], id);
16620 }
16621
16622 #[tokio::test]
16623 async fn http_delete_memory_governance_deny_returns_403() {
16624 let state = test_state();
16625 seed_governance_policy(
16626 &state,
16627 "gov-delete-deny",
16628 serde_json::json!({"write": "any", "delete": "owner", "approver": "human"}),
16629 )
16630 .await;
16631 let id = insert_test_memory(&state, "gov-delete-deny", "row").await;
16637 let app = Router::new()
16638 .route(
16639 "/api/v1/memories/{id}",
16640 axum::routing::delete(delete_memory),
16641 )
16642 .with_state(test_app_state(state));
16643 let resp = app
16644 .oneshot(
16645 axum::http::Request::builder()
16646 .uri(format!("/api/v1/memories/{id}"))
16647 .method("DELETE")
16648 .header("x-agent-id", "ai:other")
16649 .body(Body::empty())
16650 .unwrap(),
16651 )
16652 .await
16653 .unwrap();
16654 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
16655 }
16656
16657 #[tokio::test]
16658 async fn http_promote_memory_governance_pending_returns_202() {
16659 let state = test_state();
16660 seed_governance_policy(
16661 &state,
16662 "gov-promote",
16663 serde_json::json!({
16664 "write": "any",
16665 "delete": "any",
16666 "promote": "approve",
16667 "approver": "human",
16668 }),
16669 )
16670 .await;
16671 let id = insert_test_memory(&state, "gov-promote", "to-promote").await;
16672 let app = Router::new()
16673 .route("/api/v1/memories/{id}/promote", axum_post(promote_memory))
16674 .with_state(test_app_state(state));
16675 let resp = app
16676 .oneshot(
16677 axum::http::Request::builder()
16678 .uri(format!("/api/v1/memories/{id}/promote"))
16679 .method("POST")
16680 .header("x-agent-id", "ai:caller")
16681 .body(Body::empty())
16682 .unwrap(),
16683 )
16684 .await
16685 .unwrap();
16686 assert_eq!(resp.status(), StatusCode::ACCEPTED);
16687 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16688 .await
16689 .unwrap();
16690 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16691 assert_eq!(v["status"], "pending");
16692 assert_eq!(v["action"], "promote");
16693 assert_eq!(v["memory_id"], id);
16694 }
16695
16696 #[tokio::test]
16699 async fn http_create_memory_with_top_level_scope_succeeds() {
16700 let state = test_state();
16701 let app = Router::new()
16702 .route("/api/v1/memories", axum_post(create_memory))
16703 .with_state(test_app_state(state));
16704 let body = serde_json::json!({
16705 "tier": "long",
16706 "namespace": "scoped",
16707 "title": "with scope",
16708 "content": "scoped content",
16709 "tags": [],
16710 "priority": 5,
16711 "confidence": 1.0,
16712 "source": "api",
16713 "metadata": {},
16714 "scope": "private"
16715 });
16716 let resp = app
16717 .oneshot(
16718 axum::http::Request::builder()
16719 .uri("/api/v1/memories")
16720 .method("POST")
16721 .header("content-type", "application/json")
16722 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16723 .unwrap(),
16724 )
16725 .await
16726 .unwrap();
16727 assert_eq!(resp.status(), StatusCode::CREATED);
16728 }
16729
16730 #[tokio::test]
16733 async fn http_create_memory_clamps_extreme_priority_to_range() {
16734 let state = test_state();
16735 let app = Router::new()
16736 .route("/api/v1/memories", axum_post(create_memory))
16737 .with_state(test_app_state(state.clone()));
16738 let body = serde_json::json!({
16741 "tier": "long",
16742 "namespace": "clamp",
16743 "title": "clamp",
16744 "content": "c",
16745 "tags": [],
16746 "priority": 10,
16747 "confidence": 1.0,
16748 "source": "api",
16749 "metadata": {},
16750 });
16751 let resp = app
16752 .oneshot(
16753 axum::http::Request::builder()
16754 .uri("/api/v1/memories")
16755 .method("POST")
16756 .header("content-type", "application/json")
16757 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16758 .unwrap(),
16759 )
16760 .await
16761 .unwrap();
16762 assert_eq!(resp.status(), StatusCode::CREATED);
16763 let lock = state.lock().await;
16765 let rows = db::list(
16766 &lock.0,
16767 Some("clamp"),
16768 None,
16769 10,
16770 0,
16771 None,
16772 None,
16773 None,
16774 None,
16775 None,
16776 )
16777 .unwrap();
16778 assert_eq!(rows[0].priority, 10);
16779 }
16780
16781 #[tokio::test]
16784 async fn http_update_memory_with_oversized_title_returns_400() {
16785 let state = test_state();
16786 let id = insert_test_memory(&state, "ns-bigtitle", "old").await;
16787 let app = Router::new()
16788 .route("/api/v1/memories/{id}", axum::routing::put(update_memory))
16789 .with_state(test_app_state(state));
16790 let big_title = "T".repeat(10_000);
16792 let body = serde_json::json!({"title": big_title});
16793 let resp = app
16794 .oneshot(
16795 axum::http::Request::builder()
16796 .uri(format!("/api/v1/memories/{id}"))
16797 .method("PUT")
16798 .header("content-type", "application/json")
16799 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16800 .unwrap(),
16801 )
16802 .await
16803 .unwrap();
16804 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
16805 }
16806
16807 #[tokio::test]
16810 async fn http_purge_archive_no_query_returns_purged_zero_for_empty_archive() {
16811 let state = test_state();
16812 let app = Router::new()
16813 .route("/api/v1/archive", axum::routing::delete(purge_archive))
16814 .with_state(state);
16815 let resp = app
16816 .oneshot(
16817 axum::http::Request::builder()
16818 .uri("/api/v1/archive")
16819 .method("DELETE")
16820 .body(Body::empty())
16821 .unwrap(),
16822 )
16823 .await
16824 .unwrap();
16825 assert_eq!(resp.status(), StatusCode::OK);
16826 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16827 .await
16828 .unwrap();
16829 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16830 assert_eq!(v["purged"], 0);
16831 }
16832
16833 #[tokio::test]
16836 async fn http_contradictions_topic_only_returns_ok_empty() {
16837 let state = test_state();
16838 let app = Router::new()
16839 .route("/api/v1/contradictions", axum_get(detect_contradictions))
16840 .with_state(state);
16841 let resp = app
16842 .oneshot(
16843 axum::http::Request::builder()
16844 .uri("/api/v1/contradictions?topic=missing-topic")
16845 .body(Body::empty())
16846 .unwrap(),
16847 )
16848 .await
16849 .unwrap();
16850 assert_eq!(resp.status(), StatusCode::OK);
16851 }
16852
16853 #[tokio::test]
16856 async fn http_entity_register_aliases_with_blanks_filtered() {
16857 let state = test_state();
16858 let app = Router::new()
16859 .route("/api/v1/entities", axum_post(entity_register))
16860 .with_state(state);
16861 let body = serde_json::json!({
16862 "canonical_name": "Globex",
16863 "namespace": "corp2",
16864 "aliases": ["", "globex", " ", "GLOBEX"],
16865 });
16866 let resp = app
16867 .oneshot(
16868 axum::http::Request::builder()
16869 .uri("/api/v1/entities")
16870 .method("POST")
16871 .header("content-type", "application/json")
16872 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16873 .unwrap(),
16874 )
16875 .await
16876 .unwrap();
16877 assert_eq!(resp.status(), StatusCode::CREATED);
16878 }
16879
16880 #[tokio::test]
16883 async fn http_subscribe_with_explicit_url_succeeds() {
16884 let state = test_state();
16885 let app = Router::new()
16886 .route("/api/v1/subscribe", axum_post(subscribe))
16887 .with_state(test_app_state(state));
16888 let body = serde_json::json!({
16889 "agent_id": "ai:webhook-user",
16890 "url": "http://localhost:9999/webhook",
16891 "events": "store",
16892 "secret": "shhh",
16893 "namespace_filter": "team",
16894 });
16895 let resp = app
16896 .oneshot(
16897 axum::http::Request::builder()
16898 .uri("/api/v1/subscribe")
16899 .method("POST")
16900 .header("content-type", "application/json")
16901 .body(Body::from(serde_json::to_vec(&body).unwrap()))
16902 .unwrap(),
16903 )
16904 .await
16905 .unwrap();
16906 assert_eq!(resp.status(), StatusCode::CREATED);
16907 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
16908 .await
16909 .unwrap();
16910 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
16911 assert_eq!(v["url"], "http://localhost:9999/webhook");
16912 assert_eq!(v["events"], "store");
16913 }
16914
16915 #[tokio::test]
16918 async fn http_unsubscribe_by_unknown_id_returns_ok_unchanged() {
16919 let state = test_state();
16920 let app = Router::new()
16921 .route("/api/v1/subscribe", axum::routing::delete(unsubscribe))
16922 .with_state(test_app_state(state));
16923 let resp = app
16926 .oneshot(
16927 axum::http::Request::builder()
16928 .uri("/api/v1/subscribe?id=does-not-exist")
16929 .method("DELETE")
16930 .body(Body::empty())
16931 .unwrap(),
16932 )
16933 .await
16934 .unwrap();
16935 assert!(
16938 resp.status() == StatusCode::OK || resp.status() == StatusCode::BAD_REQUEST,
16939 "got {}",
16940 resp.status()
16941 );
16942 }
16943}