mas_storage_pg/user/
registration_token.rs

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