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