| 1 | //! Invite a friend: any person invites someone to g1t by email. |
| 2 | //! |
| 3 | //! Anyone may sign up without an invite; this one is word of mouth. The |
| 4 | //! friend gets an email from the person who invited them, whose link opens |
| 5 | //! sign-up with their address filled in and, because it came from that |
| 6 | //! email, confirmed (invites.rs, `proves_email`). The account it makes |
| 7 | //! starts with a workspace of its own, and the invite records who invited |
| 8 | //! whom (`redeemed_by`), so the inviter sees who joined. |
| 9 | //! |
| 10 | //! Each person has `INVITES_PER_USER` (20) invites to g1t for good, plus |
| 11 | //! any staff grant (`invite_grants`, read with [`GRANTED`]). Pending and |
| 12 | //! used invites count; one revoked or expired before it was used comes |
| 13 | //! back. An invite to an address that already has an account, or to one |
| 14 | //! the person already has a pending invite for, is refused and costs |
| 15 | //! nothing. Inviting into a workspace or onto a repository never uses one. |
| 16 | //! |
| 17 | //! "Also invite them to a workspace" is only for a workspace the person |
| 18 | //! owns on a paid plan: a free workspace invites no one (paid.rs). |
| 19 | //! |
| 20 | //! The statements are constants written out in full, `SQL_NOW` included, |
| 21 | //! so scripts/deploy/friend-invites.test.mjs runs them as they are against |
| 22 | //! identity's migrations. |
| 23 | |
| 24 | use g1t_contracts::audit::Surface; |
| 25 | use g1t_contracts::identity::*; |
| 26 | use g1t_contracts::time::rfc3339; |
| 27 | use g1t_contracts::{FailureCode, Outcome, Role, User, new_id}; |
| 28 | use g1t_kit::now_ms; |
| 29 | use worker::Result; |
| 30 | use worker::wasm_bindgen::JsValue; |
| 31 | |
| 32 | use super::{BAD_EMAIL, CREATES_PER_HOUR, Count, Draft, Id, LIST_LIMIT, TOO_MANY, invite_note, normalize_email, resendable}; |
| 33 | use crate::Identity; |
| 34 | |
| 35 | /// Stores an invite (`?1`…`?14`, in the columns' order). |
| 36 | pub(crate) const INSERT: &str = r#"INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id, |
| 37 | staff, charged_to, created_at, expires_at, invitee_id, role) |
| 38 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) |
| 39 | RETURNING id"#; |
| 40 | |
| 41 | /// [`INSERT`], only while the inviter `?15` has fewer than `?16` invites |
| 42 | /// to g1t counted against them: checked in the statement that writes, so |
| 43 | /// two made at once cannot both take the last one. |
| 44 | pub(crate) const INSERT_WITHIN_ALLOWANCE: &str = r#"INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id, |
| 45 | staff, charged_to, created_at, expires_at, invitee_id, role) |
| 46 | SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14 |
| 47 | WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ?15 AND i.charged_to = 'user' |
| 48 | AND (i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))) < ?16 |
| 49 | RETURNING id"#; |
| 50 | |
| 51 | /// How many invites to g1t count against the person `?1`: used ones, and |
| 52 | /// ones neither revoked nor expired. |
| 53 | pub(crate) const USED: &str = r#"SELECT count(*) AS n FROM invites i WHERE i.inviter_id = ?1 AND i.charged_to = 'user' |
| 54 | AND (i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"#; |
| 55 | |
| 56 | /// The invites staff granted the person `?1`, together (negative takes back). |
| 57 | pub(crate) const GRANTED: &str = |
| 58 | r#"SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = 'user' AND target_id = ?1"#; |
| 59 | |
| 60 | /// Whether the person `?1` already has a pending invite to g1t for `?2`. |
| 61 | pub(crate) const PENDING_FOR: &str = r#"SELECT count(*) AS n FROM invites WHERE inviter_id = ?1 AND email = ?2 AND charged_to = 'user' |
| 62 | AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')"#; |
| 63 | |
| 64 | const ALREADY_ON_G1T: &str = |
| 65 | "That address already has a g1t account, so there is nothing to invite them to. To work with them, invite them to a workspace from its People page."; |
| 66 | const ALREADY_PENDING: &str = "You already have a pending invite for that address. Send it again, or revoke it to make a new one."; |
| 67 | const ONLY_PENDING: &str = "Only a pending invite can be sent again."; |
| 68 | const NOT_YOURS: &str = "There is no invite to g1t of yours with that id."; |
| 69 | |
| 70 | /// The message when a person has no invites left. |
| 71 | pub fn out_of_invites(limit: u32) -> String { |
| 72 | format!("You have used all {limit} of your invites to g1t. Anyone can still sign up at g1t.sh without one.") |
| 73 | } |
| 74 | |
| 75 | /// How many invites to g1t a person may have: the default plus staff |
| 76 | /// grants, never below zero. |
| 77 | pub fn limit_for(default: u32, granted: i64) -> u32 { |
| 78 | (i64::from(default) + granted).clamp(0, i64::from(u32::MAX)) as u32 |
| 79 | } |
| 80 | |
| 81 | /// Whether an invite in this state uses up one of an allowance: pending |
| 82 | /// and used ones do; a revoked or expired one never used gives it back. |
| 83 | /// [`USED`] says the same in SQL. |
| 84 | pub fn counts_against_allowance(status: InviteStatus, used: bool) -> bool { |
| 85 | used || status == InviteStatus::Pending |
| 86 | } |
| 87 | |
| 88 | /// Why an invite to g1t cannot be made, checked in this order: the |
| 89 | /// address has an account, the person has a pending invite for it, or |
| 90 | /// they have none left. The first two cost nothing. |
| 91 | pub fn refusal(has_account: bool, pending: bool, allowance: Allowance) -> Option<(FailureCode, String)> { |
| 92 | if has_account { |
| 93 | return Some((FailureCode::Conflict, ALREADY_ON_G1T.to_owned())); |
| 94 | } |
| 95 | if pending { |
| 96 | return Some((FailureCode::Conflict, ALREADY_PENDING.to_owned())); |
| 97 | } |
| 98 | if allowance.exhausted() { |
| 99 | return Some((FailureCode::Limit, out_of_invites(allowance.limit))); |
| 100 | } |
| 101 | None |
| 102 | } |
| 103 | |
| 104 | impl Identity { |
| 105 | /// INVITES_PER_USER, or [`INVITES_PER_USER`] (20) when unset or not a |
| 106 | /// number. A self-hoster may set it to 0 to turn invites to g1t off. |
| 107 | fn invites_per_user(&self) -> u32 { |
| 108 | self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32) |
| 109 | } |
| 110 | |
| 111 | async fn sql_count(&self, sql: &str, binds: &[JsValue]) -> Result<f64> { |
| 112 | Ok(self.db.prepare(sql).bind(binds)?.first::<Count>(None).await?.map_or(0.0, |count| count.n)) |
| 113 | } |
| 114 | |
| 115 | /// A person's invites to g1t: how many they may have and have used. |
| 116 | pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> { |
| 117 | let granted = self.sql_count(GRANTED, &[user_id.into()]).await? as i64; |
| 118 | let used = self.sql_count(USED, &[user_id.into()]).await? as u32; |
| 119 | Ok(Allowance::new(limit_for(self.invites_per_user(), granted), used)) |
| 120 | } |
| 121 | |
| 122 | /// The workspace an invite to g1t also invites its person to, from |
| 123 | /// `join` (a slug): one `user` owns, on a paid plan. None for none. |
| 124 | async fn joinable_workspace(&self, user: &User, join: Option<&str>) -> Result<Outcome<Option<(String, String)>>> { |
| 125 | let Some(slug) = join.map(str::trim).filter(|slug| !slug.is_empty()).map(str::to_lowercase) else { |
| 126 | return Ok(Outcome::Ok(None)); |
| 127 | }; |
| 128 | if user.role_in(&slug) != Some(Role::Owner) { |
| 129 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can invite people to it.")); |
| 130 | } |
| 131 | let Some(id) = self.workspace_id(&slug).await? else { |
| 132 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 133 | }; |
| 134 | // A free workspace adds no one until it starts the plan (paid.rs). |
| 135 | if let Some(refused) = self.free_workspace_refusal(&slug).await? { |
| 136 | return Ok(refused); |
| 137 | } |
| 138 | Ok(Outcome::Ok(Some((id, slug)))) |
| 139 | } |
| 140 | |
| 141 | pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> { |
| 142 | if let Some(reason) = Self::draft_allowed(&a.user) { |
| 143 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); |
| 144 | } |
| 145 | let Some(email) = normalize_email(&a.email) else { |
| 146 | return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)); |
| 147 | }; |
| 148 | if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? { |
| 149 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 150 | } |
| 151 | let has_account = self.email_has_account(&email).await?; |
| 152 | let pending = !has_account && self.sql_count(PENDING_FOR, &[a.user.id.as_str().into(), email.as_str().into()]).await? > 0.0; |
| 153 | let allowance = self.user_allowance(&a.user.id).await?; |
| 154 | if let Some((code, message)) = refusal(has_account, pending, allowance) { |
| 155 | return Ok(Outcome::fail(code, message)); |
| 156 | } |
| 157 | let joins = match self.joinable_workspace(&a.user, a.join.as_deref()).await? { |
| 158 | Outcome::Ok(joins) => joins, |
| 159 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 160 | }; |
| 161 | let draft = Draft { |
| 162 | email: Some(&email), |
| 163 | kind: "account", |
| 164 | workspace_id: joins.as_ref().map(|(id, _)| id.as_str()), |
| 165 | inviter: Some(&a.user), |
| 166 | staff: None, |
| 167 | invitee_id: None, |
| 168 | role: joins.as_ref().map(|_| if a.join_role == Some(Role::Owner) { "owner" } else { "member" }), |
| 169 | charged_to: "user", |
| 170 | limit: Some(allowance.limit), |
| 171 | }; |
| 172 | let Some(invite) = self.insert_invite(draft).await? else { |
| 173 | return Ok(Outcome::fail(FailureCode::Limit, out_of_invites(allowance.limit))); |
| 174 | }; |
| 175 | if let Some(code) = &invite.code { |
| 176 | let from = self.display_name(&a.user).await; |
| 177 | let workspace = match &joins { |
| 178 | Some((id, slug)) => Some(self.workspace_name(id, slug).await), |
| 179 | None => None, |
| 180 | }; |
| 181 | let note = invite_note(a.message.as_deref()); |
| 182 | self.send_invite_email(&email, Some(&from), workspace.as_deref(), false, code, &invite.id, note.as_deref()).await; |
| 183 | } |
| 184 | // A workspace it invites them to keeps it in its audit log; an |
| 185 | // invite to g1t alone is the person's own business. |
| 186 | if let Some((_, slug)) = &joins { |
| 187 | let surface = a.surface.unwrap_or(Surface::Web); |
| 188 | self.audit_invites(&a.user, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to g1t and {slug}")) |
| 189 | .await; |
| 190 | } |
| 191 | Ok(Outcome::Ok(invite)) |
| 192 | } |
| 193 | |
| 194 | pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> { |
| 195 | let invites = self |
| 196 | .rows("WHERE i.inviter_id = ? AND i.charged_to = 'user'", &[a.user.id.as_str().into()], LIST_LIMIT) |
| 197 | .await? |
| 198 | .into_iter() |
| 199 | .map(|row| self.shown(row, true, false)) |
| 200 | .collect(); |
| 201 | Ok(InvitesOverview { allowance: self.user_allowance(&a.user.id).await?, invites }) |
| 202 | } |
| 203 | |
| 204 | /// Sends a pending invite to g1t again: the same email, with the same |
| 205 | /// link, to the same address. Counted with the invites made in the |
| 206 | /// hour, so a list cannot be used to flood an inbox. |
| 207 | pub async fn resend_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> { |
| 208 | if let Some(reason) = Self::draft_allowed(&a.user) { |
| 209 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); |
| 210 | } |
| 211 | let row = self.invite_by_id(a.id.trim()).await?.filter(|row| { |
| 212 | row.inviter_id.as_deref() == Some(a.user.id.as_str()) && row.charged_to.as_deref() == Some("user") |
| 213 | }); |
| 214 | let Some(row) = row else { |
| 215 | return Ok(Outcome::fail(FailureCode::NotFound, NOT_YOURS)); |
| 216 | }; |
| 217 | if !resendable(row.status(&rfc3339(now_ms()))) { |
| 218 | return Ok(Outcome::fail(FailureCode::Conflict, ONLY_PENDING)); |
| 219 | } |
| 220 | if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? { |
| 221 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 222 | } |
| 223 | let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)); |
| 224 | if let (Some(to), Some(code)) = (row.email.as_deref(), code.as_deref()) { |
| 225 | let from = self.display_name(&a.user).await; |
| 226 | let workspace = match (&row.workspace_id, &row.workspace) { |
| 227 | (Some(id), Some(slug)) => Some(self.workspace_name(id, slug).await), |
| 228 | _ => None, |
| 229 | }; |
| 230 | self.send_invite_email(to, Some(&from), workspace.as_deref(), false, code, &row.id, None).await; |
| 231 | } |
| 232 | Ok(Outcome::Ok(self.shown(row, true, false))) |
| 233 | } |
| 234 | |
| 235 | // --- Staff --- |
| 236 | |
| 237 | pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> { |
| 238 | if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT { |
| 239 | return Ok(Outcome::fail( |
| 240 | FailureCode::Invalid, |
| 241 | format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number."), |
| 242 | )); |
| 243 | } |
| 244 | let staff = a.staff.trim(); |
| 245 | if staff.is_empty() { |
| 246 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them.")); |
| 247 | } |
| 248 | let name = a.username.trim().trim_start_matches('@').to_lowercase(); |
| 249 | let user = self |
| 250 | .db |
| 251 | .prepare("SELECT id FROM users WHERE username = ? AND deleted_at IS NULL") |
| 252 | .bind(&[name.as_str().into()])? |
| 253 | .first::<Id>(None) |
| 254 | .await?; |
| 255 | let Some(Id { id: user_id }) = user else { |
| 256 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no person named {name}."))); |
| 257 | }; |
| 258 | let note = a.note.trim(); |
| 259 | self.db |
| 260 | .prepare( |
| 261 | "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at) |
| 262 | VALUES (?, 'user', ?, ?, ?, ?, ?)", |
| 263 | ) |
| 264 | .bind(&[ |
| 265 | new_id("igr", now_ms()).into(), |
| 266 | user_id.as_str().into(), |
| 267 | f64::from(a.amount).into(), |
| 268 | if note.is_empty() { JsValue::NULL } else { note.into() }, |
| 269 | staff.into(), |
| 270 | rfc3339(now_ms()).into(), |
| 271 | ])? |
| 272 | .run() |
| 273 | .await?; |
| 274 | worker::console_log!("{} invites granted to {name} by staff {staff}", a.amount); |
| 275 | Ok(Outcome::Ok(self.user_allowance(&user_id).await?)) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | #[cfg(test)] |
| 280 | mod tests { |
| 281 | use super::*; |
| 282 | use g1t_contracts::time::SQL_NOW; |
| 283 | |
| 284 | #[test] |
| 285 | fn everyone_has_twenty_invites_to_g1t_plus_what_staff_grant() { |
| 286 | assert_eq!(INVITES_PER_USER, 20); |
| 287 | assert_eq!(limit_for(INVITES_PER_USER, 0), 20); |
| 288 | assert_eq!(limit_for(20, 10), 30); |
| 289 | assert_eq!(limit_for(20, -5), 15); |
| 290 | assert_eq!(limit_for(20, -50), 0); |
| 291 | // A self-hoster's 0 turns them off. |
| 292 | assert_eq!(limit_for(0, 0), 0); |
| 293 | let fresh = Allowance::new(20, 0); |
| 294 | assert_eq!((fresh.remaining, fresh.exhausted()), (20, false)); |
| 295 | let last = Allowance::new(20, 19); |
| 296 | assert_eq!((last.remaining, last.exhausted()), (1, false)); |
| 297 | let full = Allowance::new(20, 20); |
| 298 | assert_eq!((full.remaining, full.exhausted()), (0, true)); |
| 299 | // Grants taken back below what was used: none left, never negative. |
| 300 | assert_eq!(Allowance::new(15, 18).remaining, 0); |
| 301 | } |
| 302 | |
| 303 | #[test] |
| 304 | fn a_revoked_or_expired_invite_comes_back_and_a_used_one_is_spent() { |
| 305 | assert!(counts_against_allowance(InviteStatus::Pending, false)); |
| 306 | assert!(!counts_against_allowance(InviteStatus::Revoked, false)); |
| 307 | assert!(!counts_against_allowance(InviteStatus::Expired, false)); |
| 308 | // Used: spent, whatever became of it after (revoked while the |
| 309 | // account confirmed its address, or the workspace declined). |
| 310 | for status in [ |
| 311 | InviteStatus::Redeemed, |
| 312 | InviteStatus::AwaitingConfirmation, |
| 313 | InviteStatus::AwaitingAnswer, |
| 314 | InviteStatus::Revoked, |
| 315 | InviteStatus::Declined, |
| 316 | ] { |
| 317 | assert!(counts_against_allowance(status, true), "{status:?}"); |
| 318 | } |
| 319 | // The SQL says the same: used, or neither revoked nor expired. |
| 320 | for sql in [USED, INSERT_WITHIN_ALLOWANCE] { |
| 321 | assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >"), "{sql}"); |
| 322 | assert!(sql.contains("i.charged_to = 'user'"), "{sql}"); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | #[test] |
| 327 | fn an_address_with_an_account_or_a_pending_invite_is_refused_without_costing_one() { |
| 328 | let some = Allowance::new(20, 3); |
| 329 | let none = Allowance::new(20, 20); |
| 330 | let (code, message) = refusal(true, false, some).unwrap(); |
| 331 | assert_eq!(code, FailureCode::Conflict); |
| 332 | assert!(message.contains("already has a g1t account")); |
| 333 | // Even with none left, the account is what is said. |
| 334 | assert_eq!(refusal(true, false, none).unwrap().1, ALREADY_ON_G1T); |
| 335 | let (code, message) = refusal(false, true, some).unwrap(); |
| 336 | assert_eq!(code, FailureCode::Conflict); |
| 337 | assert!(message.contains("already have a pending invite")); |
| 338 | let (code, message) = refusal(false, false, none).unwrap(); |
| 339 | assert_eq!(code, FailureCode::Limit); |
| 340 | assert!(message.contains("all 20")); |
| 341 | // Never a gate: running out still says anyone can sign up. |
| 342 | assert!(message.contains("Anyone can still sign up")); |
| 343 | assert_eq!(refusal(false, false, some), None); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn signing_up_from_a_friends_invite_confirms_the_address_and_makes_a_workspace_of_ones_own() { |
| 348 | use super::super::{AwaitingJoin, InviteRow, admits, awaiting_join, email_proof, starts_confirmed}; |
| 349 | use super::super::invitations::makes_own_workspace; |
| 350 | const KEY: &[u8] = b"identity key"; |
| 351 | let row = InviteRow { |
| 352 | id: "inv_1".into(), |
| 353 | hint: "g1t-k7m2".into(), |
| 354 | sealed_code: None, |
| 355 | email: Some("ada@example.com".into()), |
| 356 | kind: "account".into(), |
| 357 | workspace_id: None, |
| 358 | workspace: None, |
| 359 | inviter_id: Some("usr_bo".into()), |
| 360 | inviter: Some("bo".into()), |
| 361 | staff: None, |
| 362 | charged_to: Some("user".into()), |
| 363 | created_at: "2026-10-01T12:00:00.000Z".into(), |
| 364 | expires_at: "2026-10-31T12:00:00.000Z".into(), |
| 365 | revoked_at: None, |
| 366 | redeemer: None, |
| 367 | redeemed_at: None, |
| 368 | applied_at: None, |
| 369 | invitee_id: None, |
| 370 | invitee: None, |
| 371 | role: None, |
| 372 | accepted_at: None, |
| 373 | declined_at: None, |
| 374 | }; |
| 375 | let now = "2026-10-13T12:00:00.000Z"; |
| 376 | // It admits its own address, and only while pending. |
| 377 | assert_eq!(admits(Some(&row.admits(now)), "ada@example.com", true), Ok(())); |
| 378 | assert!(admits(Some(&row.admits(now)), "eve@example.com", true).is_err()); |
| 379 | // Opened from its email, the address starts confirmed; the code alone does not. |
| 380 | let proof = email_proof(KEY, &row.id, row.email.as_deref()).unwrap(); |
| 381 | assert!(starts_confirmed(KEY, false, Some(&row), "ada@example.com", Some(&proof))); |
| 382 | assert!(!starts_confirmed(KEY, false, Some(&row), "ada@example.com", None)); |
| 383 | // It names no workspace: the account makes its own, and confirming |
| 384 | // joins nothing. |
| 385 | assert!(makes_own_workspace(row.workspace.as_deref(), false)); |
| 386 | assert_eq!(awaiting_join(&row, now), AwaitingJoin::Nothing); |
| 387 | // Its inviter sees it used, by whom. |
| 388 | let used = InviteRow { |
| 389 | redeemer: Some("ada".into()), |
| 390 | redeemed_at: Some(now.into()), |
| 391 | applied_at: Some(now.into()), |
| 392 | ..row |
| 393 | }; |
| 394 | assert_eq!(used.status(now), InviteStatus::Redeemed); |
| 395 | assert_eq!(super::super::charge_of(used.charged_to.as_deref()), InviteCharge::User); |
| 396 | } |
| 397 | |
| 398 | #[test] |
| 399 | fn the_statements_use_the_same_now_as_everything_else() { |
| 400 | let now = format!("> {SQL_NOW}"); |
| 401 | for sql in [INSERT_WITHIN_ALLOWANCE, USED, PENDING_FOR, super::super::SPEND_CODE] { |
| 402 | assert!(sql.contains(&now), "{sql}"); |
| 403 | } |
| 404 | for sql in [super::super::REVOKE, super::super::INSERT_USER_WITH_CODE] { |
| 405 | assert!(sql.contains(SQL_NOW), "{sql}"); |
| 406 | } |
| 407 | // The guarded insert writes the same columns as the plain one. |
| 408 | let columns = |sql: &str| sql.split(')').next().unwrap().to_owned(); |
| 409 | assert_eq!(columns(INSERT), columns(INSERT_WITHIN_ALLOWANCE)); |
| 410 | } |
| 411 | } |