Phase 2: analytics rollups, geo breakdown, event feed (backend)
CI / policy (push) Successful in 4s
CI / backend (push) Failing after 1m13s
CI / mobile (push) Successful in 19s
CI / profile (push) Successful in 48s

- Idempotent hourly rollup (run_rollup, INSERT ... ON CONFLICT DO UPDATE into
  analytics_rollups_hourly) + Tokio background task (startup then hourly)
- GET /v1/cards/{id}/analytics/geo — country-grouped visit counts
- GeoResolver trait: real MaxMind under the `geoip` feature, no-op default;
  raw IP resolved then discarded (only hash + coarse country/city persisted)
- analytics_events now store country/city; rollups read query for the dashboard

2 new tests (geo grouping, rollup aggregation + idempotency); 75 backend tests.
fmt + clippy clean; geoip feature compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 11:34:52 -05:00
co-authored by Claude Opus 4.8
parent e66813ef58
commit 4770efd861
14 changed files with 419 additions and 11 deletions
@@ -21,6 +21,7 @@ use tower::ServiceExt;
use cardclaws_api::assets::InMemoryStore;
use cardclaws_api::cache::InMemoryCache;
use cardclaws_api::email::CapturingEmailSender;
use cardclaws_api::geo::{GeoLocation, GeoResolver};
use cardclaws_api::{build_router, AppState};
use cardclaws_auth::apple::{AppleAuthError, AppleJwks, JwkProvider};
use cardclaws_auth::JwtKeys;
@@ -40,10 +41,31 @@ impl JwkProvider for NoopApple {
}
}
/// Deterministic geo resolver for tests: any non-empty IP resolves to the US so
/// the geo endpoint can be exercised without a MaxMind database.
struct FakeGeo;
impl GeoResolver for FakeGeo {
fn resolve(&self, ip: &str) -> GeoLocation {
if ip.is_empty() {
GeoLocation {
country: None,
city: None,
}
} else {
GeoLocation {
country: Some("US".to_string()),
city: Some("San Francisco".to_string()),
}
}
}
}
pub struct TestApp {
pub router: Router,
pub email: Arc<CapturingEmailSender>,
pub assets: Arc<InMemoryStore>,
pub db: cardclaws_db::Db,
}
/// Returns `None` when no test DB is configured (test should early-return).
@@ -54,11 +76,13 @@ pub async fn try_setup() -> Option<TestApp> {
let email = Arc::new(CapturingEmailSender::default());
let assets = Arc::new(InMemoryStore::default());
let db_handle = db.clone();
let state = AppState {
db,
cache: Arc::new(InMemoryCache::default()),
email: email.clone(),
assets: assets.clone(),
geo: Arc::new(FakeGeo),
jwt: JwtKeys::new("test-jwt-secret"),
apple: Arc::new(NoopApple),
apple_audience: "com.cardclaws.test".into(),
@@ -80,6 +104,7 @@ pub async fn try_setup() -> Option<TestApp> {
router: build_router(state),
email,
assets,
db: db_handle,
})
}
@@ -207,6 +232,37 @@ impl TestApp {
(status, content_type, bytes)
}
/// Like `request` but with extra request headers (e.g. `x-forwarded-for` to
/// exercise the geo path).
pub async fn request_with_headers(
&self,
method: &str,
path: &str,
token: Option<&str>,
body: Option<Value>,
headers: &[(&str, &str)],
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(t) = token {
builder = builder.header("authorization", format!("Bearer {t}"));
}
for (k, v) in headers {
builder = builder.header(*k, *v);
}
let req = match body {
Some(b) => builder
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&b).unwrap()))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
let resp = self.router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(status, json)
}
/// GET a path without following redirects; returns (status, Location header).
pub async fn get_redirect(&self, path: &str) -> (StatusCode, Option<String>) {
let req = Request::builder()