Skip to main content

max / goingson

10.8 KB · 340 lines History Blame Raw
1 //! Integration tests for the Problems inbox commands.
2 //!
3 //! The command layer's own job is thin — filtering, project-name resolution,
4 //! and the promote sequence — so that is what these cover. The upsert and
5 //! ageing semantics are pinned in `goingson-db-sqlite`'s `problem_repo_tests`.
6
7 use chrono::{Duration, Utc};
8 use goingson_core::{NewProblem, ProblemStatus, TaskStatus};
9
10 use crate::test_utils::{create_test_project, setup_test_state};
11
12 fn new_problem(source_ref: &str, pain: u8, scale: u8) -> NewProblem {
13 let now = Utc::now();
14 NewProblem {
15 source: "audit".to_string(),
16 source_ref: source_ref.to_string(),
17 title: format!("finding {source_ref}"),
18 body: "db-sqlite/task_repo.rs:120".to_string(),
19 pain,
20 scale,
21 project_id: None,
22 tags: vec!["testing".to_string()],
23 created_at: now,
24 updated_at: now,
25 resolved_upstream: false,
26 }
27 }
28
29 #[tokio::test]
30 async fn list_defaults_to_open_and_ranks_by_painhours() {
31 let (state, user_id) = setup_test_state().await;
32
33 state
34 .problems
35 .ingest(user_id, new_problem("narrow", 1, 1))
36 .await
37 .unwrap();
38 state
39 .problems
40 .ingest(user_id, new_problem("widespread", 1, 5))
41 .await
42 .unwrap();
43 let dismissed = state
44 .problems
45 .ingest(user_id, new_problem("ignored", 3, 3))
46 .await
47 .unwrap();
48 state
49 .problems
50 .set_status(dismissed.id, user_id, ProblemStatus::Dismissed)
51 .await
52 .unwrap();
53
54 let open = state
55 .problems
56 .list(
57 user_id,
58 &goingson_core::ProblemFilter {
59 status: Some(ProblemStatus::Open),
60 ..Default::default()
61 },
62 )
63 .await
64 .unwrap();
65
66 assert_eq!(open.len(), 2, "the dismissed problem is out of the inbox");
67 assert_eq!(open[0].source_ref, "widespread", "scale drives the ranking");
68 assert!(open[0].painhours() > open[1].painhours());
69 }
70
71 #[tokio::test]
72 async fn promoting_creates_a_linked_task_carrying_project_and_tags() {
73 let (state, user_id) = setup_test_state().await;
74 let project_id = create_test_project(&state, user_id).await;
75
76 let mut input = new_problem("blob-memory", 5, 4);
77 input.project_id = Some(project_id);
78 // Backdate it so the freeze has something to freeze.
79 input.created_at = Utc::now() - Duration::days(60);
80 let problem = state.problems.ingest(user_id, input).await.unwrap();
81 let open_score = problem.painhours();
82
83 let task = {
84 let mut tags = problem.tags.clone();
85 tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
86 let builder = goingson_core::NewTask::builder(problem.title.clone())
87 .priority(goingson_core::Priority::High)
88 .tags(tags)
89 .project_id(project_id);
90 state.tasks.create(user_id, builder.build()).await.unwrap()
91 };
92
93 let promoted = state
94 .problems
95 .promote(problem.id, user_id, task.id)
96 .await
97 .unwrap()
98 .expect("promote returned None");
99
100 assert_eq!(promoted.status, ProblemStatus::Promoted);
101 assert_eq!(promoted.promoted_task_id, Some(task.id));
102 assert_eq!(promoted.project_id, Some(project_id));
103
104 // Settling anchors age at promotion: the score holds rather than dropping,
105 // and stops climbing from here.
106 assert_eq!(promoted.painhours(), open_score);
107 assert!(promoted.settled_at.is_some());
108
109 let task = state
110 .tasks
111 .get_by_id(task.id, user_id)
112 .await
113 .unwrap()
114 .expect("task missing");
115 assert_eq!(task.status, TaskStatus::Pending);
116 assert!(
117 task.tags.contains(&"problem:audit:blob-memory".to_string()),
118 "the task must carry its provenance back to the problem"
119 );
120 assert!(task.tags.contains(&"testing".to_string()));
121 }
122
123 #[tokio::test]
124 async fn dismissing_survives_a_re_report() {
125 let (state, user_id) = setup_test_state().await;
126
127 let problem = state
128 .problems
129 .ingest(user_id, new_problem("style-nit", 2, 2))
130 .await
131 .unwrap();
132 state
133 .problems
134 .set_status(problem.id, user_id, ProblemStatus::Dismissed)
135 .await
136 .unwrap();
137
138 // The next audit run reports it again, and now calls it fixed.
139 let mut again = new_problem("style-nit", 2, 2);
140 again.resolved_upstream = true;
141 state.problems.ingest(user_id, again).await.unwrap();
142
143 let after = state
144 .problems
145 .find_by_source_ref(user_id, "audit", "style-nit")
146 .await
147 .unwrap()
148 .expect("problem missing");
149 assert_eq!(
150 after.status,
151 ProblemStatus::Dismissed,
152 "a human ruling outranks what the source says"
153 );
154 }
155
156 #[tokio::test]
157 async fn project_attribution_resolves_to_a_name() {
158 let (state, user_id) = setup_test_state().await;
159 let project_id = create_test_project(&state, user_id).await;
160
161 let problem = state
162 .problems
163 .ingest(user_id, new_problem("unattributed", 3, 3))
164 .await
165 .unwrap();
166 assert!(problem.project_id.is_none());
167
168 let attributed = state
169 .problems
170 .set_project(problem.id, user_id, Some(project_id))
171 .await
172 .unwrap()
173 .expect("problem missing");
174 assert_eq!(attributed.project_id, Some(project_id));
175
176 // The command layer resolves the name from this id; confirm the project it
177 // points at is really there, which is what that lookup depends on.
178 let project = state
179 .projects
180 .get_by_id(project_id, user_id)
181 .await
182 .unwrap()
183 .expect("project missing");
184 assert_eq!(attributed.project_id, Some(project.id));
185
186 let cleared = state
187 .problems
188 .set_project(problem.id, user_id, None)
189 .await
190 .unwrap()
191 .expect("problem missing");
192 assert!(cleared.project_id.is_none());
193 }
194
195 /// Serve one canned HTTP response on an ephemeral port and return its base URL.
196 ///
197 /// A real socket rather than a mocked client: the point is to exercise the
198 /// actual reqwest call, wam's real JSON shape, and the deserialize, which a
199 /// hand-built `Ticket` would skip.
200 async fn stub_wam(body: &'static str) -> String {
201 use tokio::io::{AsyncReadExt, AsyncWriteExt};
202
203 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
204 let addr = listener.local_addr().unwrap();
205
206 tokio::spawn(async move {
207 if let Ok((mut socket, _)) = listener.accept().await {
208 let mut buf = [0u8; 2048];
209 let _ = socket.read(&mut buf).await;
210 let response = format!(
211 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
212 body.len(),
213 body,
214 );
215 let _ = socket.write_all(response.as_bytes()).await;
216 let _ = socket.shutdown().await;
217 }
218 });
219
220 format!("http://{addr}")
221 }
222
223 #[tokio::test]
224 async fn pulling_wam_mirrors_tickets_without_creating_tasks() {
225 use crate::problems::{ProblemSource, WamSource};
226
227 let (state, user_id) = setup_test_state().await;
228
229 let url = stub_wam(
230 r#"{"data":[
231 {"id":"t-1","title":"disk filling","body":"astra /var at 94%",
232 "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1",
233 "source":"pom","source_ref":"alert-9",
234 "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z",
235 "resolved_at":null},
236 {"id":"t-2","title":"already handled","body":null,
237 "pain":2,"scale":2,"status":"closed","channel":"task","node_id":"n1",
238 "source":null,"source_ref":null,
239 "created_at":"2026-05-01T12:00:00Z","updated_at":"2026-06-10T12:00:00Z",
240 "resolved_at":"2026-06-10T12:00:00Z"}
241 ],"count":2}"#,
242 )
243 .await;
244
245 crate::problems::init_crypto_for_tests();
246 let pulled = WamSource::new(url, None).pull().await.expect("pull failed");
247 assert_eq!(pulled.len(), 2);
248
249 for problem in pulled {
250 state.problems.ingest(user_id, problem).await.unwrap();
251 }
252
253 // The closed ticket settles on arrival, so only the live one is in the inbox.
254 let open = state
255 .problems
256 .list(
257 user_id,
258 &goingson_core::ProblemFilter {
259 status: Some(ProblemStatus::Open),
260 ..Default::default()
261 },
262 )
263 .await
264 .unwrap();
265 assert_eq!(open.len(), 1);
266 assert_eq!(open[0].source_ref, "t-1");
267 assert_eq!(open[0].source, "wam");
268 assert!(open[0].tags.contains(&"channel:system".to_string()));
269
270 let closed = state
271 .problems
272 .find_by_source_ref(user_id, "wam", "t-2")
273 .await
274 .unwrap()
275 .expect("closed ticket should still be mirrored");
276 assert_eq!(closed.status, ProblemStatus::Resolved);
277
278 // wam's own provenance (it got t-1 from pom) must not become ours.
279 assert_eq!(open[0].source, "wam");
280
281 // A month-old ticket arrives already a month old rather than restarting its
282 // climb, which is what makes the ranking honest across the bridge.
283 assert!(open[0].age_weeks() > 1);
284
285 // And pulling created no work.
286 let tasks = state.tasks.list_all(user_id).await.unwrap();
287 assert!(tasks.is_empty(), "a pull must never create tasks");
288 }
289
290 #[tokio::test]
291 async fn re_pulling_updates_in_place_and_keeps_triage() {
292 use crate::problems::{ProblemSource, WamSource};
293
294 let (state, user_id) = setup_test_state().await;
295
296 const TICKET: &str = r#"{"data":[
297 {"id":"t-1","title":"disk filling","body":"astra /var at 94%",
298 "pain":5,"scale":4,"status":"open","channel":"system","node_id":"n1",
299 "source":null,"source_ref":null,
300 "created_at":"2026-06-01T12:00:00Z","updated_at":"2026-06-02T12:00:00Z",
301 "resolved_at":null}
302 ],"count":1}"#;
303
304 crate::problems::init_crypto_for_tests();
305
306 for _ in 0..2 {
307 let url = stub_wam(TICKET).await;
308 for problem in WamSource::new(url, None).pull().await.unwrap() {
309 state.problems.ingest(user_id, problem).await.unwrap();
310 }
311 }
312
313 let all = state
314 .problems
315 .list(user_id, &goingson_core::ProblemFilter::default())
316 .await
317 .unwrap();
318 assert_eq!(all.len(), 1, "the ticket id is the upsert key");
319
320 // Dismiss it, then pull again: wam still reports it, but the ruling holds.
321 state
322 .problems
323 .set_status(all[0].id, user_id, ProblemStatus::Dismissed)
324 .await
325 .unwrap();
326
327 let url = stub_wam(TICKET).await;
328 for problem in WamSource::new(url, None).pull().await.unwrap() {
329 state.problems.ingest(user_id, problem).await.unwrap();
330 }
331
332 let after = state
333 .problems
334 .find_by_source_ref(user_id, "wam", "t-1")
335 .await
336 .unwrap()
337 .unwrap();
338 assert_eq!(after.status, ProblemStatus::Dismissed);
339 }
340