Skip to main content

max / makenotwork

6.8 KB · 224 lines History Blame Raw
1 //! Adversarial coverage for `db::idempotency` (the POST-retry cache).
2 //!
3 //! Run #1 graded `db/idempotency.rs` Testing=C (zero tests). These probe the
4 //! invariants the module is supposed to hold: (key, user, method, path) scoping,
5 //! first-writer-wins under ON CONFLICT, and the 24-hour cleanup boundary.
6
7 use crate::harness::db::TestDb;
8 use crate::harness::seed_user;
9 use makenotwork::db::idempotency;
10
11 #[tokio::test]
12 async fn store_then_get_roundtrip() {
13 let db = TestDb::new().await;
14 let user = seed_user(&db.pool, "idem_roundtrip").await;
15
16 assert!(
17 idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout")
18 .await
19 .expect("get miss")
20 .is_none(),
21 "cold key must miss"
22 );
23
24 idempotency::store_response(
25 &db.pool,
26 "k1",
27 user,
28 "POST",
29 "/checkout",
30 201,
31 "{\"ok\":true}",
32 )
33 .await
34 .expect("store");
35
36 let hit = idempotency::get_cached_response(&db.pool, "k1", user, "POST", "/checkout")
37 .await
38 .expect("get hit")
39 .expect("must hit after store");
40 assert_eq!(hit.status_code, 201);
41 assert_eq!(hit.response_body, "{\"ok\":true}");
42 }
43
44 #[tokio::test]
45 async fn scope_isolates_key_across_user_method_path() {
46 let db = TestDb::new().await;
47 let alice = seed_user(&db.pool, "idem_alice").await;
48 let bob = seed_user(&db.pool, "idem_bob").await;
49
50 // Same key string, four distinct scopes, none may leak into another.
51 idempotency::store_response(&db.pool, "shared", alice, "POST", "/a", 200, "alice-a")
52 .await
53 .unwrap();
54 idempotency::store_response(&db.pool, "shared", alice, "POST", "/b", 200, "alice-b")
55 .await
56 .unwrap();
57 idempotency::store_response(
58 &db.pool,
59 "shared",
60 alice,
61 "DELETE",
62 "/a",
63 200,
64 "alice-del-a",
65 )
66 .await
67 .unwrap();
68 idempotency::store_response(&db.pool, "shared", bob, "POST", "/a", 200, "bob-a")
69 .await
70 .unwrap();
71
72 let cases = [
73 (alice, "POST", "/a", "alice-a"),
74 (alice, "POST", "/b", "alice-b"),
75 (alice, "DELETE", "/a", "alice-del-a"),
76 (bob, "POST", "/a", "bob-a"),
77 ];
78 for (user, method, path, want) in cases {
79 let got = idempotency::get_cached_response(&db.pool, "shared", user, method, path)
80 .await
81 .unwrap()
82 .expect("each scope is stored independently");
83 assert_eq!(got.response_body, want, "scope {method} {path} leaked");
84 }
85
86 // A scope nobody wrote (bob, DELETE, /a) must still miss.
87 assert!(
88 idempotency::get_cached_response(&db.pool, "shared", bob, "DELETE", "/a")
89 .await
90 .unwrap()
91 .is_none()
92 );
93 }
94
95 #[tokio::test]
96 async fn second_store_is_a_no_op_first_writer_wins() {
97 let db = TestDb::new().await;
98 let user = seed_user(&db.pool, "idem_firstwriter").await;
99
100 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 201, "first")
101 .await
102 .unwrap();
103 // ON CONFLICT DO NOTHING: a second store on the same scope must not clobber.
104 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 500, "second")
105 .await
106 .unwrap();
107
108 let got = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x")
109 .await
110 .unwrap()
111 .unwrap();
112 assert_eq!(got.status_code, 201, "first writer's status must stand");
113 assert_eq!(got.response_body, "first", "first writer's body must stand");
114 }
115
116 #[tokio::test]
117 async fn concurrent_stores_keep_exactly_one_row() {
118 let db = TestDb::new().await;
119 let user = seed_user(&db.pool, "idem_concurrent").await;
120
121 // 16 concurrent deliveries of the "same" retry, each with a distinct body.
122 let mut handles = Vec::new();
123 for i in 0..16 {
124 let pool = db.pool.clone();
125 handles.push(tokio::spawn(async move {
126 idempotency::store_response(
127 &pool,
128 "race",
129 user,
130 "POST",
131 "/checkout",
132 201,
133 &format!("body-{i}"),
134 )
135 .await
136 }));
137 }
138 for h in handles {
139 h.await.expect("join").expect("store ok");
140 }
141
142 let count: i64 = sqlx::query_scalar(
143 "SELECT COUNT(*) FROM idempotency_keys WHERE key = 'race' AND user_id = $1",
144 )
145 .bind(user)
146 .fetch_one(&db.pool)
147 .await
148 .unwrap();
149 assert_eq!(count, 1, "exactly one row survives the race");
150
151 // And the cached read is stable (some single winner's body).
152 let got = idempotency::get_cached_response(&db.pool, "race", user, "POST", "/checkout")
153 .await
154 .unwrap()
155 .unwrap();
156 assert!(got.response_body.starts_with("body-"));
157 }
158
159 #[tokio::test]
160 async fn cleanup_expired_respects_24h_boundary() {
161 let db = TestDb::new().await;
162 let user = seed_user(&db.pool, "idem_cleanup").await;
163
164 idempotency::store_response(&db.pool, "fresh", user, "POST", "/x", 200, "fresh")
165 .await
166 .unwrap();
167 idempotency::store_response(&db.pool, "stale", user, "POST", "/x", 200, "stale")
168 .await
169 .unwrap();
170 // Backdate the stale row just past the 24h window.
171 sqlx::query(
172 "UPDATE idempotency_keys SET created_at = NOW() - INTERVAL '25 hours' \
173 WHERE key = 'stale' AND user_id = $1",
174 )
175 .bind(user)
176 .execute(&db.pool)
177 .await
178 .unwrap();
179
180 let deleted = idempotency::cleanup_expired(&db.pool).await.unwrap();
181 assert_eq!(deleted, 1, "only the >24h row is purged");
182
183 assert!(
184 idempotency::get_cached_response(&db.pool, "fresh", user, "POST", "/x")
185 .await
186 .unwrap()
187 .is_some(),
188 "fresh row survives cleanup"
189 );
190 assert!(
191 idempotency::get_cached_response(&db.pool, "stale", user, "POST", "/x")
192 .await
193 .unwrap()
194 .is_none(),
195 "stale row is gone"
196 );
197 }
198
199 #[tokio::test]
200 async fn empty_key_is_stored_and_scoped_independently() {
201 let db = TestDb::new().await;
202 let user = seed_user(&db.pool, "idem_emptykey").await;
203
204 // An empty key is a benign, distinct key value, it must round-trip and not
205 // collide with a non-empty key in the same scope.
206 idempotency::store_response(&db.pool, "", user, "POST", "/x", 202, "empty")
207 .await
208 .unwrap();
209 idempotency::store_response(&db.pool, "k", user, "POST", "/x", 200, "nonempty")
210 .await
211 .unwrap();
212
213 let empty = idempotency::get_cached_response(&db.pool, "", user, "POST", "/x")
214 .await
215 .unwrap()
216 .unwrap();
217 assert_eq!(empty.response_body, "empty");
218 let nonempty = idempotency::get_cached_response(&db.pool, "k", user, "POST", "/x")
219 .await
220 .unwrap()
221 .unwrap();
222 assert_eq!(nonempty.response_body, "nonempty");
223 }
224