Skip to main content

mas_storage_pg/user/
registration_token.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2025 New Vector Ltd.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use mas_data_model::{Clock, UlidExt as _, UserRegistrationToken};
10use mas_storage::{
11    Page, Pagination,
12    pagination::Node,
13    user::{UserRegistrationTokenFilter, UserRegistrationTokenRepository},
14};
15use rand::Rng;
16use sea_query::{Condition, Expr, ExprTrait, PostgresQueryBuilder, Query, enum_def};
17use sea_query_sqlx::SqlxBinder;
18use sqlx::PgConnection;
19use ulid::Ulid;
20use uuid::Uuid;
21
22use crate::{
23    DatabaseInconsistencyError,
24    errors::DatabaseError,
25    filter::{Filter, StatementExt},
26    iden::UserRegistrationTokens,
27    pagination::QueryBuilderExt,
28    tracing::ExecuteExt,
29};
30
31/// An implementation of [`mas_storage::user::UserRegistrationTokenRepository`]
32/// for a PostgreSQL connection
33pub struct PgUserRegistrationTokenRepository<'c> {
34    conn: &'c mut PgConnection,
35}
36
37impl<'c> PgUserRegistrationTokenRepository<'c> {
38    /// Create a new [`PgUserRegistrationTokenRepository`] from an active
39    /// PostgreSQL connection
40    pub fn new(conn: &'c mut PgConnection) -> Self {
41        Self { conn }
42    }
43}
44
45#[derive(Debug, Clone, sqlx::FromRow)]
46#[enum_def]
47struct UserRegistrationTokenLookup {
48    user_registration_token_id: Uuid,
49    token: String,
50    username: Option<String>,
51    email: Option<String>,
52    passwordless: bool,
53    usage_limit: Option<i32>,
54    times_used: i32,
55    created_at: DateTime<Utc>,
56    last_used_at: Option<DateTime<Utc>>,
57    expires_at: Option<DateTime<Utc>>,
58    revoked_at: Option<DateTime<Utc>>,
59}
60
61impl Node<Ulid> for UserRegistrationTokenLookup {
62    fn cursor(&self) -> Ulid {
63        self.user_registration_token_id.into()
64    }
65}
66
67impl Filter for UserRegistrationTokenFilter {
68    fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
69        sea_query::Condition::all()
70            .add_option(self.has_been_used().map(|has_been_used| {
71                if has_been_used {
72                    Expr::col((
73                        UserRegistrationTokens::Table,
74                        UserRegistrationTokens::TimesUsed,
75                    ))
76                    .gt(0)
77                } else {
78                    Expr::col((
79                        UserRegistrationTokens::Table,
80                        UserRegistrationTokens::TimesUsed,
81                    ))
82                    .eq(0)
83                }
84            }))
85            .add_option(self.is_revoked().map(|is_revoked| {
86                if is_revoked {
87                    Expr::col((
88                        UserRegistrationTokens::Table,
89                        UserRegistrationTokens::RevokedAt,
90                    ))
91                    .is_not_null()
92                } else {
93                    Expr::col((
94                        UserRegistrationTokens::Table,
95                        UserRegistrationTokens::RevokedAt,
96                    ))
97                    .is_null()
98                }
99            }))
100            .add_option(self.is_expired().map(|is_expired| {
101                if is_expired {
102                    Condition::all()
103                        .add(
104                            Expr::col((
105                                UserRegistrationTokens::Table,
106                                UserRegistrationTokens::ExpiresAt,
107                            ))
108                            .is_not_null(),
109                        )
110                        .add(
111                            Expr::col((
112                                UserRegistrationTokens::Table,
113                                UserRegistrationTokens::ExpiresAt,
114                            ))
115                            .lt(Expr::val(self.now())),
116                        )
117                } else {
118                    Condition::any()
119                        .add(
120                            Expr::col((
121                                UserRegistrationTokens::Table,
122                                UserRegistrationTokens::ExpiresAt,
123                            ))
124                            .is_null(),
125                        )
126                        .add(
127                            Expr::col((
128                                UserRegistrationTokens::Table,
129                                UserRegistrationTokens::ExpiresAt,
130                            ))
131                            .gte(Expr::val(self.now())),
132                        )
133                }
134            }))
135            .add_option(self.is_valid().map(|is_valid| {
136                let valid = Condition::all()
137                    // Has not reached its usage limit
138                    .add(
139                        Condition::any()
140                            .add(
141                                Expr::col((
142                                    UserRegistrationTokens::Table,
143                                    UserRegistrationTokens::UsageLimit,
144                                ))
145                                .is_null(),
146                            )
147                            .add(
148                                Expr::col((
149                                    UserRegistrationTokens::Table,
150                                    UserRegistrationTokens::TimesUsed,
151                                ))
152                                .lt(Expr::col((
153                                    UserRegistrationTokens::Table,
154                                    UserRegistrationTokens::UsageLimit,
155                                ))),
156                            ),
157                    )
158                    // Has not been revoked
159                    .add(
160                        Expr::col((
161                            UserRegistrationTokens::Table,
162                            UserRegistrationTokens::RevokedAt,
163                        ))
164                        .is_null(),
165                    )
166                    // Has not expired
167                    .add(
168                        Condition::any()
169                            .add(
170                                Expr::col((
171                                    UserRegistrationTokens::Table,
172                                    UserRegistrationTokens::ExpiresAt,
173                                ))
174                                .is_null(),
175                            )
176                            .add(
177                                Expr::col((
178                                    UserRegistrationTokens::Table,
179                                    UserRegistrationTokens::ExpiresAt,
180                                ))
181                                .gte(Expr::val(self.now())),
182                            ),
183                    );
184
185                if is_valid { valid } else { valid.not() }
186            }))
187    }
188}
189
190impl TryFrom<UserRegistrationTokenLookup> for UserRegistrationToken {
191    type Error = DatabaseInconsistencyError;
192
193    fn try_from(res: UserRegistrationTokenLookup) -> Result<Self, Self::Error> {
194        let id = Ulid::from(res.user_registration_token_id);
195
196        let usage_limit = res
197            .usage_limit
198            .map(u32::try_from)
199            .transpose()
200            .map_err(|e| {
201                DatabaseInconsistencyError::on("user_registration_tokens")
202                    .column("usage_limit")
203                    .row(id)
204                    .source(e)
205            })?;
206
207        let times_used = res.times_used.try_into().map_err(|e| {
208            DatabaseInconsistencyError::on("user_registration_tokens")
209                .column("times_used")
210                .row(id)
211                .source(e)
212        })?;
213
214        Ok(UserRegistrationToken {
215            id,
216            token: res.token,
217            username: res.username,
218            email: res.email,
219            passwordless: res.passwordless,
220            usage_limit,
221            times_used,
222            created_at: res.created_at,
223            last_used_at: res.last_used_at,
224            expires_at: res.expires_at,
225            revoked_at: res.revoked_at,
226        })
227    }
228}
229
230#[async_trait]
231impl UserRegistrationTokenRepository for PgUserRegistrationTokenRepository<'_> {
232    type Error = DatabaseError;
233
234    #[tracing::instrument(
235        name = "db.user_registration_token.list",
236        skip_all,
237        fields(
238            db.query.text,
239        ),
240        err,
241    )]
242    async fn list(
243        &mut self,
244        filter: UserRegistrationTokenFilter,
245        pagination: Pagination,
246    ) -> Result<Page<UserRegistrationToken>, Self::Error> {
247        let (sql, arguments) = Query::select()
248            .expr_as(
249                Expr::col((
250                    UserRegistrationTokens::Table,
251                    UserRegistrationTokens::UserRegistrationTokenId,
252                )),
253                UserRegistrationTokenLookupIden::UserRegistrationTokenId,
254            )
255            .expr_as(
256                Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Token)),
257                UserRegistrationTokenLookupIden::Token,
258            )
259            .expr_as(
260                Expr::col((
261                    UserRegistrationTokens::Table,
262                    UserRegistrationTokens::Username,
263                )),
264                UserRegistrationTokenLookupIden::Username,
265            )
266            .expr_as(
267                Expr::col((UserRegistrationTokens::Table, UserRegistrationTokens::Email)),
268                UserRegistrationTokenLookupIden::Email,
269            )
270            .expr_as(
271                Expr::col((
272                    UserRegistrationTokens::Table,
273                    UserRegistrationTokens::Passwordless,
274                )),
275                UserRegistrationTokenLookupIden::Passwordless,
276            )
277            .expr_as(
278                Expr::col((
279                    UserRegistrationTokens::Table,
280                    UserRegistrationTokens::UsageLimit,
281                )),
282                UserRegistrationTokenLookupIden::UsageLimit,
283            )
284            .expr_as(
285                Expr::col((
286                    UserRegistrationTokens::Table,
287                    UserRegistrationTokens::TimesUsed,
288                )),
289                UserRegistrationTokenLookupIden::TimesUsed,
290            )
291            .expr_as(
292                Expr::col((
293                    UserRegistrationTokens::Table,
294                    UserRegistrationTokens::CreatedAt,
295                )),
296                UserRegistrationTokenLookupIden::CreatedAt,
297            )
298            .expr_as(
299                Expr::col((
300                    UserRegistrationTokens::Table,
301                    UserRegistrationTokens::LastUsedAt,
302                )),
303                UserRegistrationTokenLookupIden::LastUsedAt,
304            )
305            .expr_as(
306                Expr::col((
307                    UserRegistrationTokens::Table,
308                    UserRegistrationTokens::ExpiresAt,
309                )),
310                UserRegistrationTokenLookupIden::ExpiresAt,
311            )
312            .expr_as(
313                Expr::col((
314                    UserRegistrationTokens::Table,
315                    UserRegistrationTokens::RevokedAt,
316                )),
317                UserRegistrationTokenLookupIden::RevokedAt,
318            )
319            .from(UserRegistrationTokens::Table)
320            .apply_filter(filter)
321            .generate_pagination(
322                (
323                    UserRegistrationTokens::Table,
324                    UserRegistrationTokens::UserRegistrationTokenId,
325                ),
326                pagination,
327            )
328            .build_sqlx(PostgresQueryBuilder);
329
330        let edges: Vec<UserRegistrationTokenLookup> = sqlx::query_as_with(&sql, arguments)
331            .traced()
332            .fetch_all(&mut *self.conn)
333            .await?;
334
335        let page = pagination
336            .process(edges)
337            .try_map(UserRegistrationToken::try_from)?;
338
339        Ok(page)
340    }
341
342    #[tracing::instrument(
343        name = "db.user_registration_token.count",
344        skip_all,
345        fields(
346            db.query.text,
347            user_registration_token.filter = ?filter,
348        ),
349        err,
350    )]
351    async fn count(&mut self, filter: UserRegistrationTokenFilter) -> Result<usize, Self::Error> {
352        let (sql, values) = Query::select()
353            .expr(
354                Expr::col((
355                    UserRegistrationTokens::Table,
356                    UserRegistrationTokens::UserRegistrationTokenId,
357                ))
358                .count(),
359            )
360            .from(UserRegistrationTokens::Table)
361            .apply_filter(filter)
362            .build_sqlx(PostgresQueryBuilder);
363
364        let count: i64 = sqlx::query_scalar_with(&sql, values)
365            .traced()
366            .fetch_one(&mut *self.conn)
367            .await?;
368
369        count
370            .try_into()
371            .map_err(DatabaseError::to_invalid_operation)
372    }
373
374    #[tracing::instrument(
375        name = "db.user_registration_token.lookup",
376        skip_all,
377        fields(
378            db.query.text,
379            user_registration_token.id = %id,
380        ),
381        err,
382    )]
383    async fn lookup(&mut self, id: Ulid) -> Result<Option<UserRegistrationToken>, Self::Error> {
384        let res = sqlx::query_as!(
385            UserRegistrationTokenLookup,
386            r#"
387                SELECT user_registration_token_id,
388                       token,
389                       username,
390                       email,
391                       passwordless,
392                       usage_limit,
393                       times_used,
394                       created_at,
395                       last_used_at,
396                       expires_at,
397                       revoked_at
398                FROM user_registration_tokens
399                WHERE user_registration_token_id = $1
400            "#,
401            Uuid::from(id)
402        )
403        .traced()
404        .fetch_optional(&mut *self.conn)
405        .await?;
406
407        let Some(res) = res else {
408            return Ok(None);
409        };
410
411        Ok(Some(res.try_into()?))
412    }
413
414    #[tracing::instrument(
415        name = "db.user_registration_token.find_by_token",
416        skip_all,
417        fields(
418            db.query.text,
419            token = %token,
420        ),
421        err,
422    )]
423    async fn find_by_token(
424        &mut self,
425        token: &str,
426    ) -> Result<Option<UserRegistrationToken>, Self::Error> {
427        let res = sqlx::query_as!(
428            UserRegistrationTokenLookup,
429            r#"
430                SELECT user_registration_token_id,
431                       token,
432                       username,
433                       email,
434                       passwordless,
435                       usage_limit,
436                       times_used,
437                       created_at,
438                       last_used_at,
439                       expires_at,
440                       revoked_at
441                FROM user_registration_tokens
442                WHERE token = $1
443            "#,
444            token
445        )
446        .traced()
447        .fetch_optional(&mut *self.conn)
448        .await?;
449
450        let Some(res) = res else {
451            return Ok(None);
452        };
453
454        Ok(Some(res.try_into()?))
455    }
456
457    #[tracing::instrument(
458        name = "db.user_registration_token.add",
459        skip_all,
460        fields(
461            db.query.text,
462            user_registration_token.token = %token,
463        ),
464        err,
465    )]
466    async fn add(
467        &mut self,
468        rng: &mut (dyn Rng + Send),
469        clock: &dyn mas_data_model::Clock,
470        token: String,
471        usage_limit: Option<u32>,
472        expires_at: Option<DateTime<Utc>>,
473        username: Option<String>,
474        email: Option<String>,
475        passwordless: bool,
476    ) -> Result<UserRegistrationToken, Self::Error> {
477        let created_at = clock.now();
478        let id = Ulid::from_datetime_with_rng(created_at, rng);
479
480        let usage_limit_i32 = usage_limit
481            .map(i32::try_from)
482            .transpose()
483            .map_err(DatabaseError::to_invalid_operation)?;
484
485        sqlx::query!(
486            r#"
487                INSERT INTO user_registration_tokens
488                    (user_registration_token_id, token, usage_limit, created_at, expires_at, username, email, passwordless)
489                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
490            "#,
491            Uuid::from(id),
492            &token,
493            usage_limit_i32,
494            created_at,
495            expires_at,
496            username.as_deref(),
497            email.as_deref(),
498            passwordless,
499        )
500        .traced()
501        .execute(&mut *self.conn)
502        .await?;
503
504        Ok(UserRegistrationToken {
505            id,
506            token,
507            username,
508            email,
509            passwordless,
510            usage_limit,
511            times_used: 0,
512            created_at,
513            last_used_at: None,
514            expires_at,
515            revoked_at: None,
516        })
517    }
518
519    #[tracing::instrument(
520        name = "db.user_registration_token.use_token",
521        skip_all,
522        fields(
523            db.query.text,
524            user_registration_token.id = %token.id,
525        ),
526        err,
527    )]
528    async fn use_token(
529        &mut self,
530        clock: &dyn Clock,
531        token: UserRegistrationToken,
532    ) -> Result<UserRegistrationToken, Self::Error> {
533        let now = clock.now();
534        let new_times_used = sqlx::query_scalar!(
535            r#"
536                UPDATE user_registration_tokens
537                SET times_used = times_used + 1,
538                    last_used_at = $2
539                WHERE user_registration_token_id = $1 AND revoked_at IS NULL
540                RETURNING times_used
541            "#,
542            Uuid::from(token.id),
543            now,
544        )
545        .traced()
546        .fetch_one(&mut *self.conn)
547        .await?;
548
549        let new_times_used = new_times_used
550            .try_into()
551            .map_err(DatabaseError::to_invalid_operation)?;
552
553        Ok(UserRegistrationToken {
554            times_used: new_times_used,
555            last_used_at: Some(now),
556            ..token
557        })
558    }
559
560    #[tracing::instrument(
561        name = "db.user_registration_token.revoke",
562        skip_all,
563        fields(
564            db.query.text,
565            user_registration_token.id = %token.id,
566        ),
567        err,
568    )]
569    async fn revoke(
570        &mut self,
571        clock: &dyn Clock,
572        mut token: UserRegistrationToken,
573    ) -> Result<UserRegistrationToken, Self::Error> {
574        let revoked_at = clock.now();
575        let res = sqlx::query!(
576            r#"
577                UPDATE user_registration_tokens
578                SET revoked_at = $2
579                WHERE user_registration_token_id = $1
580            "#,
581            Uuid::from(token.id),
582            revoked_at,
583        )
584        .traced()
585        .execute(&mut *self.conn)
586        .await?;
587
588        DatabaseError::ensure_affected_rows(&res, 1)?;
589
590        token.revoked_at = Some(revoked_at);
591
592        Ok(token)
593    }
594
595    #[tracing::instrument(
596        name = "db.user_registration_token.unrevoke",
597        skip_all,
598        fields(
599            db.query.text,
600            user_registration_token.id = %token.id,
601        ),
602        err,
603    )]
604    async fn unrevoke(
605        &mut self,
606        mut token: UserRegistrationToken,
607    ) -> Result<UserRegistrationToken, Self::Error> {
608        let res = sqlx::query!(
609            r#"
610                UPDATE user_registration_tokens
611                SET revoked_at = NULL
612                WHERE user_registration_token_id = $1
613            "#,
614            Uuid::from(token.id),
615        )
616        .traced()
617        .execute(&mut *self.conn)
618        .await?;
619
620        DatabaseError::ensure_affected_rows(&res, 1)?;
621
622        token.revoked_at = None;
623
624        Ok(token)
625    }
626
627    #[tracing::instrument(
628        name = "db.user_registration_token.set_expiry",
629        skip_all,
630        fields(
631            db.query.text,
632            user_registration_token.id = %token.id,
633        ),
634        err,
635    )]
636    async fn set_expiry(
637        &mut self,
638        mut token: UserRegistrationToken,
639        expires_at: Option<DateTime<Utc>>,
640    ) -> Result<UserRegistrationToken, Self::Error> {
641        let res = sqlx::query!(
642            r#"
643                UPDATE user_registration_tokens
644                SET expires_at = $2
645                WHERE user_registration_token_id = $1
646            "#,
647            Uuid::from(token.id),
648            expires_at,
649        )
650        .traced()
651        .execute(&mut *self.conn)
652        .await?;
653
654        DatabaseError::ensure_affected_rows(&res, 1)?;
655
656        token.expires_at = expires_at;
657
658        Ok(token)
659    }
660
661    #[tracing::instrument(
662        name = "db.user_registration_token.set_usage_limit",
663        skip_all,
664        fields(
665            db.query.text,
666            user_registration_token.id = %token.id,
667        ),
668        err,
669    )]
670    async fn set_usage_limit(
671        &mut self,
672        mut token: UserRegistrationToken,
673        usage_limit: Option<u32>,
674    ) -> Result<UserRegistrationToken, Self::Error> {
675        let usage_limit_i32 = usage_limit
676            .map(i32::try_from)
677            .transpose()
678            .map_err(DatabaseError::to_invalid_operation)?;
679
680        let res = sqlx::query!(
681            r#"
682                UPDATE user_registration_tokens
683                SET usage_limit = $2
684                WHERE user_registration_token_id = $1
685            "#,
686            Uuid::from(token.id),
687            usage_limit_i32,
688        )
689        .traced()
690        .execute(&mut *self.conn)
691        .await?;
692
693        DatabaseError::ensure_affected_rows(&res, 1)?;
694
695        token.usage_limit = usage_limit;
696
697        Ok(token)
698    }
699
700    #[tracing::instrument(
701        name = "db.user_registration_token.set_username",
702        skip_all,
703        fields(
704            db.query.text,
705            user_registration_token.id = %token.id,
706        ),
707        err,
708    )]
709    async fn set_username(
710        &mut self,
711        mut token: UserRegistrationToken,
712        username: Option<String>,
713    ) -> Result<UserRegistrationToken, Self::Error> {
714        let res = sqlx::query!(
715            r#"
716                UPDATE user_registration_tokens
717                SET username = $2
718                WHERE user_registration_token_id = $1
719            "#,
720            Uuid::from(token.id),
721            username.as_deref(),
722        )
723        .traced()
724        .execute(&mut *self.conn)
725        .await?;
726
727        DatabaseError::ensure_affected_rows(&res, 1)?;
728
729        token.username = username;
730
731        Ok(token)
732    }
733
734    #[tracing::instrument(
735        name = "db.user_registration_token.set_email",
736        skip_all,
737        fields(
738            db.query.text,
739            user_registration_token.id = %token.id,
740        ),
741        err,
742    )]
743    async fn set_email(
744        &mut self,
745        mut token: UserRegistrationToken,
746        email: Option<String>,
747    ) -> Result<UserRegistrationToken, Self::Error> {
748        let res = sqlx::query!(
749            r#"
750                UPDATE user_registration_tokens
751                SET email = $2
752                WHERE user_registration_token_id = $1
753            "#,
754            Uuid::from(token.id),
755            email.as_deref(),
756        )
757        .traced()
758        .execute(&mut *self.conn)
759        .await?;
760
761        DatabaseError::ensure_affected_rows(&res, 1)?;
762
763        token.email = email;
764
765        Ok(token)
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use chrono::Duration;
772    use mas_data_model::{Clock as _, clock::MockClock};
773    use mas_storage::{Pagination, user::UserRegistrationTokenFilter};
774    use rand::SeedableRng;
775    use rand_chacha::ChaChaRng;
776    use sqlx::PgPool;
777
778    use crate::PgRepository;
779
780    #[sqlx::test(migrator = "crate::MIGRATOR")]
781    async fn test_unrevoke(pool: PgPool) {
782        let mut rng = ChaChaRng::seed_from_u64(42);
783        let clock = MockClock::default();
784
785        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
786
787        // Create a token
788        let token = repo
789            .user_registration_token()
790            .add(
791                &mut rng,
792                &clock,
793                "test_token".to_owned(),
794                None,
795                None,
796                None,
797                None,
798                false,
799            )
800            .await
801            .unwrap();
802
803        // Revoke the token
804        let revoked_token = repo
805            .user_registration_token()
806            .revoke(&clock, token)
807            .await
808            .unwrap();
809
810        // Verify it's revoked
811        assert!(revoked_token.revoked_at.is_some());
812
813        // Unrevoke the token
814        let unrevoked_token = repo
815            .user_registration_token()
816            .unrevoke(revoked_token)
817            .await
818            .unwrap();
819
820        // Verify it's no longer revoked
821        assert!(unrevoked_token.revoked_at.is_none());
822
823        // Check that we can find it with the non-revoked filter
824        let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
825        let page = repo
826            .user_registration_token()
827            .list(non_revoked_filter, Pagination::first(10))
828            .await
829            .unwrap();
830
831        assert!(page.edges.iter().any(|t| t.node.id == unrevoked_token.id));
832    }
833
834    #[sqlx::test(migrator = "crate::MIGRATOR")]
835    async fn test_set_expiry(pool: PgPool) {
836        let mut rng = ChaChaRng::seed_from_u64(42);
837        let clock = MockClock::default();
838
839        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
840
841        // Create a token without expiry
842        let token = repo
843            .user_registration_token()
844            .add(
845                &mut rng,
846                &clock,
847                "test_token_expiry".to_owned(),
848                None,
849                None,
850                None,
851                None,
852                false,
853            )
854            .await
855            .unwrap();
856
857        // Verify it has no expiration
858        assert!(token.expires_at.is_none());
859
860        // Set an expiration
861        let future_time = clock.now() + Duration::days(30);
862        let updated_token = repo
863            .user_registration_token()
864            .set_expiry(token, Some(future_time))
865            .await
866            .unwrap();
867
868        // Verify expiration is set
869        assert_eq!(updated_token.expires_at, Some(future_time));
870
871        // Remove the expiration
872        let final_token = repo
873            .user_registration_token()
874            .set_expiry(updated_token, None)
875            .await
876            .unwrap();
877
878        // Verify expiration is removed
879        assert!(final_token.expires_at.is_none());
880    }
881
882    #[sqlx::test(migrator = "crate::MIGRATOR")]
883    async fn test_set_usage_limit(pool: PgPool) {
884        let mut rng = ChaChaRng::seed_from_u64(42);
885        let clock = MockClock::default();
886
887        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
888
889        // Create a token without usage limit
890        let token = repo
891            .user_registration_token()
892            .add(
893                &mut rng,
894                &clock,
895                "test_token_limit".to_owned(),
896                None,
897                None,
898                None,
899                None,
900                false,
901            )
902            .await
903            .unwrap();
904
905        // Verify it has no usage limit
906        assert!(token.usage_limit.is_none());
907
908        // Set a usage limit
909        let updated_token = repo
910            .user_registration_token()
911            .set_usage_limit(token, Some(5))
912            .await
913            .unwrap();
914
915        // Verify usage limit is set
916        assert_eq!(updated_token.usage_limit, Some(5));
917
918        // Change the usage limit
919        let changed_token = repo
920            .user_registration_token()
921            .set_usage_limit(updated_token, Some(10))
922            .await
923            .unwrap();
924
925        // Verify usage limit is changed
926        assert_eq!(changed_token.usage_limit, Some(10));
927
928        // Remove the usage limit
929        let final_token = repo
930            .user_registration_token()
931            .set_usage_limit(changed_token, None)
932            .await
933            .unwrap();
934
935        // Verify usage limit is removed
936        assert!(final_token.usage_limit.is_none());
937    }
938
939    #[sqlx::test(migrator = "crate::MIGRATOR")]
940    async fn test_list_and_count(pool: PgPool) {
941        let mut rng = ChaChaRng::seed_from_u64(42);
942        let clock = MockClock::default();
943
944        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
945
946        // Create different types of tokens
947        // 1. A regular token
948        let _token1 = repo
949            .user_registration_token()
950            .add(
951                &mut rng,
952                &clock,
953                "token1".to_owned(),
954                None,
955                None,
956                None,
957                None,
958                false,
959            )
960            .await
961            .unwrap();
962
963        // 2. A token that has been used
964        let token2 = repo
965            .user_registration_token()
966            .add(
967                &mut rng,
968                &clock,
969                "token2".to_owned(),
970                None,
971                None,
972                None,
973                None,
974                false,
975            )
976            .await
977            .unwrap();
978        let token2 = repo
979            .user_registration_token()
980            .use_token(&clock, token2)
981            .await
982            .unwrap();
983
984        // 3. A token that is expired
985        let past_time = clock.now() - Duration::days(1);
986        let token3 = repo
987            .user_registration_token()
988            .add(
989                &mut rng,
990                &clock,
991                "token3".to_owned(),
992                None,
993                Some(past_time),
994                None,
995                None,
996                false,
997            )
998            .await
999            .unwrap();
1000
1001        // 4. A token that is revoked
1002        let token4 = repo
1003            .user_registration_token()
1004            .add(
1005                &mut rng,
1006                &clock,
1007                "token4".to_owned(),
1008                None,
1009                None,
1010                None,
1011                None,
1012                false,
1013            )
1014            .await
1015            .unwrap();
1016        let token4 = repo
1017            .user_registration_token()
1018            .revoke(&clock, token4)
1019            .await
1020            .unwrap();
1021
1022        // Test list with empty filter
1023        let empty_filter = UserRegistrationTokenFilter::new(clock.now());
1024        let page = repo
1025            .user_registration_token()
1026            .list(empty_filter, Pagination::first(10))
1027            .await
1028            .unwrap();
1029        assert_eq!(page.edges.len(), 4);
1030
1031        // Test count with empty filter
1032        let count = repo
1033            .user_registration_token()
1034            .count(empty_filter)
1035            .await
1036            .unwrap();
1037        assert_eq!(count, 4);
1038
1039        // Test has_been_used filter
1040        let used_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(true);
1041        let page = repo
1042            .user_registration_token()
1043            .list(used_filter, Pagination::first(10))
1044            .await
1045            .unwrap();
1046        assert_eq!(page.edges.len(), 1);
1047        assert_eq!(page.edges[0].node.id, token2.id);
1048
1049        // Test unused filter
1050        let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false);
1051        let page = repo
1052            .user_registration_token()
1053            .list(unused_filter, Pagination::first(10))
1054            .await
1055            .unwrap();
1056        assert_eq!(page.edges.len(), 3);
1057
1058        // Test is_expired filter
1059        let expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(true);
1060        let page = repo
1061            .user_registration_token()
1062            .list(expired_filter, Pagination::first(10))
1063            .await
1064            .unwrap();
1065        assert_eq!(page.edges.len(), 1);
1066        assert_eq!(page.edges[0].node.id, token3.id);
1067
1068        let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false);
1069        let page = repo
1070            .user_registration_token()
1071            .list(not_expired_filter, Pagination::first(10))
1072            .await
1073            .unwrap();
1074        assert_eq!(page.edges.len(), 3);
1075
1076        // Test is_revoked filter
1077        let revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(true);
1078        let page = repo
1079            .user_registration_token()
1080            .list(revoked_filter, Pagination::first(10))
1081            .await
1082            .unwrap();
1083        assert_eq!(page.edges.len(), 1);
1084        assert_eq!(page.edges[0].node.id, token4.id);
1085
1086        let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
1087        let page = repo
1088            .user_registration_token()
1089            .list(not_revoked_filter, Pagination::first(10))
1090            .await
1091            .unwrap();
1092        assert_eq!(page.edges.len(), 3);
1093
1094        // Test is_valid filter
1095        let valid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(true);
1096        let page = repo
1097            .user_registration_token()
1098            .list(valid_filter, Pagination::first(10))
1099            .await
1100            .unwrap();
1101        assert_eq!(page.edges.len(), 2);
1102
1103        let invalid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(false);
1104        let page = repo
1105            .user_registration_token()
1106            .list(invalid_filter, Pagination::first(10))
1107            .await
1108            .unwrap();
1109        assert_eq!(page.edges.len(), 2);
1110
1111        // Test combined filters
1112        let combined_filter = UserRegistrationTokenFilter::new(clock.now())
1113            .with_been_used(false)
1114            .with_revoked(true);
1115        let page = repo
1116            .user_registration_token()
1117            .list(combined_filter, Pagination::first(10))
1118            .await
1119            .unwrap();
1120        assert_eq!(page.edges.len(), 1);
1121        assert_eq!(page.edges[0].node.id, token4.id);
1122
1123        // Test pagination
1124        let page = repo
1125            .user_registration_token()
1126            .list(empty_filter, Pagination::first(2))
1127            .await
1128            .unwrap();
1129        assert_eq!(page.edges.len(), 2);
1130    }
1131}