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::{Clock, UserRegistrationToken};
9use mas_storage::{
10    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_data_model::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_data_model::{Clock as _, clock::MockClock};
659    use mas_storage::{Pagination, user::UserRegistrationTokenFilter};
660    use rand::SeedableRng;
661    use rand_chacha::ChaChaRng;
662    use sqlx::PgPool;
663
664    use crate::PgRepository;
665
666    #[sqlx::test(migrator = "crate::MIGRATOR")]
667    async fn test_unrevoke(pool: PgPool) {
668        let mut rng = ChaChaRng::seed_from_u64(42);
669        let clock = MockClock::default();
670
671        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
672
673        // Create a token
674        let token = repo
675            .user_registration_token()
676            .add(&mut rng, &clock, "test_token".to_owned(), None, None)
677            .await
678            .unwrap();
679
680        // Revoke the token
681        let revoked_token = repo
682            .user_registration_token()
683            .revoke(&clock, token)
684            .await
685            .unwrap();
686
687        // Verify it's revoked
688        assert!(revoked_token.revoked_at.is_some());
689
690        // Unrevoke the token
691        let unrevoked_token = repo
692            .user_registration_token()
693            .unrevoke(revoked_token)
694            .await
695            .unwrap();
696
697        // Verify it's no longer revoked
698        assert!(unrevoked_token.revoked_at.is_none());
699
700        // Check that we can find it with the non-revoked filter
701        let non_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
702        let page = repo
703            .user_registration_token()
704            .list(non_revoked_filter, Pagination::first(10))
705            .await
706            .unwrap();
707
708        assert!(page.edges.iter().any(|t| t.id == unrevoked_token.id));
709    }
710
711    #[sqlx::test(migrator = "crate::MIGRATOR")]
712    async fn test_set_expiry(pool: PgPool) {
713        let mut rng = ChaChaRng::seed_from_u64(42);
714        let clock = MockClock::default();
715
716        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
717
718        // Create a token without expiry
719        let token = repo
720            .user_registration_token()
721            .add(&mut rng, &clock, "test_token_expiry".to_owned(), None, None)
722            .await
723            .unwrap();
724
725        // Verify it has no expiration
726        assert!(token.expires_at.is_none());
727
728        // Set an expiration
729        let future_time = clock.now() + Duration::days(30);
730        let updated_token = repo
731            .user_registration_token()
732            .set_expiry(token, Some(future_time))
733            .await
734            .unwrap();
735
736        // Verify expiration is set
737        assert_eq!(updated_token.expires_at, Some(future_time));
738
739        // Remove the expiration
740        let final_token = repo
741            .user_registration_token()
742            .set_expiry(updated_token, None)
743            .await
744            .unwrap();
745
746        // Verify expiration is removed
747        assert!(final_token.expires_at.is_none());
748    }
749
750    #[sqlx::test(migrator = "crate::MIGRATOR")]
751    async fn test_set_usage_limit(pool: PgPool) {
752        let mut rng = ChaChaRng::seed_from_u64(42);
753        let clock = MockClock::default();
754
755        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
756
757        // Create a token without usage limit
758        let token = repo
759            .user_registration_token()
760            .add(&mut rng, &clock, "test_token_limit".to_owned(), None, None)
761            .await
762            .unwrap();
763
764        // Verify it has no usage limit
765        assert!(token.usage_limit.is_none());
766
767        // Set a usage limit
768        let updated_token = repo
769            .user_registration_token()
770            .set_usage_limit(token, Some(5))
771            .await
772            .unwrap();
773
774        // Verify usage limit is set
775        assert_eq!(updated_token.usage_limit, Some(5));
776
777        // Change the usage limit
778        let changed_token = repo
779            .user_registration_token()
780            .set_usage_limit(updated_token, Some(10))
781            .await
782            .unwrap();
783
784        // Verify usage limit is changed
785        assert_eq!(changed_token.usage_limit, Some(10));
786
787        // Remove the usage limit
788        let final_token = repo
789            .user_registration_token()
790            .set_usage_limit(changed_token, None)
791            .await
792            .unwrap();
793
794        // Verify usage limit is removed
795        assert!(final_token.usage_limit.is_none());
796    }
797
798    #[sqlx::test(migrator = "crate::MIGRATOR")]
799    async fn test_list_and_count(pool: PgPool) {
800        let mut rng = ChaChaRng::seed_from_u64(42);
801        let clock = MockClock::default();
802
803        let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
804
805        // Create different types of tokens
806        // 1. A regular token
807        let _token1 = repo
808            .user_registration_token()
809            .add(&mut rng, &clock, "token1".to_owned(), None, None)
810            .await
811            .unwrap();
812
813        // 2. A token that has been used
814        let token2 = repo
815            .user_registration_token()
816            .add(&mut rng, &clock, "token2".to_owned(), None, None)
817            .await
818            .unwrap();
819        let token2 = repo
820            .user_registration_token()
821            .use_token(&clock, token2)
822            .await
823            .unwrap();
824
825        // 3. A token that is expired
826        let past_time = clock.now() - Duration::days(1);
827        let token3 = repo
828            .user_registration_token()
829            .add(&mut rng, &clock, "token3".to_owned(), None, Some(past_time))
830            .await
831            .unwrap();
832
833        // 4. A token that is revoked
834        let token4 = repo
835            .user_registration_token()
836            .add(&mut rng, &clock, "token4".to_owned(), None, None)
837            .await
838            .unwrap();
839        let token4 = repo
840            .user_registration_token()
841            .revoke(&clock, token4)
842            .await
843            .unwrap();
844
845        // Test list with empty filter
846        let empty_filter = UserRegistrationTokenFilter::new(clock.now());
847        let page = repo
848            .user_registration_token()
849            .list(empty_filter, Pagination::first(10))
850            .await
851            .unwrap();
852        assert_eq!(page.edges.len(), 4);
853
854        // Test count with empty filter
855        let count = repo
856            .user_registration_token()
857            .count(empty_filter)
858            .await
859            .unwrap();
860        assert_eq!(count, 4);
861
862        // Test has_been_used filter
863        let used_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(true);
864        let page = repo
865            .user_registration_token()
866            .list(used_filter, Pagination::first(10))
867            .await
868            .unwrap();
869        assert_eq!(page.edges.len(), 1);
870        assert_eq!(page.edges[0].id, token2.id);
871
872        // Test unused filter
873        let unused_filter = UserRegistrationTokenFilter::new(clock.now()).with_been_used(false);
874        let page = repo
875            .user_registration_token()
876            .list(unused_filter, Pagination::first(10))
877            .await
878            .unwrap();
879        assert_eq!(page.edges.len(), 3);
880
881        // Test is_expired filter
882        let expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(true);
883        let page = repo
884            .user_registration_token()
885            .list(expired_filter, Pagination::first(10))
886            .await
887            .unwrap();
888        assert_eq!(page.edges.len(), 1);
889        assert_eq!(page.edges[0].id, token3.id);
890
891        let not_expired_filter = UserRegistrationTokenFilter::new(clock.now()).with_expired(false);
892        let page = repo
893            .user_registration_token()
894            .list(not_expired_filter, Pagination::first(10))
895            .await
896            .unwrap();
897        assert_eq!(page.edges.len(), 3);
898
899        // Test is_revoked filter
900        let revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(true);
901        let page = repo
902            .user_registration_token()
903            .list(revoked_filter, Pagination::first(10))
904            .await
905            .unwrap();
906        assert_eq!(page.edges.len(), 1);
907        assert_eq!(page.edges[0].id, token4.id);
908
909        let not_revoked_filter = UserRegistrationTokenFilter::new(clock.now()).with_revoked(false);
910        let page = repo
911            .user_registration_token()
912            .list(not_revoked_filter, Pagination::first(10))
913            .await
914            .unwrap();
915        assert_eq!(page.edges.len(), 3);
916
917        // Test is_valid filter
918        let valid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(true);
919        let page = repo
920            .user_registration_token()
921            .list(valid_filter, Pagination::first(10))
922            .await
923            .unwrap();
924        assert_eq!(page.edges.len(), 2);
925
926        let invalid_filter = UserRegistrationTokenFilter::new(clock.now()).with_valid(false);
927        let page = repo
928            .user_registration_token()
929            .list(invalid_filter, Pagination::first(10))
930            .await
931            .unwrap();
932        assert_eq!(page.edges.len(), 2);
933
934        // Test combined filters
935        let combined_filter = UserRegistrationTokenFilter::new(clock.now())
936            .with_been_used(false)
937            .with_revoked(true);
938        let page = repo
939            .user_registration_token()
940            .list(combined_filter, Pagination::first(10))
941            .await
942            .unwrap();
943        assert_eq!(page.edges.len(), 1);
944        assert_eq!(page.edges[0].id, token4.id);
945
946        // Test pagination
947        let page = repo
948            .user_registration_token()
949            .list(empty_filter, Pagination::first(2))
950            .await
951            .unwrap();
952        assert_eq!(page.edges.len(), 2);
953    }
954}