//! Integration tests for the Problems inbox commands. //! //! The command layer's own job is thin — filtering, project-name resolution, //! and the promote sequence — so that is what these cover. The upsert and //! ageing semantics are pinned in `goingson-db-sqlite`'s `problem_repo_tests`. use chrono::{Duration, Utc}; use goingson_core::{NewProblem, ProblemStatus, TaskStatus}; use crate::test_utils::{create_test_project, setup_test_state}; fn new_problem(source_ref: &str, pain: u8, scale: u8) -> NewProblem { let now = Utc::now(); NewProblem { source: "audit".to_string(), source_ref: source_ref.to_string(), title: format!("finding {source_ref}"), body: "db-sqlite/task_repo.rs:120".to_string(), pain, scale, project_id: None, tags: vec!["testing".to_string()], created_at: now, updated_at: now, resolved_upstream: false, } } #[tokio::test] async fn list_defaults_to_open_and_ranks_by_painhours() { let (state, user_id) = setup_test_state().await; state .problems .ingest(user_id, new_problem("narrow", 1, 1)) .await .unwrap(); state .problems .ingest(user_id, new_problem("widespread", 1, 5)) .await .unwrap(); let dismissed = state .problems .ingest(user_id, new_problem("ignored", 3, 3)) .await .unwrap(); state .problems .set_status(dismissed.id, user_id, ProblemStatus::Dismissed) .await .unwrap(); let open = state .problems .list( user_id, &goingson_core::ProblemFilter { status: Some(ProblemStatus::Open), ..Default::default() }, ) .await .unwrap(); assert_eq!(open.len(), 2, "the dismissed problem is out of the inbox"); assert_eq!(open[0].source_ref, "widespread", "scale drives the ranking"); assert!(open[0].painhours() > open[1].painhours()); } #[tokio::test] async fn promoting_creates_a_linked_task_carrying_project_and_tags() { let (state, user_id) = setup_test_state().await; let project_id = create_test_project(&state, user_id).await; let mut input = new_problem("blob-memory", 5, 4); input.project_id = Some(project_id); // Backdate it so the freeze has something to freeze. input.created_at = Utc::now() - Duration::days(60); let problem = state.problems.ingest(user_id, input).await.unwrap(); let open_score = problem.painhours(); let task = { let mut tags = problem.tags.clone(); tags.push(format!("problem:{}:{}", problem.source, problem.source_ref)); let builder = goingson_core::NewTask::builder(problem.title.clone()) .priority(goingson_core::Priority::High) .tags(tags) .project_id(project_id); state.tasks.create(user_id, builder.build()).await.unwrap() }; let promoted = state .problems .promote(problem.id, user_id, task.id) .await .unwrap() .expect("promote returned None"); assert_eq!(promoted.status, ProblemStatus::Promoted); assert_eq!(promoted.promoted_task_id, Some(task.id)); assert_eq!(promoted.project_id, Some(project_id)); // Settling anchors age at promotion: the score holds rather than dropping, // and stops climbing from here. assert_eq!(promoted.painhours(), open_score); assert!(promoted.settled_at.is_some()); let task = state .tasks .get_by_id(task.id, user_id) .await .unwrap() .expect("task missing"); assert_eq!(task.status, TaskStatus::Pending); assert!( task.tags.contains(&"problem:audit:blob-memory".to_string()), "the task must carry its provenance back to the problem" ); assert!(task.tags.contains(&"testing".to_string())); } #[tokio::test] async fn dismissing_survives_a_re_report() { let (state, user_id) = setup_test_state().await; let problem = state .problems .ingest(user_id, new_problem("style-nit", 2, 2)) .await .unwrap(); state .problems .set_status(problem.id, user_id, ProblemStatus::Dismissed) .await .unwrap(); // The next audit run reports it again, and now calls it fixed. let mut again = new_problem("style-nit", 2, 2); again.resolved_upstream = true; state.problems.ingest(user_id, again).await.unwrap(); let after = state .problems .find_by_source_ref(user_id, "audit", "style-nit") .await .unwrap() .expect("problem missing"); assert_eq!( after.status, ProblemStatus::Dismissed, "a human ruling outranks what the source says" ); } #[tokio::test] async fn project_attribution_resolves_to_a_name() { let (state, user_id) = setup_test_state().await; let project_id = create_test_project(&state, user_id).await; let problem = state .problems .ingest(user_id, new_problem("unattributed", 3, 3)) .await .unwrap(); assert!(problem.project_id.is_none()); let attributed = state .problems .set_project(problem.id, user_id, Some(project_id)) .await .unwrap() .expect("problem missing"); assert_eq!(attributed.project_id, Some(project_id)); // The command layer resolves the name from this id; confirm the project it // points at is really there, which is what that lookup depends on. let project = state .projects .get_by_id(project_id, user_id) .await .unwrap() .expect("project missing"); assert_eq!(attributed.project_id, Some(project.id)); let cleared = state .problems .set_project(problem.id, user_id, None) .await .unwrap() .expect("problem missing"); assert!(cleared.project_id.is_none()); } /// Serve one canned HTTP response on an ephemeral port and return its base URL. /// /// A real socket rather than a mocked client: the point is to exercise the /// actual reqwest call, wam's real JSON shape, and the deserialize, which a /// hand-built `Ticket` would skip. async fn stub_wam(body: &'static str) -> String { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { if let Ok((mut socket, _)) = listener.accept().await { let mut buf = [0u8; 2048]; let _ = socket.read(&mut buf).await; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body, ); let _ = socket.write_all(response.as_bytes()).await; let _ = socket.shutdown().await; } }); format!("http://{addr}") } #[tokio::test] async fn pulling_wam_mirrors_tickets_without_creating_tasks() { use crate::problems::{ProblemSource, WamSource}; let (state, user_id) = setup_test_state().await; let url = stub_wam( r#"{"data":[ {"id":"t-1","title":"disk filling","body":"astra /var at 94%", "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1", "source":"pom","source_ref":"alert-9", "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z", "resolved_at":null}, {"id":"t-2","title":"already handled","body":null, "pain":2,"scale":2,"status":"closed","channel":"task","node_id":"n1", "source":null,"source_ref":null, "created_at":"2026-05-01T12:00:00Z","updated_at":"2026-06-10T12:00:00Z", "resolved_at":"2026-06-10T12:00:00Z"} ],"count":2}"#, ) .await; crate::problems::init_crypto_for_tests(); let pulled = WamSource::new(url, None).pull().await.expect("pull failed"); assert_eq!(pulled.len(), 2); for problem in pulled { state.problems.ingest(user_id, problem).await.unwrap(); } // The closed ticket settles on arrival, so only the live one is in the inbox. let open = state .problems .list( user_id, &goingson_core::ProblemFilter { status: Some(ProblemStatus::Open), ..Default::default() }, ) .await .unwrap(); assert_eq!(open.len(), 1); assert_eq!(open[0].source_ref, "t-1"); assert_eq!(open[0].source, "wam"); assert!(open[0].tags.contains(&"channel:system".to_string())); let closed = state .problems .find_by_source_ref(user_id, "wam", "t-2") .await .unwrap() .expect("closed ticket should still be mirrored"); assert_eq!(closed.status, ProblemStatus::Resolved); // wam's own provenance (it got t-1 from pom) must not become ours. assert_eq!(open[0].source, "wam"); // A month-old ticket arrives already a month old rather than restarting its // climb, which is what makes the ranking honest across the bridge. assert!(open[0].age_weeks() > 1); // And pulling created no work. let tasks = state.tasks.list_all(user_id).await.unwrap(); assert!(tasks.is_empty(), "a pull must never create tasks"); } #[tokio::test] async fn re_pulling_updates_in_place_and_keeps_triage() { use crate::problems::{ProblemSource, WamSource}; let (state, user_id) = setup_test_state().await; const TICKET: &str = r#"{"data":[ {"id":"t-1","title":"disk filling","body":"astra /var at 94%", "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1", "source":null,"source_ref":null, "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z", "resolved_at":null} ],"count":1}"#; crate::problems::init_crypto_for_tests(); for _ in 0..2 { let url = stub_wam(TICKET).await; for problem in WamSource::new(url, None).pull().await.unwrap() { state.problems.ingest(user_id, problem).await.unwrap(); } } let all = state .problems .list(user_id, &goingson_core::ProblemFilter::default()) .await .unwrap(); assert_eq!(all.len(), 1, "the ticket id is the upsert key"); // Dismiss it, then pull again: wam still reports it, but the ruling holds. state .problems .set_status(all[0].id, user_id, ProblemStatus::Dismissed) .await .unwrap(); let url = stub_wam(TICKET).await; for problem in WamSource::new(url, None).pull().await.unwrap() { state.problems.ingest(user_id, problem).await.unwrap(); } let after = state .problems .find_by_source_ref(user_id, "wam", "t-1") .await .unwrap() .unwrap(); assert_eq!(after.status, ProblemStatus::Dismissed); }