keepsake-sqlx 0.5.1

SQLx adapter for keepsake lifecycle storage
Documentation
use super::support::*;

use std::sync::Arc;

#[tokio::test]
async fn active_relation_source_accepts_generic_and_erased_sqlx_repository() -> TestResult<()> {
    fn assert_generic<S>(_: &S)
    where
        S: ActiveRelationSource<Error = RepositoryError>,
    {
    }

    let pool = PgPoolOptions::new().connect_lazy("postgres://keepsake@example.invalid/keepsake")?;
    let repo = KeepsakeRepository::new(pool);

    assert_generic(&repo);
    let erased: Arc<dyn DynActiveRelationSource<Error = RepositoryError>> = Arc::new(repo);
    drop(erased);
    Ok(())
}

#[tokio::test]
#[ignore = "requires docker postgres; run `make test-db`"]
async fn active_membership_scan_uses_keyset_pagination() -> TestResult<()> {
    let repo = repo().await?;
    let relation = timed_relation(&repo, "membership-pages", "2026-01-02T00:00:00Z").await?;
    let subjects = [
        SubjectRef::new("user", format!("a_{}", Uuid::now_v7()))?,
        SubjectRef::new("user", format!("b_{}", Uuid::now_v7()))?,
        SubjectRef::new("user", format!("c_{}", Uuid::now_v7()))?,
    ];

    let applied_b = apply_at(&repo, &subjects[1], relation.id, "2026-01-01T00:00:00Z").await?;
    let applied_a = apply_at(&repo, &subjects[0], relation.id, "2026-01-01T00:00:00Z").await?;
    let applied_c = apply_at(&repo, &subjects[2], relation.id, "2026-01-01T00:00:00Z").await?;

    let first_page = repo.active_membership_scan(relation.id, 2).await?;
    let cursor = MembershipCursor::after(&first_page[1]);
    let second_page = repo
        .active_membership_scan_after(relation.id, Some(&cursor), 2)
        .await?;
    let empty_page = repo
        .active_membership_scan_after(
            relation.id,
            Some(&MembershipCursor::after(&second_page[0])),
            2,
        )
        .await?;

    assert_eq!(
        first_page
            .iter()
            .map(keepsake::Keepsake::id)
            .collect::<Vec<Uuid>>(),
        vec![applied_a.keepsake.id(), applied_b.keepsake.id()]
    );
    assert_eq!(second_page.len(), 1);
    assert_eq!(second_page[0].id(), applied_c.keepsake.id());
    assert!(empty_page.is_empty());
    Ok(())
}

#[tokio::test]
#[ignore = "requires docker postgres; run `make test-db`"]
async fn active_relations_for_subject_returns_joined_relation_definitions() -> TestResult<()> {
    let repo = repo().await?;
    let relation_a = timed_relation(&repo, "joined-a", "2026-01-02T00:00:00Z").await?;
    let relation_b = timed_relation(&repo, "joined-b", "2026-01-03T00:00:00Z").await?;
    let subject = SubjectRef::new("user", format!("joined_{}", Uuid::now_v7()))?;

    let applied_a = apply_at(&repo, &subject, relation_a.id, "2026-01-01T00:00:00Z").await?;
    let applied_b = apply_at(&repo, &subject, relation_b.id, "2026-01-01T00:00:00Z").await?;

    let active = repo.active_relations_for_subject(&subject).await?;

    assert_eq!(active.len(), 2);
    assert_eq!(
        active
            .iter()
            .map(|row| row.keepsake().id())
            .collect::<Vec<Uuid>>(),
        vec![applied_a.keepsake.id(), applied_b.keepsake.id()]
    );
    assert_eq!(active[0].relation(), &relation_a);
    assert_eq!(active[1].relation(), &relation_b);
    assert!(
        active
            .iter()
            .all(|row| row.keepsake().metadata().is_empty())
    );
    Ok(())
}

