Skip to main content

max / makenotwork

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