f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
628 lines
24 KiB
Rust
628 lines
24 KiB
Rust
//! E2E tests for IDMM (Intelligent Decision-Making Mode) HTTP endpoints.
|
|
//!
|
|
//! Phase 2: the per-session config is two independently-toggled watches
|
|
//! (`fault_watch` / `decision_watch`), each with a flattened [`WatchBase`]
|
|
//! (enabled / tier / bypass_model / …). The `RulePlusModel` tier requires a
|
|
//! resolvable bypass model; `RuleOnly` does not. The GET status surfaces
|
|
//! `enabled` (= any watch on), `fault_enabled`, `decision_enabled`, and the
|
|
//! persisted `config` blob for form rehydration.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use common::{
|
|
body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login,
|
|
};
|
|
|
|
async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str) -> String {
|
|
let body = json!({ "type": "nomi", "name": "idmm-e2e", "extra": { "workspace": "/project" } });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/conversations", body, token, csrf))
|
|
.await
|
|
.unwrap();
|
|
let json = body_json(resp).await;
|
|
json["data"]["id"].as_i64().unwrap().to_string()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unauthenticated_get_is_rejected() {
|
|
let (app, _services) = build_app().await;
|
|
let resp = app
|
|
.oneshot(get_request("/api/idmm/conversation/whatever"))
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
|
"expected 401/403, got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rule_only_config_roundtrip_on_conversation() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
// Enable the decision watch at the RuleOnly tier (no bypass model required).
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": true, "tier": "rule_only", "scan_interval_secs": 30, "max_retries": 3 }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "RuleOnly enable should succeed");
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], true);
|
|
assert_eq!(j["data"]["decision_enabled"], true);
|
|
assert_eq!(j["data"]["run_state"], "armed");
|
|
|
|
// Read it back.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], true);
|
|
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_only");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allow_unmarked_pick_flag_round_trips_on_conversation() {
|
|
// The decision strategy's option-decision auto-pick flag must survive
|
|
// serialize → DB → deserialize so the saved config rehydrates the form (and
|
|
// the supervisor reads it). Set it explicitly and read it back.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": {
|
|
"enabled": true,
|
|
"tier": "rule_only",
|
|
"strategy": { "categories": { "option_decision": { "allow_unmarked_pick": true } } }
|
|
}
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "enabling RuleOnly auto-pick should succeed");
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(
|
|
j["data"]["config"]["decision_watch"]["strategy"]["categories"]["option_decision"]["allow_unmarked_pick"],
|
|
true,
|
|
"allow_unmarked_pick must persist and rehydrate; got {j:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_tier_without_freeform_policy_is_allowed() {
|
|
// The strategy's freeform policy is OPTIONAL for the RulePlusModel tier (a
|
|
// conservative built-in policy is used when empty). With a resolvable global
|
|
// backup provider, enabling the model tier with no freeform must SUCCEED.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let settings = json!({ "backup_provider_id": "prov-1", "default_steering_prompt": "" });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"empty freeform must be allowed once a backup model resolves"
|
|
);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], true);
|
|
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_plus_model");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_tier_without_backup_provider_is_rejected() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
// No global backup provider, no per-watch bypass model, and the e2e
|
|
// conversation carries no model of its own → nothing resolves → must 400.
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let j = body_json(resp).await;
|
|
assert!(
|
|
j["error"].as_str().unwrap_or("").contains("backup"),
|
|
"error should mention the missing backup model, got {j:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fault_watch_model_tier_without_backup_is_rejected() {
|
|
// The fault watch on the model tier carries the same backup requirement.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"fault_watch": { "enabled": true, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_tier_with_global_backup_succeeds() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
// Configure a global backup provider.
|
|
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": {
|
|
"enabled": true,
|
|
"tier": "rule_plus_model",
|
|
"strategy": { "freeform_policy": "prefer the recommended option; never delete data" }
|
|
}
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"model tier enable with a global backup should succeed"
|
|
);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["config"]["decision_watch"]["tier"], "rule_plus_model");
|
|
assert_eq!(j["data"]["sidecar_provider_resolved"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settings_roundtrip() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let settings = json!({
|
|
"backup_provider_id": "prov-xyz",
|
|
"backup_model": "model-xyz",
|
|
"default_steering_prompt": "be conservative"
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/settings", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["backup_provider_id"], "prov-xyz");
|
|
assert_eq!(j["data"]["backup_model"], "model-xyz");
|
|
assert_eq!(j["data"]["default_steering_prompt"], "be conservative");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_target_unknown_kind_is_rejected() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/bogus/some-id", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_target_not_found_is_rejected() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
// A terminal that does not exist → ownership verification yields 404.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/terminal/nonexistent-term", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_state_reports_off() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
// A conversation id that was never configured → default disabled state.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/conversation/999999", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], false);
|
|
assert_eq!(j["data"]["run_state"], "off");
|
|
}
|
|
|
|
// ── must always be able to disable + persisted config round-trips ──
|
|
|
|
#[tokio::test]
|
|
async fn disable_model_watch_without_backup_succeeds() {
|
|
// The user filled the model-tier form, then toggled off. The disable POST
|
|
// sends the model tier but `enabled: false`. Pre-fix this returned 400 and
|
|
// the user could not disable; post-fix the disable must always succeed (a
|
|
// disabled watch carries no operational requirements).
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": false, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"disabling must always succeed regardless of model-tier prerequisites"
|
|
);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], false);
|
|
assert_eq!(j["data"]["run_state"], "off");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_to_disabled_transition_succeeds_without_validation() {
|
|
// The user enables the model tier with a global backup, later turns it off.
|
|
// The disable POST must succeed even without the backup still resolving.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Enable.
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": true, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
// Disable → must still succeed.
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"decision_watch": { "enabled": false, "tier": "rule_plus_model" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], false);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_status_round_trips_persisted_config() {
|
|
// After saving a config, GET must return the persisted blob (both watches,
|
|
// tiers, bypass model, strategy) so the frontend can rehydrate its form.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let settings = json!({ "backup_provider_id": "prov-1", "backup_model": "m1", "default_steering_prompt": "" });
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("PUT", "/api/idmm/settings", settings, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"fault_watch": { "enabled": true, "tier": "rule_only", "scan_interval_secs": 45, "max_retries": 7 },
|
|
"decision_watch": {
|
|
"enabled": true,
|
|
"tier": "rule_plus_model",
|
|
"answer_open_questions": true,
|
|
"bypass_model": { "provider_id": "prov-watch", "model": "m-watch" },
|
|
"strategy": { "freeform_policy": "round-trip me", "tendency": "aggressive" }
|
|
}
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token(&format!("/api/idmm/conversation/{conv}"), &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
let cfg = j["data"]["config"].clone();
|
|
assert!(
|
|
cfg.is_object(),
|
|
"GET must include the persisted IdmmConfig under .data.config; got {j:?}"
|
|
);
|
|
assert_eq!(cfg["fault_watch"]["enabled"], true);
|
|
assert_eq!(cfg["fault_watch"]["tier"], "rule_only");
|
|
assert_eq!(cfg["fault_watch"]["scan_interval_secs"], 45);
|
|
assert_eq!(cfg["fault_watch"]["max_retries"], 7);
|
|
assert_eq!(cfg["decision_watch"]["enabled"], true);
|
|
assert_eq!(cfg["decision_watch"]["tier"], "rule_plus_model");
|
|
assert_eq!(cfg["decision_watch"]["answer_open_questions"], true);
|
|
assert_eq!(cfg["decision_watch"]["bypass_model"]["provider_id"], "prov-watch");
|
|
assert_eq!(cfg["decision_watch"]["bypass_model"]["model"], "m-watch");
|
|
assert_eq!(cfg["decision_watch"]["strategy"]["freeform_policy"], "round-trip me");
|
|
assert_eq!(cfg["decision_watch"]["strategy"]["tendency"], "aggressive");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_phase1_blob_disables_gracefully() {
|
|
// D3 back-compat at the HTTP layer: a Phase-1-shaped body (enabled/tier/
|
|
// rule/sidecar/steering_prompt) must not error — serde ignores the unknown
|
|
// fields and both watches deserialize to the default (disabled). The POST
|
|
// succeeds and the target reports `enabled: false`.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let body = json!({
|
|
"kind": "conversation",
|
|
"target_id": conv,
|
|
"enabled": true,
|
|
"tier": "rule_plus_sidecar",
|
|
"steering_prompt": "prefer recommended",
|
|
"rule": { "idle_threshold_secs": 30, "max_retries": 3, "auto_pick_unmarked": true },
|
|
"sidecar": { "provider_id": "prov", "model": "m" }
|
|
});
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"a legacy Phase-1 blob must deserialize to default (disabled), not error"
|
|
);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"]["enabled"], false, "legacy blob → both watches default-disabled");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_status_omits_config_when_never_configured() {
|
|
// Targets that were never saved must not carry a `config` field so the
|
|
// frontend knows to seed from global defaults rather than a blank blob.
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/conversation/999999", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert!(
|
|
j["data"].get("config").is_none() || j["data"]["config"].is_null(),
|
|
"unsaved targets must not carry a persisted config; got {j:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_conversation_cascades_idmm_records() {
|
|
// IDMM records are disposable and carry no FK (polymorphic target_id), so the
|
|
// app layer must clear them when the owning conversation is deleted. Insert a
|
|
// record for a conversation target, delete the conversation via the HTTP route
|
|
// (which fires the OnConversationDelete cascade hook), then assert the record
|
|
// is gone.
|
|
use nomifun_db::models::IdmmInterventionRow;
|
|
use nomifun_db::{IIdmmInterventionRepository, SqliteIdmmInterventionRepository};
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let conv = create_conversation(&mut app, &token, &csrf).await;
|
|
|
|
let records: SqliteIdmmInterventionRepository =
|
|
SqliteIdmmInterventionRepository::new(services.database.pool().clone());
|
|
|
|
let row = IdmmInterventionRow {
|
|
id: "idmmrec_cascade_test".into(),
|
|
target_kind: "conversation".into(),
|
|
target_id: conv.clone(),
|
|
watch: "decision".into(),
|
|
at: 1,
|
|
signal: "decision".into(),
|
|
tier_used: "rule".into(),
|
|
category: Some("option".into()),
|
|
action: "answer_choice".into(),
|
|
detail: Some("option 2".into()),
|
|
reason: Some("rule-tier auto-pick".into()),
|
|
confidence: None,
|
|
bypass_model: None,
|
|
outcome: "applied".into(),
|
|
};
|
|
records.insert(&row).await.unwrap();
|
|
|
|
let before = records.list_for_target("conversation", &conv, 30).await.unwrap();
|
|
assert_eq!(before.len(), 1, "record must exist before delete");
|
|
|
|
// Delete the conversation — the cascade hook runs inside the delete path.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(delete_with_token(&format!("/api/conversations/{conv}"), &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK, "conversation delete should succeed");
|
|
|
|
let after = records.list_for_target("conversation", &conv, 30).await.unwrap();
|
|
assert!(
|
|
after.is_empty(),
|
|
"IDMM records must be cascade-cleared when the conversation is deleted; got {after:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cross_session_activity_feed_round_trips() {
|
|
// The cross-session activity feed reads every target's records most-recent-
|
|
// first, and the bulk clear empties the whole table. Insert records for two
|
|
// targets directly, then exercise GET + DELETE /api/idmm/activity.
|
|
use nomifun_db::models::IdmmInterventionRow;
|
|
use nomifun_db::{IIdmmInterventionRepository, SqliteIdmmInterventionRepository};
|
|
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let records: SqliteIdmmInterventionRepository =
|
|
SqliteIdmmInterventionRepository::new(services.database.pool().clone());
|
|
|
|
let make_row = |id: &str, target_kind: &str, target_id: &str, at: i64| IdmmInterventionRow {
|
|
id: id.into(),
|
|
target_kind: target_kind.into(),
|
|
target_id: target_id.into(),
|
|
watch: "decision".into(),
|
|
at,
|
|
signal: "decision".into(),
|
|
tier_used: "rule".into(),
|
|
category: Some("option".into()),
|
|
action: "answer_choice".into(),
|
|
detail: Some("option 2".into()),
|
|
reason: Some("rule-tier auto-pick".into()),
|
|
confidence: None,
|
|
bypass_model: None,
|
|
outcome: "applied".into(),
|
|
};
|
|
|
|
// Two distinct targets, at interleaved → most-recent-first must mix them.
|
|
records.insert(&make_row("idmmrec_act_a", "conversation", "c1", 10)).await.unwrap();
|
|
records.insert(&make_row("idmmrec_act_b", "terminal", "1", 30)).await.unwrap();
|
|
records.insert(&make_row("idmmrec_act_c", "conversation", "c2", 20)).await.unwrap();
|
|
|
|
// GET the feed → most-recent-first across ALL targets (30 -> 20 -> 10).
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/activity", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
let items = j["data"].as_array().expect("activity feed is an array");
|
|
let ids: Vec<&str> = items.iter().map(|r| r["id"].as_str().unwrap()).collect();
|
|
assert_eq!(ids, vec!["idmmrec_act_b", "idmmrec_act_c", "idmmrec_act_a"]);
|
|
// Spans both targets.
|
|
assert_eq!(items[0]["target_kind"], "terminal");
|
|
assert_eq!(items[1]["target_kind"], "conversation");
|
|
|
|
// DELETE the feed → clears every target's records.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(delete_with_token("/api/idmm/activity", &token, &csrf))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert_eq!(j["data"], 3, "bulk clear must report the removed count");
|
|
|
|
// The feed is now empty.
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_token("/api/idmm/activity", &token))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let j = body_json(resp).await;
|
|
assert!(
|
|
j["data"].as_array().unwrap().is_empty(),
|
|
"activity feed must be empty after bulk clear; got {j:?}"
|
|
);
|
|
}
|