#[tokio::test]
#[ignore = "requires docker postgres; run `make test-db`"]
async fn active_relations_for_subject_by_ids_returns_requested_active_relations() -> TestResult<()>
{
    let repo = repo().await?;
    let relation_a = timed_relation(&repo, "ids-a", "2026-01-04T00:00:00Z").await?;
    let relation_b = timed_relation(&repo, "ids-b", "2026-01-05T00:00:00Z").await?;
    let disabled = timed_relation(&repo, "ids-disabled", "2026-01-06T00:00:00Z").await?;
    let revoked = timed_relation(&repo, "ids-revoked", "2026-01-07T00:00:00Z").await?;
    let expired = timed_relation(&repo, "ids-expired", "2026-01-02T00:00:00Z").await?;
    let subject = SubjectRef::new("user", format!("ids_{}", Uuid::now_v7()))?;

    let applied_a = apply_at(&repo, &subject, relation_a.id, "2026-01-01T00:00:00Z").await?;
    apply_at(&repo, &subject, relation_b.id, "2026-01-01T00:00:00Z").await?;
    let applied_disabled = apply_at(&repo, &subject, disabled.id, "2026-01-01T00:00:00Z").await?;
    let applied_revoked = apply_at(&repo, &subject, revoked.id, "2026-01-01T00:00:00Z").await?;
    let applied_expired = apply_at(&repo, &subject, expired.id, "2026-01-01T00:00:00Z").await?;

    assert!(set_relation_enabled(&repo, disabled.id, false).await?);
    assert!(revoke_at(&repo, applied_revoked.keepsake.id(), "2026-01-01T00:05:00Z").await?);
    assert_eq!(
        repo.expire_due_timed(ts("2026-01-03T00:00:00Z")?, 10)
            .await?,
        1
    );

    let requested = vec![
        relation_a.id,
        relation_a.id,
        disabled.id,
        revoked.id,
        expired.id,
        Uuid::now_v7(),
    ];
    let active = repo
        .active_relations_for_subject_by_ids(&subject, &requested)
        .await?;

    assert_eq!(
        active
            .iter()
            .map(|row| row.keepsake().id())
            .collect::<Vec<Uuid>>(),
        vec![applied_a.keepsake.id(), applied_disabled.keepsake.id()]
    );
    assert_eq!(active[0].relation(), &relation_a);
    assert!(!active[1].relation().enabled);
    assert_eq!(active[1].keepsake().id(), applied_disabled.keepsake.id());
    assert!(
        active
            .iter()
            .all(|row| row.keepsake().id() != applied_expired.keepsake.id())
    );
    Ok(())
}

#[tokio::test]
#[ignore = "requires docker postgres; run `make test-db`"]
async fn active_relations_for_subject_by_keys_returns_requested_active_relations() -> TestResult<()>
{
    let repo = repo().await?;
    let relation_a = timed_relation(&repo, "keyed-a", "2026-01-04T00:00:00Z").await?;
    let relation_b = timed_relation(&repo, "keyed-b", "2026-01-05T00:00:00Z").await?;
    let disabled = timed_relation(&repo, "keyed-disabled", "2026-01-06T00:00:00Z").await?;
    let revoked = timed_relation(&repo, "keyed-revoked", "2026-01-07T00:00:00Z").await?;
    let expired = timed_relation(&repo, "keyed-expired", "2026-01-02T00:00:00Z").await?;
    let subject = SubjectRef::new("user", format!("keyed_{}", Uuid::now_v7()))?;

    let applied_a = apply_at(&repo, &subject, relation_a.id, "2026-01-01T00:00:00Z").await?;
    apply_at(&repo, &subject, relation_b.id, "2026-01-01T00:00:00Z").await?;
    let applied_disabled = apply_at(&repo, &subject, disabled.id, "2026-01-01T00:00:00Z").await?;
    let applied_revoked = apply_at(&repo, &subject, revoked.id, "2026-01-01T00:00:00Z").await?;
    let applied_expired = apply_at(&repo, &subject, expired.id, "2026-01-01T00:00:00Z").await?;

    assert!(set_relation_enabled(&repo, disabled.id, false).await?);
    assert!(revoke_at(&repo, applied_revoked.keepsake.id(), "2026-01-01T00:05:00Z").await?);
    assert_eq!(
        repo.expire_due_timed(ts("2026-01-03T00:00:00Z")?, 10)
            .await?,
        1
    );

    let requested = vec![
        relation_a.key.clone(),
        relation_a.key.clone(),
        disabled.key.clone(),
        revoked.key.clone(),
        expired.key.clone(),
        RelationKey::new("tag", unique_key("keyed-missing"))?,
    ];
    let active = repo
        .active_relations_for_subject_by_keys(&subject, &requested)
        .await?;

    assert_eq!(
        active
            .iter()
            .map(|row| row.keepsake().id())
            .collect::<Vec<Uuid>>(),
        vec![applied_a.keepsake.id(), applied_disabled.keepsake.id()]
    );
    assert_eq!(active[0].relation(), &relation_a);
    assert!(!active[1].relation().enabled);
    assert_eq!(active[1].keepsake().id(), applied_disabled.keepsake.id());
    assert!(
        active
            .iter()
            .all(|row| row.keepsake().id() != applied_expired.keepsake.id())
    );
    Ok(())
}

#[tokio::test]
#[ignore = "requires docker postgres; run `make test-db`"]
async fn batch_queries_reject_invalid_limits() -> TestResult<()> {
    let repo = repo().await?;
    let relation = timed_relation(&repo, "invalid-limit", "2026-01-02T00:00:00Z").await?;

    let membership = repo.active_membership_scan(relation.id, 0).await;
    assert!(matches!(
        membership,
        Err(RepositoryError::InvalidLimit { limit: 0, .. })
    ));

    let due = repo.due_timed_expiry(ts("2026-01-03T00:00:00Z")?, -1).await;
    assert!(matches!(
        due,
        Err(RepositoryError::InvalidLimit { limit: -1, .. })
    ));

    let expire = repo
        .expire_due_timed(ts("2026-01-03T00:00:00Z")?, 10_001)
        .await;
    assert!(matches!(
        expire,
        Err(RepositoryError::InvalidLimit { limit: 10_001, .. })
    ));
    Ok(())
}