Add analytics drill-down, visitor logs, exports and pagination

This commit is contained in:
thakares committed 2026-06-17 15:35:59 +05:30
1 parent 0e111d61ff
commit 84c48fa5c1
16 files changed
+1982 -8

No files matched your search

+156
View File
@@ -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");
}
+155
View File
@@ -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
}