Fix payouts notifications not delivering (#5430)

* fix FK violation when inserting rows into `notifications_deliveries`

* add test for FK violation when inserting into notifications_deliveries

* sqlx prepare

* add migration to prevent stale notifications from being dequeued all at once upon fix

* Revert "add migration to prevent stale notifications from being dequeued all at once upon fix"

This reverts commit 446f398752bbddb632196a549501f9ce0b2da67f.
This commit is contained in:
Xander
2026-02-26 19:42:25 +00:00
committed by GitHub
parent 017f6a5afb
commit 6fba33d443
3 changed files with 220 additions and 10 deletions

View File

@@ -1393,3 +1393,179 @@ async fn check_balance_with_webhook(
Ok(result.ok().flatten())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::{
api_v3::ApiV3,
database::USER_USER_ID_PARSED,
environment::{TestEnvironment, with_test_environment},
};
use rust_decimal::dec;
async fn setup_payouts_values(
db: &PgPool,
entries: Vec<(i64, Decimal, DateTime<Utc>)>, // (user_id, amount, date_available)
) {
for (user_id, amount, date_available) in &entries {
sqlx::query!(
"INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)
VALUES ($1, NULL, $2, NOW(), $3)",
user_id,
amount,
date_available,
)
.execute(db)
.await
.unwrap();
}
for (user_id, _amount, date_available) in &entries {
sqlx::query!(
"INSERT INTO payouts_values_notifications (date_available, user_id, notified)
VALUES ($1, $2, FALSE)
ON CONFLICT (date_available, user_id) DO NOTHING",
date_available,
user_id,
)
.execute(db)
.await
.unwrap();
}
}
/// When a user's payout amount is below
/// the $1.00 (100 cents) threshold, the `WHERE sum >= 100` filter in
/// insert_many_payout_notifications skips the INSERT into notifications
/// for that user, but the old code still passed ALL pre-generated
/// notification_ids to insert_many_deliveries. Since
/// notifications_deliveries.notification_id has a FK constraint on
/// notifications(id), this caused a violation that failed
/// the entire transaction. The notified flag was never set to `true`,
/// so the rows accumulated and the same failure repeated every run.
#[actix_rt::test]
async fn test_payout_notification_below_threshold_does_not_fail() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
let redis = &env.db.redis_pool;
let user_id = USER_USER_ID_PARSED;
// date_available must be in the past so the notification query
// picks it up (date_available <= NOW()).
let date_available = Utc::now() - Duration::hours(1);
// Amount of $0.50 -- below the $1.00 threshold (sum < 100 cents).
setup_payouts_values(
db,
vec![(user_id, dec!(0.50), date_available)],
)
.await;
// This should succeed, NOT return an error.
// Before the fix, this would fail with a FK constraint violation.
let result = index_payouts_notifications(db, redis).await;
assert!(
result.is_ok(),
"index_payouts_notifications should succeed for below-threshold payouts, got: {:?}",
result.err()
);
// Verify the notification row was marked as notified (not stuck).
let remaining = sqlx::query_scalar!(
"SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
user_id,
)
.fetch_one(db)
.await
.unwrap()
.unwrap_or(0);
assert_eq!(
remaining, 0,
"All payouts_values_notifications rows should be marked notified"
);
})
.await;
}
/// When there is a mix of users, some above and some below the
/// threshold, the above-threshold users should get notifications
/// while the below-threshold ones are silently skipped. The entire
/// transaction must succeed (not fail due to the below-threshold user).
#[actix_rt::test]
async fn test_payout_notification_mixed_threshold_users() {
with_test_environment(None, |env: TestEnvironment<ApiV3>| async move {
let db = &env.db.pool;
let redis = &env.db.redis_pool;
let above_user_id = USER_USER_ID_PARSED; // user 3
let below_user_id = 4i64; // FRIEND_USER_ID
let date_available = Utc::now() - Duration::hours(1);
setup_payouts_values(
db,
vec![
// Above threshold
(above_user_id, dec!(5.00), date_available),
// Below threshold
(below_user_id, dec!(0.25), date_available),
],
)
.await;
let result = index_payouts_notifications(db, redis).await;
assert!(
result.is_ok(),
"index_payouts_notifications should succeed with mixed users, got: {:?}",
result.err()
);
// Above-threshold user should have a notification.
let above_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
above_user_id,
)
.fetch_one(db)
.await
.unwrap()
.unwrap_or(0);
assert!(
above_count > 0,
"Above-threshold user should have a payout notification"
);
// Below-threshold user should NOT have a notification.
let below_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
below_user_id,
)
.fetch_one(db)
.await
.unwrap()
.unwrap_or(0);
assert_eq!(
below_count, 0,
"Below-threshold user should NOT have a payout notification"
);
// Both should be marked notified.
let remaining = sqlx::query_scalar!(
"SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
)
.fetch_one(db)
.await
.unwrap()
.unwrap_or(0);
assert_eq!(
remaining, 0,
"All payouts_values_notifications rows should be marked notified"
);
})
.await;
}
}