//! Integration tests for SqliteProblemRepository. //! //! The interesting surface is ingest. Problems are a mirror, so every adapter //! re-pull runs the same rows through `ingest` again, and the contract is that //! source-owned fields refresh while triage decisions survive. Most of what //! follows pins that boundary. mod common; use chrono::{Duration, Utc}; use goingson_core::{ NewProblem, ProblemFilter, ProblemId, ProblemRepository, ProblemStatus, UserId, }; use goingson_db_sqlite::SqliteProblemRepository; fn new_problem(source_ref: &str) -> NewProblem { let now = Utc::now(); NewProblem { source: "wam".to_string(), source_ref: source_ref.to_string(), title: "sync drops attachments".to_string(), body: "seen on two devices".to_string(), pain: 3, scale: 3, project_id: None, tags: vec!["sync".to_string()], created_at: now, updated_at: now, resolved_upstream: false, } } #[tokio::test] async fn ingest_creates_then_finds_by_provenance() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); let created = repo .ingest(user_id, new_problem("t-1")) .await .expect("ingest failed"); assert_eq!(created.title, "sync drops attachments"); assert_eq!(created.status, ProblemStatus::Open); assert_eq!(created.tags, vec!["sync".to_string()]); assert!(created.promoted_task_id.is_none()); assert!(created.settled_at.is_none()); let found = repo .find_by_source_ref(user_id, "wam", "t-1") .await .expect("lookup failed") .expect("problem missing"); assert_eq!(found.id, created.id); } #[tokio::test] async fn re_ingest_updates_in_place_instead_of_duplicating() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); let first = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); let mut changed = new_problem("t-1"); changed.title = "sync drops attachments on mobile".to_string(); changed.pain = 5; changed.scale = 4; let second = repo.ingest(user_id, changed).await.unwrap(); // Same row, refreshed with what the source now says. assert_eq!(second.id, first.id); assert_eq!(second.title, "sync drops attachments on mobile"); assert_eq!(second.pain, 5); assert_eq!(second.scale, 4); let all = repo.list(user_id, &ProblemFilter::default()).await.unwrap(); assert_eq!(all.len(), 1, "upsert must not duplicate on provenance key"); } #[tokio::test] async fn re_ingest_does_not_undo_triage() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let task_id = common::create_test_task(&pool, user_id).await; let repo = SqliteProblemRepository::new(pool); let promoted_ref = "t-promoted"; let dismissed_ref = "t-dismissed"; let p = repo .ingest(user_id, new_problem(promoted_ref)) .await .unwrap(); let d = repo .ingest(user_id, new_problem(dismissed_ref)) .await .unwrap(); repo.promote(p.id, user_id, task_id).await.unwrap(); repo.set_status(d.id, user_id, ProblemStatus::Dismissed) .await .unwrap(); // The source re-reports both, and now calls them fixed. A human already // ruled on each, so neither ruling is overwritten. for source_ref in [promoted_ref, dismissed_ref] { let mut again = new_problem(source_ref); again.resolved_upstream = true; repo.ingest(user_id, again).await.unwrap(); } let after_promote = repo .find_by_source_ref(user_id, "wam", promoted_ref) .await .unwrap() .unwrap(); assert_eq!(after_promote.status, ProblemStatus::Promoted); assert_eq!(after_promote.promoted_task_id, Some(task_id)); assert!(after_promote.settled_at.is_some()); let after_dismiss = repo .find_by_source_ref(user_id, "wam", dismissed_ref) .await .unwrap() .unwrap(); assert_eq!(after_dismiss.status, ProblemStatus::Dismissed); } #[tokio::test] async fn upstream_resolution_settles_an_untriaged_problem() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); repo.ingest(user_id, new_problem("t-1")).await.unwrap(); let mut fixed = new_problem("t-1"); fixed.resolved_upstream = true; let settled = repo.ingest(user_id, fixed).await.unwrap(); assert_eq!(settled.status, ProblemStatus::Resolved); assert!(settled.settled_at.is_some()); } #[tokio::test] async fn promote_links_the_task_and_freezes_the_score() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let task_id = common::create_test_task(&pool, user_id).await; let repo = SqliteProblemRepository::new(pool); // Backdate it so there is a score to freeze. let mut old = new_problem("t-1"); old.created_at = Utc::now() - Duration::days(120); let problem = repo.ingest(user_id, old).await.unwrap(); let open_score = problem.painhours(); let promoted = repo .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)); // Settling anchors age at the moment of promotion, so the score is the same // right now and stops growing from here. It does not drop: the problem was // genuinely that urgent when it got promoted. assert_eq!(promoted.painhours(), open_score); let frozen_weeks = promoted.age_weeks(); assert!(promoted.settled_at.is_some()); // Re-reading later must give the same age, which is what "frozen" means. An // open problem recomputes against the clock; this one recomputes against // settled_at. let reread = repo.get_by_id(promoted.id, user_id).await.unwrap().unwrap(); assert_eq!(reread.age_weeks(), frozen_weeks); } #[tokio::test] async fn reopening_clears_the_backlink_and_resumes_the_climb() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let task_id = common::create_test_task(&pool, user_id).await; let repo = SqliteProblemRepository::new(pool); let mut old = new_problem("t-1"); old.created_at = Utc::now() - Duration::days(120); let problem = repo.ingest(user_id, old).await.unwrap(); let open_score = problem.painhours(); repo.promote(problem.id, user_id, task_id).await.unwrap(); let reopened = repo .set_status(problem.id, user_id, ProblemStatus::Open) .await .unwrap() .unwrap(); assert_eq!(reopened.status, ProblemStatus::Open); assert!(reopened.promoted_task_id.is_none()); assert!(reopened.settled_at.is_none()); assert_eq!(reopened.painhours(), open_score); } #[tokio::test] async fn list_ranks_by_painhours_not_insertion_order() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); // Inserted mildest first, so insertion order is the reverse of the ranking. let mut mild = new_problem("mild"); mild.pain = 1; mild.scale = 1; repo.ingest(user_id, mild).await.unwrap(); let mut widespread = new_problem("widespread"); widespread.pain = 1; widespread.scale = 5; repo.ingest(user_id, widespread).await.unwrap(); let listed = repo.list(user_id, &ProblemFilter::default()).await.unwrap(); assert_eq!(listed.len(), 2); assert_eq!(listed[0].source_ref, "widespread"); assert!(listed[0].painhours() > listed[1].painhours()); } #[tokio::test] async fn list_filters_compose() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); repo.ingest(user_id, new_problem("wam-1")).await.unwrap(); let mut from_audit = new_problem("audit-1"); from_audit.source = "audit".to_string(); let audit = repo.ingest(user_id, from_audit).await.unwrap(); repo.set_status(audit.id, user_id, ProblemStatus::Dismissed) .await .unwrap(); let open = repo .list( user_id, &ProblemFilter { status: Some(ProblemStatus::Open), ..Default::default() }, ) .await .unwrap(); assert_eq!(open.len(), 1); assert_eq!(open[0].source, "wam"); let from_audit = repo .list( user_id, &ProblemFilter { source: Some("audit".to_string()), ..Default::default() }, ) .await .unwrap(); assert_eq!(from_audit.len(), 1); // Both filters at once, contradicting each other, must return nothing. let none = repo .list( user_id, &ProblemFilter { source: Some("audit".to_string()), status: Some(ProblemStatus::Open), ..Default::default() }, ) .await .unwrap(); assert!(none.is_empty()); } #[tokio::test] async fn problems_are_scoped_to_their_user() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); let mine = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); let stranger = UserId::new(); assert!(repo.get_by_id(mine.id, stranger).await.unwrap().is_none()); assert!( repo.find_by_source_ref(stranger, "wam", "t-1") .await .unwrap() .is_none() ); assert!( repo.list(stranger, &ProblemFilter::default()) .await .unwrap() .is_empty() ); assert!(!repo.delete(mine.id, stranger).await.unwrap()); } #[tokio::test] async fn set_project_attributes_and_clears() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); assert!(problem.project_id.is_none()); // A project that does not exist would trip the FK, so this asserts the // clear path and leaves attribution to the adapter that knows real ids. let cleared = repo .set_project(problem.id, user_id, None) .await .unwrap() .unwrap(); assert!(cleared.project_id.is_none()); let missing = repo .set_project(ProblemId::new(), user_id, None) .await .unwrap(); assert!(missing.is_none(), "unknown id must return None, not error"); } #[tokio::test] async fn last_pulled_at_tracks_the_newest_sighting() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); assert!( repo.last_pulled_at(user_id, "wam").await.unwrap().is_none(), "a never-pulled source has no timestamp" ); let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); let pulled = repo .last_pulled_at(user_id, "wam") .await .unwrap() .expect("expected a pull timestamp"); assert_eq!(pulled, problem.last_seen_at); assert!(!problem.is_stale(pulled)); } #[tokio::test] async fn delete_removes_the_row() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let repo = SqliteProblemRepository::new(pool); let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap(); assert!(repo.delete(problem.id, user_id).await.unwrap()); assert!(repo.get_by_id(problem.id, user_id).await.unwrap().is_none()); assert!(!repo.delete(problem.id, user_id).await.unwrap()); }