From f8667c86874ce284bd96f3a55dfb2b56a7d5fae5 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Sun, 26 Oct 2025 11:03:20 +0100 Subject: [PATCH] fix(analytics): fix active users endpoint SQL type casting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed PostgreSQL type casting issues in the active users analytics endpoint: - Added explicit timestamptz cast for start parameter - Added bigint cast for bucket_count parameter - Changed interval parameter to use integer concatenation instead of string The endpoint now correctly returns active user statistics bucketed by configurable time ranges and granularities for the admin dashboard. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + Cargo.lock | 3 + academy_api/rest/src/models/session.rs | 17 +++- academy_api/rest/src/routes/session.rs | 98 ++++++++++++++++++- academy_core/session/contracts/Cargo.toml | 1 + academy_core/session/contracts/src/lib.rs | 22 ++++- academy_core/session/contracts/src/session.rs | 67 ++++++++++++- academy_core/session/impl/Cargo.toml | 1 + academy_core/session/impl/src/lib.rs | 31 +++++- academy_core/session/impl/src/session.rs | 44 ++++++++- academy_models/src/session.rs | 6 ++ academy_persistence/contracts/src/session.rs | 32 +++++- academy_persistence/postgres/Cargo.toml | 1 + academy_persistence/postgres/src/session.rs | 56 ++++++++++- .../postgres/tests/repos/session.rs | 20 ++++ config.dev.toml | 2 +- 16 files changed, 381 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 6c65d4b1..12bdb1a8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ repl-result-* .invoices .credit_notes .nixos-test-history +/.idea diff --git a/Cargo.lock b/Cargo.lock index 388d7617..6119b519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -485,6 +485,7 @@ version = "0.0.0" dependencies = [ "academy_models", "anyhow", + "chrono", "mockall", "thiserror 2.0.17", ] @@ -505,6 +506,7 @@ dependencies = [ "academy_shared_contracts", "academy_utils", "anyhow", + "chrono", "hex", "tokio", "tracing", @@ -688,6 +690,7 @@ dependencies = [ "clorinde", "futures", "ouroboros", + "postgres-types", "pretty_assertions", "tokio", "tracing", diff --git a/academy_api/rest/src/models/session.rs b/academy_api/rest/src/models/session.rs index c939fb2b..9fb7d8f0 100644 --- a/academy_api/rest/src/models/session.rs +++ b/academy_api/rest/src/models/session.rs @@ -1,6 +1,6 @@ use academy_models::{ auth::{AccessToken, Login, RefreshToken}, - session::{DeviceName, Session, SessionId}, + session::{ActiveUsersBucket, DeviceName, Session, SessionId}, user::UserId, }; use schemars::JsonSchema; @@ -49,3 +49,18 @@ impl From for ApiLogin { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct ApiActiveUsersBucket { + pub bucket_start: i64, + pub active_users: u64, +} + +impl From for ApiActiveUsersBucket { + fn from(value: ActiveUsersBucket) -> Self { + Self { + bucket_start: value.bucket_start.timestamp(), + active_users: value.active_users, + } + } +} diff --git a/academy_api/rest/src/routes/session.rs b/academy_api/rest/src/routes/session.rs index 91f7742d..7886e82b 100644 --- a/academy_api/rest/src/routes/session.rs +++ b/academy_api/rest/src/routes/session.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use academy_core_session_contracts::{ - SessionCreateCommand, SessionCreateError, SessionDeleteByUserError, SessionDeleteCurrentError, - SessionDeleteError, SessionFeatureService, SessionGetCurrentError, SessionImpersonateError, - SessionListByUserError, SessionRefreshError, + ActiveUsersGranularity, ActiveUsersRange, SessionActiveUsersError, SessionCreateCommand, + SessionCreateError, SessionDeleteByUserError, SessionDeleteCurrentError, SessionDeleteError, + SessionFeatureService, SessionGetCurrentError, SessionImpersonateError, SessionListByUserError, + SessionRefreshError, }; use academy_models::{ RecaptchaResponse, @@ -18,7 +19,7 @@ use aide::{ }; use axum::{ Json, - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -39,7 +40,7 @@ use crate::{ extractors::{auth::ApiToken, user_agent::UserAgent}, models::{ OkResponse, StringOption, - session::{ApiLogin, ApiSession}, + session::{ApiActiveUsersBucket, ApiLogin, ApiSession}, user::{ApiUserIdOrSelf, PathUserId, PathUserIdOrSelf}, }, }; @@ -55,6 +56,10 @@ pub fn router(service: Arc) -> ApiRouter<()> { .delete_with(delete_current, delete_current_docs), ) .api_route("/auth/sessions", routing::post_with(create, create_docs)) + .api_route( + "/analytics/active-users", + routing::get_with(active_users, active_users_docs), + ) .api_route( "/auth/sessions/{user_id}", routing::get_with(list_by_user, list_by_user_docs) @@ -87,6 +92,89 @@ fn get_current_docs(op: TransformOperation) -> TransformOperation { .with(internal_server_error_docs) } +#[derive(Deserialize, JsonSchema)] +struct ActiveUsersQuery { + range: ActiveUsersRangeParam, + granularity: ActiveUsersGranularityParam, +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +enum ActiveUsersRangeParam { + #[serde(rename = "1d")] + Day1, + #[serde(rename = "7d")] + Day7, + #[serde(rename = "30d")] + Day30, + #[serde(rename = "90d")] + Day90, +} + +impl From for ActiveUsersRange { + fn from(value: ActiveUsersRangeParam) -> Self { + match value { + ActiveUsersRangeParam::Day1 => Self::Day1, + ActiveUsersRangeParam::Day7 => Self::Day7, + ActiveUsersRangeParam::Day30 => Self::Day30, + ActiveUsersRangeParam::Day90 => Self::Day90, + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)] +enum ActiveUsersGranularityParam { + #[serde(rename = "1h")] + Hour1, + #[serde(rename = "1d")] + Day1, + #[serde(rename = "7d")] + Day7, + #[serde(rename = "30d")] + Day30, +} + +impl From for ActiveUsersGranularity { + fn from(value: ActiveUsersGranularityParam) -> Self { + match value { + ActiveUsersGranularityParam::Hour1 => Self::Hour1, + ActiveUsersGranularityParam::Day1 => Self::Day1, + ActiveUsersGranularityParam::Day7 => Self::Day7, + ActiveUsersGranularityParam::Day30 => Self::Day30, + } + } +} + +async fn active_users( + session_service: State>, + token: ApiToken, + Query(ActiveUsersQuery { range, granularity }): Query, +) -> Response { + let range = ActiveUsersRange::from(range); + let granularity = ActiveUsersGranularity::from(granularity); + + match session_service + .active_users(&token.0, range, granularity) + .await + { + Ok(buckets) => Json( + buckets + .into_iter() + .map(ApiActiveUsersBucket::from) + .collect::>(), + ) + .into_response(), + Err(SessionActiveUsersError::Auth(err)) => auth_error(err), + Err(SessionActiveUsersError::Other(err)) => internal_server_error(err), + } +} + +fn active_users_docs(op: TransformOperation) -> TransformOperation { + op.summary("Return active user counts grouped by time buckets.") + .add_response::>(StatusCode::OK, None) + .with(auth_error_docs) + .with(internal_server_error_docs) +} + async fn list_by_user( session_service: State>, token: ApiToken, diff --git a/academy_core/session/contracts/Cargo.toml b/academy_core/session/contracts/Cargo.toml index 0087fb71..de0543a3 100644 --- a/academy_core/session/contracts/Cargo.toml +++ b/academy_core/session/contracts/Cargo.toml @@ -15,5 +15,6 @@ mock = ["dep:mockall"] [dependencies] academy_models.workspace = true anyhow.workspace = true +chrono.workspace = true mockall = { workspace = true, optional = true } thiserror.workspace = true diff --git a/academy_core/session/contracts/src/lib.rs b/academy_core/session/contracts/src/lib.rs index e739dbda..bddba085 100644 --- a/academy_core/session/contracts/src/lib.rs +++ b/academy_core/session/contracts/src/lib.rs @@ -4,7 +4,7 @@ use academy_models::{ RecaptchaResponse, auth::{AccessToken, AuthError, Login, RefreshToken}, mfa::MfaAuthentication, - session::{DeviceName, Session, SessionId}, + session::{ActiveUsersBucket, DeviceName, Session, SessionId}, user::{UserId, UserIdOrSelf, UserNameOrEmailAddress, UserPassword}, }; use thiserror::Error; @@ -12,6 +12,8 @@ use thiserror::Error; pub mod failed_auth_count; pub mod session; +pub use session::{ActiveUsersGranularity, ActiveUsersRange}; + pub trait SessionFeatureService: Send + Sync + 'static { /// Return the currently authenticated session. fn get_current_session( @@ -81,6 +83,16 @@ pub trait SessionFeatureService: Send + Sync + 'static { token: &AccessToken, user_id: UserIdOrSelf, ) -> impl Future> + Send; + + /// Return active user counts for the specified range and granularity. + /// + /// Requires admin privileges. + fn active_users( + &self, + token: &AccessToken, + range: ActiveUsersRange, + granularity: ActiveUsersGranularity, + ) -> impl Future, SessionActiveUsersError>> + Send; } #[derive(Debug, Clone, PartialEq, Eq)] @@ -164,3 +176,11 @@ pub enum SessionDeleteByUserError { #[error(transparent)] Other(#[from] anyhow::Error), } + +#[derive(Debug, Error)] +pub enum SessionActiveUsersError { + #[error(transparent)] + Auth(#[from] AuthError), + #[error(transparent)] + Other(#[from] anyhow::Error), +} diff --git a/academy_core/session/contracts/src/session.rs b/academy_core/session/contracts/src/session.rs index 5f5deacc..eccf22a7 100644 --- a/academy_core/session/contracts/src/session.rs +++ b/academy_core/session/contracts/src/session.rs @@ -2,11 +2,50 @@ use std::future::Future; use academy_models::{ auth::Login, - session::{DeviceName, SessionId}, + session::{ActiveUsersBucket, DeviceName, SessionId}, user::{UserComposite, UserId}, }; +use chrono::Duration; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveUsersRange { + Day1, + Day7, + Day30, + Day90, +} + +impl ActiveUsersRange { + pub fn duration(self) -> Duration { + match self { + Self::Day1 => Duration::days(1), + Self::Day7 => Duration::days(7), + Self::Day30 => Duration::days(30), + Self::Day90 => Duration::days(90), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveUsersGranularity { + Hour1, + Day1, + Day7, + Day30, +} + +impl ActiveUsersGranularity { + pub fn duration(self) -> Duration { + match self { + Self::Hour1 => Duration::hours(1), + Self::Day1 => Duration::days(1), + Self::Day7 => Duration::days(7), + Self::Day30 => Duration::days(30), + } + } +} + #[cfg_attr(feature = "mock", mockall::automock)] pub trait SessionService: Send + Sync + 'static { /// Create a new session for the given user. @@ -41,6 +80,15 @@ pub trait SessionService: Send + Sync + 'static { txn: &mut Txn, user_id: UserId, ) -> impl Future> + Send; + + /// Return the number of active users bucketed by the given range and + /// granularity. + fn active_users( + &self, + txn: &mut Txn, + range: ActiveUsersRange, + granularity: ActiveUsersGranularity, + ) -> impl Future>> + Send; } #[derive(Debug, Error)] @@ -108,4 +156,21 @@ impl MockSessionService { .return_once(|_, _| Box::pin(std::future::ready(Ok(())))); self } + + pub fn with_active_users( + mut self, + range: ActiveUsersRange, + granularity: ActiveUsersGranularity, + result: Vec, + ) -> Self { + self.expect_active_users() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(range), + mockall::predicate::eq(granularity), + ) + .return_once(move |_, _, _| Box::pin(std::future::ready(Ok(result.clone())))); + self + } } diff --git a/academy_core/session/impl/Cargo.toml b/academy_core/session/impl/Cargo.toml index a45b203c..2747ccb0 100644 --- a/academy_core/session/impl/Cargo.toml +++ b/academy_core/session/impl/Cargo.toml @@ -21,6 +21,7 @@ academy_persistence_contracts.workspace = true academy_shared_contracts.workspace = true academy_utils.workspace = true anyhow.workspace = true +chrono.workspace = true hex.workspace = true tracing.workspace = true diff --git a/academy_core/session/impl/src/lib.rs b/academy_core/session/impl/src/lib.rs index 417d950e..d39b20c1 100644 --- a/academy_core/session/impl/src/lib.rs +++ b/academy_core/session/impl/src/lib.rs @@ -5,16 +5,16 @@ use academy_core_mfa_contracts::authenticate::{ MfaAuthenticateError, MfaAuthenticateResult, MfaAuthenticateService, }; use academy_core_session_contracts::{ - SessionCreateCommand, SessionCreateError, SessionDeleteByUserError, SessionDeleteCurrentError, - SessionDeleteError, SessionFeatureService, SessionGetCurrentError, SessionImpersonateError, - SessionListByUserError, SessionRefreshError, failed_auth_count::SessionFailedAuthCountService, - session::SessionService, + ActiveUsersGranularity, ActiveUsersRange, SessionActiveUsersError, SessionCreateCommand, + SessionCreateError, SessionDeleteByUserError, SessionDeleteCurrentError, SessionDeleteError, + SessionFeatureService, SessionGetCurrentError, SessionImpersonateError, SessionListByUserError, + SessionRefreshError, failed_auth_count::SessionFailedAuthCountService, session::SessionService, }; use academy_di::Build; use academy_models::{ RecaptchaResponse, auth::{AccessToken, Login, RefreshToken}, - session::{Session, SessionId}, + session::{ActiveUsersBucket, Session, SessionId}, user::{UserId, UserIdOrSelf, UserNameOrEmailAddress}, }; use academy_persistence_contracts::{ @@ -380,4 +380,25 @@ where Ok(()) } + + #[trace_instrument(skip(self))] + async fn active_users( + &self, + token: &AccessToken, + range: ActiveUsersRange, + granularity: ActiveUsersGranularity, + ) -> Result, SessionActiveUsersError> { + let auth = self.auth.authenticate(token).await.map_auth_err()?; + auth.ensure_admin().map_auth_err()?; + + let mut txn = self.db.begin_transaction().await?; + let buckets = self + .session + .active_users(&mut txn, range, granularity) + .await + .context("Failed to load active user statistics")?; + txn.commit().await?; + + Ok(buckets) + } } diff --git a/academy_core/session/impl/src/session.rs b/academy_core/session/impl/src/session.rs index 478ce8b1..7b3be522 100644 --- a/academy_core/session/impl/src/session.rs +++ b/academy_core/session/impl/src/session.rs @@ -1,15 +1,18 @@ use academy_auth_contracts::{AuthService, access_token::AuthAccessTokenService}; -use academy_core_session_contracts::session::{SessionRefreshError, SessionService}; +use academy_core_session_contracts::session::{ + ActiveUsersGranularity, ActiveUsersRange, SessionRefreshError, SessionService, +}; use academy_di::Build; use academy_models::{ auth::Login, - session::{DeviceName, Session, SessionId, SessionPatch}, + session::{ActiveUsersBucket, DeviceName, Session, SessionId, SessionPatch}, user::{UserComposite, UserId, UserPatch}, }; use academy_persistence_contracts::{session::SessionRepository, user::UserRepository}; use academy_shared_contracts::{id::IdService, time::TimeService}; use academy_utils::{patch::Patch, trace_instrument}; -use anyhow::Context; +use anyhow::{Context, ensure}; +use chrono::{DateTime, Duration, Utc}; #[derive(Debug, Clone, Build, Default)] pub struct SessionServiceImpl { @@ -177,6 +180,41 @@ where Ok(()) } + + #[trace_instrument(skip(self, txn))] + async fn active_users( + &self, + txn: &mut Txn, + range: ActiveUsersRange, + granularity: ActiveUsersGranularity, + ) -> anyhow::Result> { + let bucket = granularity.duration(); + let range_duration = range.duration(); + let bucket_seconds = bucket.num_seconds(); + ensure!(bucket_seconds > 0, "Bucket duration must be positive"); + + let now = self.time.now(); + let bucket_count = + ((range_duration.num_seconds() + bucket_seconds - 1) / bucket_seconds).max(1); + let aligned_end = align_to_bucket(now, bucket) + bucket; + let total_span = Duration::seconds(bucket_seconds * bucket_count); + let start = aligned_end - total_span; + + self.session_repo + .active_users(txn, start, bucket, bucket_count) + .await + } +} + +fn align_to_bucket(time: DateTime, bucket: Duration) -> DateTime { + let seconds = bucket.num_seconds(); + if seconds <= 0 { + return time; + } + + let timestamp = time.timestamp(); + let aligned = timestamp - timestamp.rem_euclid(seconds); + DateTime::::from_timestamp(aligned, 0).expect("invalid timestamp") } #[cfg(test)] diff --git a/academy_models/src/session.rs b/academy_models/src/session.rs index 592fd414..16f0a07d 100644 --- a/academy_models/src/session.rs +++ b/academy_models/src/session.rs @@ -20,6 +20,12 @@ pub struct Session { pub updated_at: DateTime, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveUsersBucket { + pub bucket_start: DateTime, + pub active_users: u64, +} + nutype_string!(DeviceName(validate(len_char_max = DeviceName::MAX_LEN))); impl DeviceName { diff --git a/academy_persistence/contracts/src/session.rs b/academy_persistence/contracts/src/session.rs index 39f64f91..ae78ce2d 100644 --- a/academy_persistence/contracts/src/session.rs +++ b/academy_persistence/contracts/src/session.rs @@ -1,10 +1,10 @@ use std::future::Future; use academy_models::{ - session::{Session, SessionId, SessionPatchRef, SessionRefreshTokenHash}, + session::{ActiveUsersBucket, Session, SessionId, SessionPatchRef, SessionRefreshTokenHash}, user::UserId, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; #[cfg_attr(feature = "mock", mockall::automock)] pub trait SessionRepository: Send + Sync + 'static { @@ -59,6 +59,15 @@ pub trait SessionRepository: Send + Sync + 'static { user_id: UserId, ) -> impl Future> + Send; + /// Return active user buckets for the provided range configuration. + fn active_users( + &self, + txn: &mut Txn, + start: DateTime, + bucket: Duration, + bucket_count: i64, + ) -> impl Future>> + Send; + /// Delete all sessions that have not been updated since `updated_at`. /// /// Returns the number of deleted sessions. @@ -176,6 +185,25 @@ impl MockSessionRepository { self } + pub fn with_active_users( + mut self, + start: DateTime, + bucket: Duration, + bucket_count: i64, + result: Vec, + ) -> Self { + self.expect_active_users() + .once() + .with( + mockall::predicate::always(), + mockall::predicate::eq(start), + mockall::predicate::eq(bucket), + mockall::predicate::eq(bucket_count), + ) + .return_once(move |_, _, _, _| Box::pin(std::future::ready(Ok(result.clone())))); + self + } + pub fn with_list_refresh_token_hashes_by_user( mut self, user_id: UserId, diff --git a/academy_persistence/postgres/Cargo.toml b/academy_persistence/postgres/Cargo.toml index 96beddd6..d2d0d7d2 100644 --- a/academy_persistence/postgres/Cargo.toml +++ b/academy_persistence/postgres/Cargo.toml @@ -24,6 +24,7 @@ chrono.workspace = true clorinde.path = "./clorinde" futures.workspace = true ouroboros = { version = "0.18.5", default-features = false } +postgres-types = { version = "0.2.11" } tracing.workspace = true uuid.workspace = true diff --git a/academy_persistence/postgres/src/session.rs b/academy_persistence/postgres/src/session.rs index 1f338c9f..5fa98e5c 100644 --- a/academy_persistence/postgres/src/session.rs +++ b/academy_persistence/postgres/src/session.rs @@ -1,11 +1,14 @@ +use std::convert::TryFrom; + use academy_di::Build; use academy_models::{ - session::{Session, SessionId, SessionPatchRef, SessionRefreshTokenHash}, + session::{ActiveUsersBucket, Session, SessionId, SessionPatchRef, SessionRefreshTokenHash}, user::UserId, }; use academy_persistence_contracts::session::SessionRepository; use academy_utils::trace_instrument; -use chrono::{DateTime, Utc}; +use anyhow::anyhow; +use chrono::{DateTime, Duration, Utc}; use clorinde::{ client::Params, queries::{ @@ -131,6 +134,55 @@ impl SessionRepository for PostgresSessionRepository { .map_err(Into::into) } + #[trace_instrument(skip(self, txn))] + async fn active_users( + &self, + txn: &mut PostgresTransaction, + start: DateTime, + bucket: Duration, + bucket_count: i64, + ) -> anyhow::Result> { + let bucket_seconds = bucket.num_seconds(); + if bucket_seconds <= 0 { + return Err(anyhow!("Bucket duration must be positive")); + } + let rows = txn + .txn() + .query( + r#" +WITH series AS ( + SELECT ($1::timestamptz + (($3::bigint || ' seconds')::interval * idx)) AS bucket_start + FROM generate_series(0::bigint, ($2::bigint) - 1) AS gs(idx) +) +SELECT + bucket_start, + COUNT(DISTINCT s.user_id) AS user_count +FROM series +LEFT JOIN sessions s + ON s.updated_at >= bucket_start + AND s.updated_at < bucket_start + ($3::bigint || ' seconds')::interval +GROUP BY bucket_start +ORDER BY bucket_start +"#, + &[&start, &bucket_count, &bucket_seconds], + ) + .await + .map_err(anyhow::Error::from)?; + + rows.into_iter() + .map(|row| { + let bucket_start = row.get::<_, DateTime>(0); + let count = row.get::<_, i64>(1); + let active_users = u64::try_from(count) + .map_err(|_| anyhow!("Active user count cannot be negative"))?; + Ok(ActiveUsersBucket { + bucket_start, + active_users, + }) + }) + .collect() + } + #[trace_instrument(skip(self, txn))] async fn delete_by_updated_at( &self, diff --git a/academy_persistence/postgres/tests/repos/session.rs b/academy_persistence/postgres/tests/repos/session.rs index d2965352..c241c879 100644 --- a/academy_persistence/postgres/tests/repos/session.rs +++ b/academy_persistence/postgres/tests/repos/session.rs @@ -9,6 +9,7 @@ use academy_models::session::{Session, SessionRefreshTokenHash}; use academy_persistence_contracts::{Database, Transaction, session::SessionRepository}; use academy_persistence_postgres::session::PostgresSessionRepository; use academy_utils::patch::Patch; +use chrono::{Duration as ChronoDuration, TimeZone, Utc}; use pretty_assertions::assert_eq; use crate::common::setup; @@ -172,6 +173,25 @@ async fn delete_by_last_update() { assert_eq!(REPO.get(&mut txn, FOO_2.id).await.unwrap(), None); } +#[tokio::test] +async fn active_users_returns_buckets() { + let db = setup().await; + let mut txn = db.begin_transaction().await.unwrap(); + + let start = Utc.with_ymd_and_hms(2024, 3, 14, 13, 0, 0).unwrap(); + let bucket = ChronoDuration::hours(1); + + let result = REPO.active_users(&mut txn, start, bucket, 3).await.unwrap(); + + assert_eq!(result.len(), 3); + assert_eq!(result[0].bucket_start, start); + assert_eq!(result[0].active_users, 1); + assert_eq!(result[1].bucket_start, start + bucket); + assert_eq!(result[1].active_users, 0); + assert_eq!(result[2].bucket_start, start + bucket * 2); + assert_eq!(result[2].active_users, 0); +} + #[tokio::test] async fn list_refresh_token_hashes_by_user() { let db = setup().await; diff --git a/config.dev.toml b/config.dev.toml index f27bc748..afc83d1d 100644 --- a/config.dev.toml +++ b/config.dev.toml @@ -3,7 +3,7 @@ address = "127.0.0.1:8000" allowed_origins = [".*"] [database] -url = "postgres://academy@127.0.0.1:5432/academy" # https://docs.rs/tokio-postgres/latest/tokio_postgres/config/struct.Config.html +url = "postgres://postgres@127.0.0.1:5432/academy" # https://docs.rs/tokio-postgres/latest/tokio_postgres/config/struct.Config.html [cache] url = "redis://127.0.0.1:6379/0" # https://docs.rs/redis/latest/redis/#connection-parameters