Add analytics drill-down, visitor logs, exports and pagination
This commit is contained in:
1 parent
0e111d61ff
commit
84c48fa5c1
16 files changed
+1982
-8
No files matched your search
@@ -0,0 +1,156 @@
|
||||
use rusqlite::Connection;
|
||||
use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS};
|
||||
use bzod::db::analytics::{
|
||||
get_visits_schema_columns, get_target_visits_paginated,
|
||||
get_target_visits_all_in_memory, get_target_visit_total_filtered,
|
||||
parse_ua, clean_referrer, insert_visits_batch
|
||||
};
|
||||
use bzod::models::VisitRecord;
|
||||
|
||||
fn setup_analytics_db() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advanced_analytics_schema_columns() {
|
||||
let conn = setup_analytics_db();
|
||||
let columns = get_visits_schema_columns(&conn).unwrap();
|
||||
assert!(columns.contains("id"));
|
||||
assert!(columns.contains("target_type"));
|
||||
assert!(columns.contains("target_id"));
|
||||
assert!(columns.contains("timestamp"));
|
||||
assert!(columns.contains("ip_address"));
|
||||
assert!(columns.contains("user_agent"));
|
||||
assert!(columns.contains("referer"));
|
||||
assert!(columns.contains("country"));
|
||||
assert!(columns.contains("status_code"));
|
||||
// Verify UTM columns don't exist by default
|
||||
assert!(!columns.contains("utm_source"));
|
||||
assert!(!columns.contains("utm_campaign"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advanced_analytics_pagination_and_sorting() {
|
||||
let mut conn = setup_analytics_db();
|
||||
let target_uuid = "target-123";
|
||||
|
||||
// Insert 55 visits to test pagination bounds (ANALYTICS_PAGE_SIZE = 50)
|
||||
let mut records = Vec::new();
|
||||
for i in 1..=55 {
|
||||
// Stagger timestamps so they are ordered deterministically
|
||||
let time_str = format!("2026-06-17T12:00:{:02}Z", i);
|
||||
records.push(VisitRecord {
|
||||
id: format!("visit-{}", i),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: time_str,
|
||||
ip_address: "1.1.1.1".to_string(),
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
});
|
||||
}
|
||||
insert_visits_batch(&mut conn, &records).unwrap();
|
||||
|
||||
// Verify total count
|
||||
let total = get_target_visit_total_filtered(&conn, "url", target_uuid, None, None).unwrap();
|
||||
assert_eq!(total, 55);
|
||||
|
||||
// Page 1 (limit 50, offset 0)
|
||||
let page1 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 0, None, None).unwrap();
|
||||
assert_eq!(page1.len(), 50);
|
||||
// Ordered DESC: first record returned should be the latest (visit-55)
|
||||
assert_eq!(page1[0].id, "visit-55");
|
||||
assert_eq!(page1[49].id, "visit-6");
|
||||
|
||||
// Page 2 (limit 50, offset 50)
|
||||
let page2 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 50, None, None).unwrap();
|
||||
assert_eq!(page2.len(), 5);
|
||||
assert_eq!(page2[0].id, "visit-5");
|
||||
assert_eq!(page2[4].id, "visit-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advanced_analytics_date_filtering() {
|
||||
let mut conn = setup_analytics_db();
|
||||
let target_uuid = "target-456";
|
||||
|
||||
// Insert records across different days
|
||||
let records = vec![
|
||||
VisitRecord {
|
||||
id: "v1".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: "2026-06-01T10:00:00Z".to_string(),
|
||||
ip_address: "1.1.1.1".to_string(),
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "v2".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: "2026-06-15T10:00:00Z".to_string(),
|
||||
ip_address: "1.1.1.1".to_string(),
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "v3".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: "2026-06-30T10:00:00Z".to_string(),
|
||||
ip_address: "1.1.1.1".to_string(),
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
];
|
||||
insert_visits_batch(&mut conn, &records).unwrap();
|
||||
|
||||
// 1. Filter with date_from = "2026-06-15" (inclusive) -> should get v2 and v3
|
||||
let count1 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap();
|
||||
assert_eq!(count1, 2);
|
||||
let results1 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap();
|
||||
assert_eq!(results1.len(), 2);
|
||||
assert_eq!(results1[0].id, "v3"); // DESC sorting
|
||||
assert_eq!(results1[1].id, "v2");
|
||||
|
||||
// 2. Filter with date_to = "2026-06-15" (inclusive) -> should get v1 and v2
|
||||
let count2 = get_target_visit_total_filtered(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap();
|
||||
assert_eq!(count2, 2);
|
||||
let results2 = get_target_visits_all_in_memory(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap();
|
||||
assert_eq!(results2.len(), 2);
|
||||
assert_eq!(results2[0].id, "v2");
|
||||
assert_eq!(results2[1].id, "v1");
|
||||
|
||||
// 3. Filter with both date_from and date_to = "2026-06-15"
|
||||
let count3 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap();
|
||||
assert_eq!(count3, 1);
|
||||
let results3 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap();
|
||||
assert_eq!(results3.len(), 1);
|
||||
assert_eq!(results3[0].id, "v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ua_and_referrer_helpers() {
|
||||
let (browser, os, device) = parse_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/605.1.15");
|
||||
assert_eq!(browser, "Safari");
|
||||
assert_eq!(os, "iOS");
|
||||
assert_eq!(device, "Mobile");
|
||||
|
||||
let referrer = clean_referrer("https://news.ycombinator.com/item?id=12345");
|
||||
assert_eq!(referrer, "news.ycombinator.com");
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
use rusqlite::Connection;
|
||||
use chrono::Utc;
|
||||
use bzod::db::migrations::{run_migrations, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS};
|
||||
use bzod::db::content::{create_url_extended, get_url_count_by_tag, list_urls, get_url_counts};
|
||||
use bzod::db::analytics::{
|
||||
get_target_visit_count, get_target_unique_visitors, get_monthly_clicks_trend,
|
||||
insert_visits_batch,
|
||||
};
|
||||
use bzod::models::VisitRecord;
|
||||
|
||||
fn setup_content_db() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "content", CONTENT_MIGRATIONS, None).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn setup_analytics_db() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_url_count_by_tag() {
|
||||
let conn = setup_content_db();
|
||||
|
||||
// Verify tag count on empty db
|
||||
let count = get_url_count_by_tag(&conn, "promo").unwrap();
|
||||
assert_eq!(count, 0);
|
||||
|
||||
// Create a URL and associate tags
|
||||
create_url_extended(
|
||||
&conn,
|
||||
"code1",
|
||||
"https://example.com/1",
|
||||
None,
|
||||
None,
|
||||
&["promo".to_string(), "tech".to_string()],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
create_url_extended(
|
||||
&conn,
|
||||
"code2",
|
||||
"https://example.com/2",
|
||||
None,
|
||||
None,
|
||||
&["promo".to_string()],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(get_url_count_by_tag(&conn, "promo").unwrap(), 2);
|
||||
assert_eq!(get_url_count_by_tag(&conn, "tech").unwrap(), 1);
|
||||
assert_eq!(get_url_count_by_tag(&conn, "other").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_limits_and_offset() {
|
||||
let conn = setup_content_db();
|
||||
|
||||
// Create 27 URLs to test page pagination limits of PAGE_SIZE = 25
|
||||
for i in 0..27 {
|
||||
create_url_extended(
|
||||
&conn,
|
||||
&format!("cd{:03}", i),
|
||||
&format!("https://example.com/{}", i),
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (total, _, _) = get_url_counts(&conn).unwrap();
|
||||
assert_eq!(total, 27);
|
||||
|
||||
// Page 1 (limit 25, offset 0)
|
||||
let page1_urls = list_urls(&conn, 25, 0, None).unwrap();
|
||||
assert_eq!(page1_urls.len(), 25);
|
||||
|
||||
// Page 2 (limit 25, offset 25)
|
||||
let page2_urls = list_urls(&conn, 25, 25, None).unwrap();
|
||||
assert_eq!(page2_urls.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_target_analytics_queries() {
|
||||
let mut conn = setup_analytics_db();
|
||||
let target_uuid = "target-uuid-123456";
|
||||
|
||||
// 1. Visit Count and Unique Visitors check on empty db
|
||||
assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 0);
|
||||
assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 0);
|
||||
|
||||
// 2. Insert some visit records
|
||||
let now = Utc::now();
|
||||
let records = vec![
|
||||
VisitRecord {
|
||||
id: "visit-1".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: now.to_rfc3339(),
|
||||
ip_address: "1.1.1.1".to_string(),
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "visit-2".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: now.to_rfc3339(),
|
||||
ip_address: "1.1.1.1".to_string(), // same visitor
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
VisitRecord {
|
||||
id: "visit-3".to_string(),
|
||||
target_type: "url".to_string(),
|
||||
target_id: target_uuid.to_string(),
|
||||
timestamp: now.to_rfc3339(),
|
||||
ip_address: "2.2.2.2".to_string(), // different visitor
|
||||
user_agent: "Mozilla".to_string(),
|
||||
referer: "direct".to_string(),
|
||||
accept_language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
status_code: 200,
|
||||
},
|
||||
];
|
||||
|
||||
insert_visits_batch(&mut conn, &records).unwrap();
|
||||
|
||||
// 3. Verify counts
|
||||
assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 3);
|
||||
assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 2);
|
||||
|
||||
// 4. Verify monthly clicks trend fallback
|
||||
let monthly_trend = get_monthly_clicks_trend(&conn, "url", target_uuid, 12).unwrap();
|
||||
assert_eq!(monthly_trend.len(), 1);
|
||||
assert_eq!(monthly_trend[0].1, 3); // 3 clicks total
|
||||
}
|
||||
Reference in new issue
Block a user