Skip to main content

max / goingson

11.9 KB · 365 lines History Blame Raw
1 //! Integration tests for SqliteProblemRepository.
2 //!
3 //! The interesting surface is ingest. Problems are a mirror, so every adapter
4 //! re-pull runs the same rows through `ingest` again, and the contract is that
5 //! source-owned fields refresh while triage decisions survive. Most of what
6 //! follows pins that boundary.
7
8 mod common;
9
10 use chrono::{Duration, Utc};
11 use goingson_core::{
12 NewProblem, ProblemFilter, ProblemId, ProblemRepository, ProblemStatus, UserId,
13 };
14 use goingson_db_sqlite::SqliteProblemRepository;
15
16 fn new_problem(source_ref: &str) -> NewProblem {
17 let now = Utc::now();
18 NewProblem {
19 source: "wam".to_string(),
20 source_ref: source_ref.to_string(),
21 title: "sync drops attachments".to_string(),
22 body: "seen on two devices".to_string(),
23 pain: 3,
24 scale: 3,
25 project_id: None,
26 tags: vec!["sync".to_string()],
27 created_at: now,
28 updated_at: now,
29 resolved_upstream: false,
30 }
31 }
32
33 #[tokio::test]
34 async fn ingest_creates_then_finds_by_provenance() {
35 let pool = common::setup_test_db().await;
36 let user_id = common::create_test_user(&pool).await;
37 let repo = SqliteProblemRepository::new(pool);
38
39 let created = repo
40 .ingest(user_id, new_problem("t-1"))
41 .await
42 .expect("ingest failed");
43 assert_eq!(created.title, "sync drops attachments");
44 assert_eq!(created.status, ProblemStatus::Open);
45 assert_eq!(created.tags, vec!["sync".to_string()]);
46 assert!(created.promoted_task_id.is_none());
47 assert!(created.settled_at.is_none());
48
49 let found = repo
50 .find_by_source_ref(user_id, "wam", "t-1")
51 .await
52 .expect("lookup failed")
53 .expect("problem missing");
54 assert_eq!(found.id, created.id);
55 }
56
57 #[tokio::test]
58 async fn re_ingest_updates_in_place_instead_of_duplicating() {
59 let pool = common::setup_test_db().await;
60 let user_id = common::create_test_user(&pool).await;
61 let repo = SqliteProblemRepository::new(pool);
62
63 let first = repo.ingest(user_id, new_problem("t-1")).await.unwrap();
64
65 let mut changed = new_problem("t-1");
66 changed.title = "sync drops attachments on mobile".to_string();
67 changed.pain = 5;
68 changed.scale = 4;
69 let second = repo.ingest(user_id, changed).await.unwrap();
70
71 // Same row, refreshed with what the source now says.
72 assert_eq!(second.id, first.id);
73 assert_eq!(second.title, "sync drops attachments on mobile");
74 assert_eq!(second.pain, 5);
75 assert_eq!(second.scale, 4);
76
77 let all = repo.list(user_id, &ProblemFilter::default()).await.unwrap();
78 assert_eq!(all.len(), 1, "upsert must not duplicate on provenance key");
79 }
80
81 #[tokio::test]
82 async fn re_ingest_does_not_undo_triage() {
83 let pool = common::setup_test_db().await;
84 let user_id = common::create_test_user(&pool).await;
85 let task_id = common::create_test_task(&pool, user_id).await;
86 let repo = SqliteProblemRepository::new(pool);
87
88 let promoted_ref = "t-promoted";
89 let dismissed_ref = "t-dismissed";
90 let p = repo
91 .ingest(user_id, new_problem(promoted_ref))
92 .await
93 .unwrap();
94 let d = repo
95 .ingest(user_id, new_problem(dismissed_ref))
96 .await
97 .unwrap();
98 repo.promote(p.id, user_id, task_id).await.unwrap();
99 repo.set_status(d.id, user_id, ProblemStatus::Dismissed)
100 .await
101 .unwrap();
102
103 // The source re-reports both, and now calls them fixed. A human already
104 // ruled on each, so neither ruling is overwritten.
105 for source_ref in [promoted_ref, dismissed_ref] {
106 let mut again = new_problem(source_ref);
107 again.resolved_upstream = true;
108 repo.ingest(user_id, again).await.unwrap();
109 }
110
111 let after_promote = repo
112 .find_by_source_ref(user_id, "wam", promoted_ref)
113 .await
114 .unwrap()
115 .unwrap();
116 assert_eq!(after_promote.status, ProblemStatus::Promoted);
117 assert_eq!(after_promote.promoted_task_id, Some(task_id));
118 assert!(after_promote.settled_at.is_some());
119
120 let after_dismiss = repo
121 .find_by_source_ref(user_id, "wam", dismissed_ref)
122 .await
123 .unwrap()
124 .unwrap();
125 assert_eq!(after_dismiss.status, ProblemStatus::Dismissed);
126 }
127
128 #[tokio::test]
129 async fn upstream_resolution_settles_an_untriaged_problem() {
130 let pool = common::setup_test_db().await;
131 let user_id = common::create_test_user(&pool).await;
132 let repo = SqliteProblemRepository::new(pool);
133
134 repo.ingest(user_id, new_problem("t-1")).await.unwrap();
135
136 let mut fixed = new_problem("t-1");
137 fixed.resolved_upstream = true;
138 let settled = repo.ingest(user_id, fixed).await.unwrap();
139
140 assert_eq!(settled.status, ProblemStatus::Resolved);
141 assert!(settled.settled_at.is_some());
142 }
143
144 #[tokio::test]
145 async fn promote_links_the_task_and_freezes_the_score() {
146 let pool = common::setup_test_db().await;
147 let user_id = common::create_test_user(&pool).await;
148 let task_id = common::create_test_task(&pool, user_id).await;
149 let repo = SqliteProblemRepository::new(pool);
150
151 // Backdate it so there is a score to freeze.
152 let mut old = new_problem("t-1");
153 old.created_at = Utc::now() - Duration::days(120);
154 let problem = repo.ingest(user_id, old).await.unwrap();
155 let open_score = problem.painhours();
156
157 let promoted = repo
158 .promote(problem.id, user_id, task_id)
159 .await
160 .unwrap()
161 .expect("promote returned None");
162
163 assert_eq!(promoted.status, ProblemStatus::Promoted);
164 assert_eq!(promoted.promoted_task_id, Some(task_id));
165
166 // Settling anchors age at the moment of promotion, so the score is the same
167 // right now and stops growing from here. It does not drop: the problem was
168 // genuinely that urgent when it got promoted.
169 assert_eq!(promoted.painhours(), open_score);
170 let frozen_weeks = promoted.age_weeks();
171 assert!(promoted.settled_at.is_some());
172
173 // Re-reading later must give the same age, which is what "frozen" means. An
174 // open problem recomputes against the clock; this one recomputes against
175 // settled_at.
176 let reread = repo.get_by_id(promoted.id, user_id).await.unwrap().unwrap();
177 assert_eq!(reread.age_weeks(), frozen_weeks);
178 }
179
180 #[tokio::test]
181 async fn reopening_clears_the_backlink_and_resumes_the_climb() {
182 let pool = common::setup_test_db().await;
183 let user_id = common::create_test_user(&pool).await;
184 let task_id = common::create_test_task(&pool, user_id).await;
185 let repo = SqliteProblemRepository::new(pool);
186
187 let mut old = new_problem("t-1");
188 old.created_at = Utc::now() - Duration::days(120);
189 let problem = repo.ingest(user_id, old).await.unwrap();
190 let open_score = problem.painhours();
191
192 repo.promote(problem.id, user_id, task_id).await.unwrap();
193 let reopened = repo
194 .set_status(problem.id, user_id, ProblemStatus::Open)
195 .await
196 .unwrap()
197 .unwrap();
198
199 assert_eq!(reopened.status, ProblemStatus::Open);
200 assert!(reopened.promoted_task_id.is_none());
201 assert!(reopened.settled_at.is_none());
202 assert_eq!(reopened.painhours(), open_score);
203 }
204
205 #[tokio::test]
206 async fn list_ranks_by_painhours_not_insertion_order() {
207 let pool = common::setup_test_db().await;
208 let user_id = common::create_test_user(&pool).await;
209 let repo = SqliteProblemRepository::new(pool);
210
211 // Inserted mildest first, so insertion order is the reverse of the ranking.
212 let mut mild = new_problem("mild");
213 mild.pain = 1;
214 mild.scale = 1;
215 repo.ingest(user_id, mild).await.unwrap();
216
217 let mut widespread = new_problem("widespread");
218 widespread.pain = 1;
219 widespread.scale = 5;
220 repo.ingest(user_id, widespread).await.unwrap();
221
222 let listed = repo.list(user_id, &ProblemFilter::default()).await.unwrap();
223 assert_eq!(listed.len(), 2);
224 assert_eq!(listed[0].source_ref, "widespread");
225 assert!(listed[0].painhours() > listed[1].painhours());
226 }
227
228 #[tokio::test]
229 async fn list_filters_compose() {
230 let pool = common::setup_test_db().await;
231 let user_id = common::create_test_user(&pool).await;
232 let repo = SqliteProblemRepository::new(pool);
233
234 repo.ingest(user_id, new_problem("wam-1")).await.unwrap();
235 let mut from_audit = new_problem("audit-1");
236 from_audit.source = "audit".to_string();
237 let audit = repo.ingest(user_id, from_audit).await.unwrap();
238 repo.set_status(audit.id, user_id, ProblemStatus::Dismissed)
239 .await
240 .unwrap();
241
242 let open = repo
243 .list(
244 user_id,
245 &ProblemFilter {
246 status: Some(ProblemStatus::Open),
247 ..Default::default()
248 },
249 )
250 .await
251 .unwrap();
252 assert_eq!(open.len(), 1);
253 assert_eq!(open[0].source, "wam");
254
255 let from_audit = repo
256 .list(
257 user_id,
258 &ProblemFilter {
259 source: Some("audit".to_string()),
260 ..Default::default()
261 },
262 )
263 .await
264 .unwrap();
265 assert_eq!(from_audit.len(), 1);
266
267 // Both filters at once, contradicting each other, must return nothing.
268 let none = repo
269 .list(
270 user_id,
271 &ProblemFilter {
272 source: Some("audit".to_string()),
273 status: Some(ProblemStatus::Open),
274 ..Default::default()
275 },
276 )
277 .await
278 .unwrap();
279 assert!(none.is_empty());
280 }
281
282 #[tokio::test]
283 async fn problems_are_scoped_to_their_user() {
284 let pool = common::setup_test_db().await;
285 let user_id = common::create_test_user(&pool).await;
286 let repo = SqliteProblemRepository::new(pool);
287
288 let mine = repo.ingest(user_id, new_problem("t-1")).await.unwrap();
289 let stranger = UserId::new();
290
291 assert!(repo.get_by_id(mine.id, stranger).await.unwrap().is_none());
292 assert!(
293 repo.find_by_source_ref(stranger, "wam", "t-1")
294 .await
295 .unwrap()
296 .is_none()
297 );
298 assert!(
299 repo.list(stranger, &ProblemFilter::default())
300 .await
301 .unwrap()
302 .is_empty()
303 );
304 assert!(!repo.delete(mine.id, stranger).await.unwrap());
305 }
306
307 #[tokio::test]
308 async fn set_project_attributes_and_clears() {
309 let pool = common::setup_test_db().await;
310 let user_id = common::create_test_user(&pool).await;
311 let repo = SqliteProblemRepository::new(pool);
312
313 let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap();
314 assert!(problem.project_id.is_none());
315
316 // A project that does not exist would trip the FK, so this asserts the
317 // clear path and leaves attribution to the adapter that knows real ids.
318 let cleared = repo
319 .set_project(problem.id, user_id, None)
320 .await
321 .unwrap()
322 .unwrap();
323 assert!(cleared.project_id.is_none());
324
325 let missing = repo
326 .set_project(ProblemId::new(), user_id, None)
327 .await
328 .unwrap();
329 assert!(missing.is_none(), "unknown id must return None, not error");
330 }
331
332 #[tokio::test]
333 async fn last_pulled_at_tracks_the_newest_sighting() {
334 let pool = common::setup_test_db().await;
335 let user_id = common::create_test_user(&pool).await;
336 let repo = SqliteProblemRepository::new(pool);
337
338 assert!(
339 repo.last_pulled_at(user_id, "wam").await.unwrap().is_none(),
340 "a never-pulled source has no timestamp"
341 );
342
343 let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap();
344 let pulled = repo
345 .last_pulled_at(user_id, "wam")
346 .await
347 .unwrap()
348 .expect("expected a pull timestamp");
349
350 assert_eq!(pulled, problem.last_seen_at);
351 assert!(!problem.is_stale(pulled));
352 }
353
354 #[tokio::test]
355 async fn delete_removes_the_row() {
356 let pool = common::setup_test_db().await;
357 let user_id = common::create_test_user(&pool).await;
358 let repo = SqliteProblemRepository::new(pool);
359
360 let problem = repo.ingest(user_id, new_problem("t-1")).await.unwrap();
361 assert!(repo.delete(problem.id, user_id).await.unwrap());
362 assert!(repo.get_by_id(problem.id, user_id).await.unwrap().is_none());
363 assert!(!repo.delete(problem.id, user_id).await.unwrap());
364 }
365