Skip to main content

max / makenotwork

4.7 KB · 141 lines History Blame Raw
1 //! DB-layer contract tests for `db::issues`, repo-scoped issue numbering.
2 //!
3 //! Audit Run 16 flagged `issues` as a Concurrency/Testing cold spot (B+). The
4 //! interesting invariant is the sequential per-repo `number` assignment:
5 //! `create_issue` computes `MAX(number)+1` and retries once on the
6 //! `(repo_id, number)` unique violation that a concurrent insert can cause.
7 //! These pin monotonic numbering, per-repo independence, and that two
8 //! simultaneous creates resolve to two distinct numbers (the retry path).
9 //!
10 //! NOTE: the retry is single-shot, so it reliably resolves up to two colliding
11 //! writers; a burst of many simultaneous creates on one repo can still exhaust
12 //! it. The concurrency test stays within that guaranteed envelope.
13
14 use crate::harness::db::TestDb;
15 use makenotwork::db::{GitRepoId, UserId, issues};
16
17 async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
18 let hash = makenotwork::auth::hash_password("password123").expect("hash");
19 sqlx::query_scalar::<_, UserId>(
20 "INSERT INTO users (username, email, password_hash, email_verified)
21 VALUES ($1, $2, $3, true) RETURNING id",
22 )
23 .bind(username)
24 .bind(format!("{username}@test.com"))
25 .bind(&hash)
26 .fetch_one(pool)
27 .await
28 .expect("seed user")
29 }
30
31 async fn seed_repo(pool: &sqlx::PgPool, user: UserId, name: &str) -> GitRepoId {
32 sqlx::query_scalar::<_, GitRepoId>(
33 "INSERT INTO git_repos (user_id, name) VALUES ($1, $2) RETURNING id",
34 )
35 .bind(user)
36 .bind(name)
37 .fetch_one(pool)
38 .await
39 .expect("seed repo")
40 }
41
42 #[tokio::test]
43 async fn issue_numbers_are_sequential_within_a_repo() {
44 let db = TestDb::new().await;
45 let user = seed_user(&db.pool, "iss_seq").await;
46 let repo = seed_repo(&db.pool, user, "repo").await;
47
48 let mut numbers = Vec::new();
49 for i in 0..4 {
50 let issue = issues::create_issue(&db.pool, repo, user, &format!("Issue {i}"), "md", "html")
51 .await
52 .unwrap();
53 numbers.push(issue.number);
54 }
55 assert_eq!(
56 numbers,
57 [1, 2, 3, 4],
58 "numbers increment monotonically from 1"
59 );
60 }
61
62 #[tokio::test]
63 async fn issue_numbering_is_independent_per_repo() {
64 let db = TestDb::new().await;
65 let user = seed_user(&db.pool, "iss_scope").await;
66 let repo_a = seed_repo(&db.pool, user, "repo-a").await;
67 let repo_b = seed_repo(&db.pool, user, "repo-b").await;
68
69 let a1 = issues::create_issue(&db.pool, repo_a, user, "A1", "m", "h")
70 .await
71 .unwrap();
72 let b1 = issues::create_issue(&db.pool, repo_b, user, "B1", "m", "h")
73 .await
74 .unwrap();
75 let a2 = issues::create_issue(&db.pool, repo_a, user, "A2", "m", "h")
76 .await
77 .unwrap();
78
79 // Each repo has its own sequence starting at 1.
80 assert_eq!(a1.number, 1);
81 assert_eq!(
82 b1.number, 1,
83 "a second repo's numbering is not affected by the first"
84 );
85 assert_eq!(a2.number, 2);
86 }
87
88 #[tokio::test]
89 async fn get_issue_by_number_resolves_within_the_repo() {
90 let db = TestDb::new().await;
91 let user = seed_user(&db.pool, "iss_get").await;
92 let repo = seed_repo(&db.pool, user, "repo").await;
93 let created = issues::create_issue(&db.pool, repo, user, "Findable", "m", "h")
94 .await
95 .unwrap();
96
97 let fetched = issues::get_issue_by_number(&db.pool, repo, created.number)
98 .await
99 .unwrap()
100 .expect("the issue resolves by its repo-scoped number");
101 assert_eq!(fetched.id, created.id);
102 assert_eq!(fetched.title, "Findable");
103
104 // A number that doesn't exist in this repo resolves to None.
105 assert!(
106 issues::get_issue_by_number(&db.pool, repo, 999)
107 .await
108 .unwrap()
109 .is_none()
110 );
111 }
112
113 /// Two simultaneous creates on the same repo must not collide on a number: the
114 /// `MAX(number)+1` race is resolved by the single-shot retry, so both succeed
115 /// with distinct sequential numbers {1, 2}.
116 #[tokio::test]
117 async fn concurrent_creates_get_distinct_numbers() {
118 let db = TestDb::new().await;
119 let user = seed_user(&db.pool, "iss_race").await;
120 let repo = seed_repo(&db.pool, user, "repo").await;
121
122 let p1 = db.pool.clone();
123 let p2 = db.pool.clone();
124 let (a, b) = tokio::join!(
125 tokio::spawn(async move { issues::create_issue(&p1, repo, user, "A", "m", "h").await }),
126 tokio::spawn(async move { issues::create_issue(&p2, repo, user, "B", "m", "h").await }),
127 );
128 let a = a.unwrap().expect("first create must succeed");
129 let b = b
130 .unwrap()
131 .expect("second create must succeed under contention");
132
133 let mut nums = [a.number, b.number];
134 nums.sort_unstable();
135 assert_eq!(
136 nums,
137 [1, 2],
138 "concurrent creates take two distinct sequential numbers"
139 );
140 }
141