Phase 2: analytics rollups, geo breakdown, event feed (backend)
- 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:
co-authored by
Claude Opus 4.8
parent
e66813ef58
commit
4770efd861
@@ -103,6 +103,84 @@ async fn ingest_rejects_server_only_event_type() {
|
||||
assert_eq!(body["code"], "validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn geo_breakdown_groups_by_country() {
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, _) = create_and_publish(&app, &token).await;
|
||||
|
||||
// Two client events with a forwarded IP → FakeGeo resolves both to US.
|
||||
for _ in 0..2 {
|
||||
app.request_with_headers(
|
||||
"POST",
|
||||
"/v1/analytics/event",
|
||||
None,
|
||||
Some(json!({ "card_id": id, "event_type": "contact_save" })),
|
||||
&[("x-forwarded-for", "203.0.113.7")],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let (status, body) = app
|
||||
.request(
|
||||
"GET",
|
||||
&format!("/v1/cards/{id}/analytics/geo"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let rows = body.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["country"], "US");
|
||||
assert_eq!(rows[0]["visits"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hourly_rollup_aggregates_and_is_idempotent() {
|
||||
use cardclaws_db::queries::analytics;
|
||||
use uuid::Uuid;
|
||||
|
||||
let app = require_app!();
|
||||
let token = app.register_and_token().await;
|
||||
let (id, handle) = create_and_publish(&app, &token).await;
|
||||
let card_id = Uuid::parse_str(&id).unwrap();
|
||||
|
||||
// Generate two profile visits and one qr_scan (via a share resolve).
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
app.request("GET", &format!("/v1/cards/handle/{handle}"), None, None)
|
||||
.await;
|
||||
let (_, share) = app
|
||||
.request(
|
||||
"POST",
|
||||
&format!("/v1/cards/{id}/share"),
|
||||
Some(&token),
|
||||
Some(json!({"modality": "qr"})),
|
||||
)
|
||||
.await;
|
||||
app.get_redirect(&format!("/v1/s/{}", share["token"].as_str().unwrap()))
|
||||
.await;
|
||||
|
||||
let affected = analytics::run_rollup(&app.db).await.unwrap();
|
||||
assert!(affected >= 1);
|
||||
|
||||
let rollups = analytics::rollups(&app.db, card_id).await.unwrap();
|
||||
let totals = rollups
|
||||
.iter()
|
||||
.fold((0, 0), |(v, q), r| (v + r.visits, q + r.qr_scans));
|
||||
assert_eq!(totals.0, 2, "two profile visits rolled up");
|
||||
assert_eq!(totals.1, 1, "one qr scan rolled up");
|
||||
|
||||
// Re-running must not double-count (ON CONFLICT DO UPDATE).
|
||||
analytics::run_rollup(&app.db).await.unwrap();
|
||||
let rollups2 = analytics::rollups(&app.db, card_id).await.unwrap();
|
||||
let totals2 = rollups2
|
||||
.iter()
|
||||
.fold((0, 0), |(v, q), r| (v + r.visits, q + r.qr_scans));
|
||||
assert_eq!(totals, totals2, "rollup is idempotent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn analytics_summary_requires_ownership() {
|
||||
let app = require_app!();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user