Files
Aether/crates/aether-data/adapters/sqlite/src/usage/tests.rs
T

983 lines
34 KiB
Rust

use super::{SqliteUsageReadRepository, SqliteUsageWriteRepository};
use crate::run_migrations;
use aether_data_contracts::repository::usage::{
UpsertUsageRecord, UsageAuditListQuery, UsageDailyHeatmapQuery,
UsageDashboardDailyBreakdownQuery, UsageDashboardSummaryQuery, UsageProviderPerformanceQuery,
UsageReadRepository, UsageTimeSeriesGranularity, UsageWriteRepository,
};
#[test]
fn sqlite_usage_upsert_guards_candidate_identity_metadata_and_routing_from_late_lifecycle() {
for field in [
"provider_name",
"model",
"target_model",
"provider_id",
"provider_endpoint_id",
"provider_api_key_id",
"request_type",
"api_format",
"api_family",
"endpoint_kind",
"endpoint_api_format",
"provider_api_family",
"provider_endpoint_kind",
"has_format_conversion",
"is_stream",
"upstream_is_stream",
"request_metadata",
"candidate_id",
"candidate_index",
"key_name",
"planner_kind",
"route_family",
"route_kind",
"execution_path",
"local_execution_runtime_miss_reason",
] {
let assignment = format!("{field} = CASE WHEN (");
assert!(
super::UPSERT_USAGE_SQL.contains(&assignment),
"missing lifecycle guard for {field}"
);
assert!(
super::UPSERT_USAGE_SQL.contains(&format!("THEN \"usage\".{field}")),
"late lifecycle must preserve {field}"
);
}
assert!(super::UPSERT_USAGE_SQL
.contains("OR (\"usage\".status = 'streaming' AND excluded.status = 'pending')"));
}
#[tokio::test]
async fn sqlite_provider_performance_can_skip_timeline() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
SqliteUsageWriteRepository::new(pool.clone())
.upsert(sample_usage(
"provider-performance",
"completed",
"pending",
1_000,
))
.await
.expect("usage should upsert");
let reader = SqliteUsageReadRepository::new(pool);
let mut query = UsageProviderPerformanceQuery {
created_from_unix_secs: 0,
created_until_unix_secs: 2_000,
granularity: UsageTimeSeriesGranularity::Hour,
tz_offset_minutes: 0,
limit: 1,
provider_id: None,
model: None,
api_format: None,
endpoint_kind: None,
is_stream: None,
has_format_conversion: None,
slow_threshold_ms: 10_000,
include_timeline: true,
};
let with_timeline = reader
.summarize_usage_provider_performance(&query)
.await
.expect("provider performance should load");
assert_eq!(with_timeline.summary.request_count, 1);
assert_eq!(with_timeline.providers.len(), 1);
assert_eq!(with_timeline.timeline.len(), 1);
query.include_timeline = false;
let without_timeline = reader
.summarize_usage_provider_performance(&query)
.await
.expect("provider performance without timeline should load");
assert_eq!(without_timeline.summary, with_timeline.summary);
assert_eq!(without_timeline.providers, with_timeline.providers);
assert!(without_timeline.timeline.is_empty());
}
#[tokio::test]
async fn sqlite_usage_write_repository_upserts_and_rebuilds_stats() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool.clone());
let record = repository
.upsert(sample_usage("request-1", "completed", "pending", 1_000))
.await
.expect("usage should upsert");
assert_eq!(record.request_id, "request-1");
assert_eq!(record.api_key_id.as_deref(), Some("api-key-1"));
assert_eq!(record.total_tokens, 7);
assert_eq!(record.cache_read_input_tokens, 2);
assert_eq!(
record.request_metadata.as_ref().unwrap()["trace_id"],
"trace-1"
);
assert_eq!(
record.request_metadata.as_ref().unwrap()["upstream_is_stream"],
true
);
let upstream_is_stream: Option<i64> =
sqlx::query_scalar("SELECT upstream_is_stream FROM \"usage\" WHERE request_id = ?")
.bind("request-1")
.fetch_one(&pool)
.await
.expect("usage stream mode should load");
assert_eq!(upstream_is_stream, Some(1));
let loaded = repository
.find_by_request_id("request-1")
.await
.expect("usage should load")
.expect("usage should exist");
assert_eq!(
loaded.provider_api_key_id.as_deref(),
Some("provider-key-1")
);
let stats = sqlx::query_as::<_, (i64, i64, f64, Option<i64>)>(
"SELECT total_requests, total_tokens, total_cost_usd, last_used_at FROM api_keys WHERE id = 'api-key-1'",
)
.fetch_one(&pool)
.await
.expect("api key stats should load");
assert_eq!(stats, (1, 7, 0.5, Some(1_000)));
let provider_stats = sqlx::query_as::<_, (i64, i64, i64, i64, f64, i64, Option<i64>)>(
"SELECT request_count, success_count, error_count, total_tokens, total_cost_usd, total_response_time_ms, last_used_at FROM provider_api_keys WHERE id = 'provider-key-1'",
)
.fetch_one(&pool)
.await
.expect("provider key stats should load");
assert_eq!(provider_stats, (1, 1, 0, 7, 0.5, 42, Some(1_000)));
}
#[tokio::test]
async fn sqlite_usage_write_repository_does_not_regress_void_usage() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool.clone());
repository
.upsert(sample_usage("request-1", "failed", "void", 1_000))
.await
.expect("void usage should upsert");
let existing = repository
.upsert(sample_usage("request-1", "pending", "pending", 1_001))
.await
.expect("stale usage should be ignored");
assert_eq!(existing.status, "failed");
assert_eq!(existing.billing_status, "void");
assert_eq!(existing.updated_at_unix_secs, 1_000);
}
#[tokio::test]
async fn sqlite_usage_write_repository_does_not_reopen_void_failure_from_late_streaming() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool);
for (request_id, status_code) in [
("request-late-active", None),
("request-late-response-start", Some(200)),
] {
let mut failed = sample_usage(request_id, "failed", "void", 1_000);
failed.status_code = Some(503);
repository
.upsert(failed)
.await
.expect("failed usage should upsert");
let mut late_streaming = sample_usage(request_id, "streaming", "pending", 1_001);
late_streaming.status_code = status_code;
late_streaming.finalized_at_unix_secs = None;
let current = repository
.upsert(late_streaming)
.await
.expect("late streaming usage should be ignored");
assert_eq!(current.status, "failed");
assert_eq!(current.billing_status, "void");
assert_eq!(current.status_code, Some(503));
assert_eq!(current.finalized_at_unix_secs, Some(1_000));
}
}
#[tokio::test]
async fn sqlite_usage_write_repository_does_not_regress_terminal_usage_from_late_streaming() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool.clone());
let mut terminal = sample_usage("request-1", "completed", "pending", 1_000);
terminal.request_metadata = Some(serde_json::json!({"trace_id": "terminal-trace"}));
repository
.upsert(terminal)
.await
.expect("terminal usage should upsert");
let mut late_streaming = sample_usage("request-1", "streaming", "pending", 1_001);
late_streaming.input_tokens = Some(0);
late_streaming.output_tokens = Some(0);
late_streaming.total_tokens = Some(0);
late_streaming.cache_read_input_tokens = Some(0);
late_streaming.cache_read_cost_usd = Some(0.0);
late_streaming.total_cost_usd = Some(0.0);
late_streaming.actual_total_cost_usd = Some(0.0);
late_streaming.response_time_ms = Some(9_999);
late_streaming.first_byte_time_ms = Some(9_999);
late_streaming.finalized_at_unix_secs = None;
late_streaming.provider_name = "Late Provider".to_string();
late_streaming.model = "late-model".to_string();
late_streaming.target_model = Some("late-target".to_string());
late_streaming.request_type = Some("late-request".to_string());
late_streaming.api_format = Some("late:api".to_string());
late_streaming.api_family = Some("late-family".to_string());
late_streaming.endpoint_kind = Some("late-endpoint".to_string());
late_streaming.endpoint_api_format = Some("late:endpoint".to_string());
late_streaming.provider_api_family = Some("late-provider-family".to_string());
late_streaming.provider_endpoint_kind = Some("late-provider-endpoint".to_string());
late_streaming.has_format_conversion = Some(false);
late_streaming.is_stream = Some(true);
late_streaming.candidate_id = Some("late-candidate".to_string());
late_streaming.candidate_index = Some(99);
late_streaming.key_name = Some("late-key".to_string());
late_streaming.planner_kind = Some("late-planner".to_string());
late_streaming.route_family = Some("late-route-family".to_string());
late_streaming.route_kind = Some("late-route-kind".to_string());
late_streaming.execution_path = Some("late-path".to_string());
late_streaming.local_execution_runtime_miss_reason = Some("late-miss".to_string());
late_streaming.request_metadata = Some(serde_json::json!({
"provider_service_tier": "priority",
"upstream_is_stream": true
}));
let current = repository
.upsert(late_streaming)
.await
.expect("late streaming usage should not regress terminal usage");
assert_eq!(current.status, "completed");
assert_eq!(current.billing_status, "pending");
assert_eq!(current.total_tokens, 7);
assert_eq!(current.cache_read_input_tokens, 2);
assert_eq!(current.total_cost_usd, 0.5);
assert_eq!(current.actual_total_cost_usd, 0.4);
assert_eq!(current.response_time_ms, Some(42));
assert_eq!(current.first_byte_time_ms, Some(12));
assert_eq!(current.finalized_at_unix_secs, Some(1_000));
assert_eq!(current.updated_at_unix_secs, 1_000);
assert_eq!(current.provider_name, "Provider One");
assert_eq!(current.model, "model-1");
assert_eq!(current.target_model.as_deref(), Some("target-model"));
assert_eq!(current.request_type.as_deref(), Some("chat"));
assert_eq!(current.api_format.as_deref(), Some("openai"));
assert!(current.has_format_conversion);
assert!(!current.is_stream);
assert_eq!(current.candidate_id.as_deref(), Some("candidate-1"));
assert_eq!(current.candidate_index, Some(1));
assert_eq!(current.key_name.as_deref(), Some("key-one"));
assert_eq!(current.planner_kind.as_deref(), Some("default"));
assert_eq!(current.route_family.as_deref(), Some("chat"));
assert_eq!(current.route_kind.as_deref(), Some("completion"));
assert_eq!(current.execution_path.as_deref(), Some("remote"));
assert_eq!(current.provider_service_tier(), None);
assert_eq!(
current
.request_metadata
.as_ref()
.and_then(|value| value.get("trace_id"))
.and_then(serde_json::Value::as_str),
Some("terminal-trace")
);
let listed = SqliteUsageReadRepository::new(pool)
.list_usage_audits(&UsageAuditListQuery {
limit: Some(10),
newest_first: true,
..UsageAuditListQuery::default()
})
.await
.expect("usage list should load")
.into_iter()
.find(|item| item.request_id == "request-1")
.expect("terminal usage should be listed");
assert_eq!(listed.provider_name, "Provider One");
assert_eq!(listed.model, "model-1");
assert_eq!(listed.candidate_id.as_deref(), Some("candidate-1"));
assert_eq!(listed.provider_service_tier(), None);
}
#[tokio::test]
async fn sqlite_usage_write_repository_allows_authoritative_completed_recovery() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool);
repository
.upsert(sample_usage("request-recovery", "failed", "void", 1_000))
.await
.expect("void failure should upsert");
let mut recovery = sample_usage("request-recovery", "completed", "pending", 1_001);
recovery.provider_name = "Recovered Provider".to_string();
recovery.model = "recovered-model".to_string();
recovery.target_model = Some("recovered-target".to_string());
recovery.api_format = Some("recovered:api".to_string());
recovery.candidate_id = Some("recovered-candidate".to_string());
recovery.request_metadata = Some(serde_json::json!({"provider_service_tier": "priority"}));
let recovered = repository
.upsert(recovery)
.await
.expect("completed recovery should upsert");
assert_eq!(recovered.status, "completed");
assert_eq!(recovered.billing_status, "pending");
assert_eq!(recovered.provider_name, "Recovered Provider");
assert_eq!(recovered.model, "recovered-model");
assert_eq!(recovered.target_model.as_deref(), Some("recovered-target"));
assert_eq!(recovered.api_format.as_deref(), Some("recovered:api"));
assert_eq!(
recovered.candidate_id.as_deref(),
Some("recovered-candidate")
);
assert_eq!(
recovered.provider_service_tier().as_deref(),
Some("priority")
);
}
#[tokio::test]
async fn sqlite_usage_write_repository_preserves_streaming_response_start_from_late_active() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool);
repository
.upsert(sample_usage(
"request-late-active",
"streaming",
"pending",
1_000,
))
.await
.expect("response-start usage should upsert");
let mut late_active = sample_usage("request-late-active", "streaming", "pending", 1_001);
late_active.status_code = None;
late_active.response_time_ms = None;
late_active.first_byte_time_ms = None;
let current = repository
.upsert(late_active)
.await
.expect("late active usage should not clear response-start fields");
assert_eq!(current.status, "streaming");
assert_eq!(current.status_code, Some(200));
assert_eq!(current.response_time_ms, Some(42));
assert_eq!(current.first_byte_time_ms, Some(12));
}
#[tokio::test]
async fn sqlite_usage_write_repository_keeps_streaming_capture_from_late_pending() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool);
let mut streaming = sample_usage("request-streaming-capture", "streaming", "pending", 1_000);
streaming.request_metadata = Some(serde_json::json!({"trace_id": "streaming-final"}));
repository
.upsert(streaming)
.await
.expect("streaming usage should upsert");
let mut late_pending = sample_usage("request-streaming-capture", "pending", "pending", 1_001);
late_pending.provider_name = "Late Provider".to_string();
late_pending.model = "late-model".to_string();
late_pending.candidate_id = Some("late-candidate".to_string());
late_pending.request_metadata = Some(serde_json::json!({"provider_service_tier": "priority"}));
let current = repository
.upsert(late_pending)
.await
.expect("late pending usage should not regress streaming capture");
assert_eq!(current.status, "streaming");
assert_eq!(current.provider_name, "Provider One");
assert_eq!(current.model, "model-1");
assert_eq!(current.candidate_id.as_deref(), Some("candidate-1"));
assert_eq!(current.provider_service_tier(), None);
assert_eq!(
current
.request_metadata
.as_ref()
.and_then(|value| value.get("trace_id"))
.and_then(serde_json::Value::as_str),
Some("streaming-final")
);
}
#[tokio::test]
async fn sqlite_usage_write_repository_cleans_stale_pending_requests() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool.clone());
repository
.upsert(sample_usage("request-recovered", "streaming", "pending", 1))
.await
.expect("streaming usage should upsert");
repository
.upsert(sample_usage("request-failed", "pending", "pending", 1))
.await
.expect("pending usage should upsert");
sqlx::query(
r#"
INSERT INTO request_candidates (
id,
request_id,
candidate_index,
retry_index,
status,
is_cached,
created_at
) VALUES
('candidate-recovered', 'request-recovered', 0, 0, 'streaming', 0, 1),
('candidate-failed', 'request-failed', 0, 0, 'pending', 0, 1)
"#,
)
.execute(&pool)
.await
.expect("request candidates should seed");
let summary = repository
.cleanup_stale_pending_requests(2, 10, 5, 1)
.await
.expect("cleanup should run");
assert_eq!(summary.recovered, 1);
assert_eq!(summary.failed, 1);
let recovered = repository
.find_by_request_id("request-recovered")
.await
.expect("recovered usage should load")
.expect("recovered usage should exist");
assert_eq!(recovered.status, "completed");
assert_eq!(recovered.status_code, Some(200));
let failed = repository
.find_by_request_id("request-failed")
.await
.expect("failed usage should load")
.expect("failed usage should exist");
assert_eq!(failed.status, "failed");
assert_eq!(failed.status_code, Some(504));
assert_eq!(failed.billing_status, "void");
assert_eq!(failed.total_cost_usd, 0.0);
assert_eq!(failed.finalized_at_unix_secs, Some(10));
let candidate_statuses = sqlx::query_as::<_, (String, String, Option<i64>)>(
r#"
SELECT request_id, status, finished_at
FROM request_candidates
ORDER BY request_id
"#,
)
.fetch_all(&pool)
.await
.expect("candidate statuses should load");
assert_eq!(
candidate_statuses,
vec![
(
"request-failed".to_string(),
"failed".to_string(),
Some(10_000)
),
(
"request-recovered".to_string(),
"success".to_string(),
Some(10_000)
),
]
);
let snapshot = sqlx::query_as::<_, (String, Option<i64>)>(
"SELECT billing_status, finalized_at FROM usage_settlement_snapshots WHERE request_id = 'request-failed'",
)
.fetch_one(&pool)
.await
.expect("void settlement snapshot should load");
assert_eq!(snapshot, ("void".to_string(), Some(10)));
}
#[tokio::test]
async fn sqlite_usage_write_repository_cleanup_uses_failed_candidate_status_when_present() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let repository = SqliteUsageWriteRepository::new(pool.clone());
repository
.upsert(sample_usage(
"request-upstream-reset",
"pending",
"pending",
1,
))
.await
.expect("pending usage should upsert");
repository
.upsert(sample_usage("request-stuck", "pending", "pending", 1))
.await
.expect("pending usage should upsert");
// request-upstream-reset has a failed candidate carrying a concrete 502 status
// and a connection-reset message — cleanup should use them instead of 504.
// request-stuck has only a still-pending candidate, so cleanup should fall back to 504.
sqlx::query(
r#"
INSERT INTO request_candidates (
id,
request_id,
candidate_index,
retry_index,
status,
status_code,
error_message,
is_cached,
created_at,
started_at,
finished_at
) VALUES
('candidate-reset', 'request-upstream-reset', 0, 0, 'failed', 502, 'upstream connection reset by peer', 0, 1, 2, 3),
('candidate-stuck', 'request-stuck', 0, 0, 'pending', NULL, NULL, 0, 1, NULL, NULL)
"#,
)
.execute(&pool)
.await
.expect("request candidates should seed");
let summary = repository
.cleanup_stale_pending_requests(2, 10, 5, 5)
.await
.expect("cleanup should run");
assert_eq!(summary.recovered, 0);
assert_eq!(summary.failed, 2);
let reset = repository
.find_by_request_id("request-upstream-reset")
.await
.expect("upstream-reset usage should load")
.expect("upstream-reset usage should exist");
assert_eq!(reset.status, "failed");
assert_eq!(reset.status_code, Some(502));
assert_eq!(
reset.error_message.as_deref(),
Some("upstream connection reset by peer")
);
let stuck = repository
.find_by_request_id("request-stuck")
.await
.expect("stuck usage should load")
.expect("stuck usage should exist");
assert_eq!(stuck.status, "failed");
assert_eq!(stuck.status_code, Some(504));
assert!(stuck
.error_message
.as_deref()
.is_some_and(|message| message.contains("超过 5 分钟未完成")));
}
#[tokio::test]
async fn sqlite_usage_read_repository_reads_usage_contract_views() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_stats_targets(&pool).await;
let writer = SqliteUsageWriteRepository::new(pool.clone());
writer
.upsert(sample_usage("request-1", "completed", "settled", 1_000))
.await
.expect("usage should upsert");
writer
.upsert(sample_usage("request-2", "failed", "void", 1_010))
.await
.expect("usage should upsert");
let reader = SqliteUsageReadRepository::new(pool);
let loaded = reader
.find_by_request_id("request-1")
.await
.expect("usage should load")
.expect("usage should exist");
assert_eq!(loaded.total_tokens, 7);
assert_eq!(loaded.billing_status, "settled");
let listed = reader
.list_usage_audits(&UsageAuditListQuery {
provider_name: Some("Provider One".to_string()),
newest_first: true,
..UsageAuditListQuery::default()
})
.await
.expect("usage list should load");
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].request_id, "request-2");
let summary = reader
.summarize_dashboard_usage(&UsageDashboardSummaryQuery {
created_from_unix_secs: 999,
created_until_unix_secs: 1_020,
user_id: Some("user-1".to_string()),
})
.await
.expect("dashboard summary should load");
assert_eq!(summary.total_requests, 2);
assert_eq!(summary.error_requests, 1);
assert_eq!(summary.total_tokens, 10);
}
#[tokio::test]
async fn sqlite_usage_daily_heatmap_reads_imported_daily_aggregates() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
sqlx::query(
r#"
INSERT INTO stats_daily (
id, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, is_complete, created_at, updated_at
) VALUES (
'daily-1', 86400, 9, 8, 1, 10, 20, 3, 4, 1.25, 1.0, 1, 1, 1
);
INSERT INTO stats_user_daily (
id, user_id, username, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, created_at, updated_at
) VALUES (
'user-daily-1', 'user-1', 'user one', 86400, 5, 5, 0, 7, 8, 2, 1, 0.75, 1, 1
);
"#,
)
.execute(&pool)
.await
.expect("daily aggregates should seed");
let reader = SqliteUsageReadRepository::new(pool);
let admin = reader
.summarize_usage_daily_heatmap(&UsageDailyHeatmapQuery {
created_from_unix_secs: 0,
user_id: None,
admin_mode: true,
})
.await
.expect("admin heatmap should load");
assert_eq!(admin.len(), 1);
assert_eq!(admin[0].date, "1970-01-02");
assert_eq!(admin[0].requests, 9);
assert_eq!(admin[0].total_tokens, 37);
assert_eq!(admin[0].actual_total_cost_usd, 1.0);
let user = reader
.summarize_usage_daily_heatmap(&UsageDailyHeatmapQuery {
created_from_unix_secs: 0,
user_id: Some("user-1".to_string()),
admin_mode: false,
})
.await
.expect("user heatmap should load");
assert_eq!(user.len(), 1);
assert_eq!(user[0].date, "1970-01-02");
assert_eq!(user[0].requests, 5);
assert_eq!(user[0].total_tokens, 18);
assert_eq!(user[0].actual_total_cost_usd, 0.75);
}
#[tokio::test]
async fn sqlite_usage_totals_by_user_ids_reads_imported_user_daily_aggregates() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
sqlx::query(
r#"
INSERT INTO stats_user_daily (
id, user_id, username, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, created_at, updated_at
) VALUES (
'user-daily-1', 'user-1', 'user one', 86400, 5, 5, 0, 7, 8, 2, 1, 0.75, 1, 1
);
INSERT INTO "usage" (
request_id, id, user_id, api_key_id, provider_name, model, total_tokens,
status, billing_status, created_at_unix_ms, updated_at_unix_secs
) VALUES
('raw-before-cutoff', 'usage-1', 'user-1', 'api-key-1', 'Provider One', 'model-1', 99,
'completed', 'settled', 90000, 90000),
('raw-after-cutoff', 'usage-2', 'user-1', 'api-key-1', 'Provider One', 'model-1', 7,
'completed', 'settled', 172800, 172800);
"#,
)
.execute(&pool)
.await
.expect("usage totals fixtures should seed");
let reader = SqliteUsageReadRepository::new(pool);
let totals = reader
.summarize_usage_totals_by_user_ids(&["user-1".to_string()])
.await
.expect("user totals should load");
assert_eq!(totals.len(), 1);
assert_eq!(totals[0].user_id, "user-1");
assert_eq!(totals[0].request_count, 6);
assert_eq!(totals[0].total_tokens, 25);
}
#[tokio::test]
async fn sqlite_dashboard_daily_stats_reads_imported_daily_aggregates() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
sqlx::query(
r#"
INSERT INTO stats_daily (
id, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, is_complete, created_at, updated_at
) VALUES (
'daily-1', 86400, 9, 8, 1, 10, 20, 3, 4, 1.25, 1.0, 1, 1, 1
);
"#,
)
.execute(&pool)
.await
.expect("daily aggregates should seed");
let reader = SqliteUsageReadRepository::new(pool);
let summary = reader
.summarize_dashboard_usage(&UsageDashboardSummaryQuery {
created_from_unix_secs: 0,
created_until_unix_secs: 172800,
user_id: None,
})
.await
.expect("dashboard summary should load");
assert_eq!(summary.total_requests, 9);
assert_eq!(summary.total_tokens, 37);
let rows = reader
.list_dashboard_daily_breakdown(&UsageDashboardDailyBreakdownQuery {
created_from_unix_secs: 0,
created_until_unix_secs: 172800,
tz_offset_minutes: 480,
user_id: None,
})
.await
.expect("dashboard daily breakdown should load");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].date, "1970-01-02");
assert_eq!(rows[0].model, "aggregate");
assert_eq!(rows[0].requests, 9);
assert_eq!(rows[0].total_tokens, 37);
}
async fn seed_stats_targets(pool: &sqlx::SqlitePool) {
sqlx::query(
r#"
INSERT INTO users (id, auth_source, created_at, updated_at)
VALUES ('user-1', 'local', 1, 1);
INSERT INTO api_keys (id, user_id, key_hash, created_at, updated_at)
VALUES ('api-key-1', 'user-1', 'hash-1', 1, 1);
INSERT INTO providers (id, name, provider_type, created_at, updated_at)
VALUES ('provider-1', 'Provider One', 'openai', 1, 1);
INSERT INTO provider_api_keys (id, provider_id, name, created_at, updated_at)
VALUES ('provider-key-1', 'provider-1', 'Provider Key One', 1, 1);
"#,
)
.execute(pool)
.await
.expect("stats targets should seed");
}
fn sample_usage(
request_id: &str,
status: &str,
billing_status: &str,
updated_at: u64,
) -> UpsertUsageRecord {
UpsertUsageRecord {
request_id: request_id.to_string(),
user_id: Some("user-1".to_string()),
api_key_id: Some("api-key-1".to_string()),
username: Some("legacy-user".to_string()),
api_key_name: Some("legacy-key".to_string()),
provider_name: "Provider One".to_string(),
model: "model-1".to_string(),
target_model: Some("target-model".to_string()),
provider_id: Some("provider-1".to_string()),
provider_endpoint_id: Some("endpoint-1".to_string()),
provider_api_key_id: Some("provider-key-1".to_string()),
request_type: Some("chat".to_string()),
api_format: Some("openai".to_string()),
api_family: Some("chat".to_string()),
endpoint_kind: Some("chat".to_string()),
endpoint_api_format: Some("openai".to_string()),
provider_api_family: Some("chat".to_string()),
provider_endpoint_kind: Some("chat".to_string()),
has_format_conversion: Some(true),
is_stream: Some(false),
input_tokens: Some(2),
output_tokens: Some(3),
total_tokens: None,
cache_creation_input_tokens: None,
cache_creation_ephemeral_5m_input_tokens: Some(0),
cache_creation_ephemeral_1h_input_tokens: Some(0),
cache_read_input_tokens: Some(2),
cache_creation_cost_usd: Some(0.0),
cache_read_cost_usd: Some(0.1),
output_price_per_1m: Some(2.0),
total_cost_usd: Some(0.5),
actual_total_cost_usd: Some(0.4),
status_code: Some(200),
error_message: None,
error_category: None,
response_time_ms: Some(42),
first_byte_time_ms: Some(12),
status: status.to_string(),
billing_status: billing_status.to_string(),
request_headers: None,
request_body: None,
request_body_ref: None,
request_body_state: None,
provider_request_headers: None,
provider_request_body: None,
provider_request_body_ref: None,
provider_request_body_state: None,
response_headers: None,
response_body: None,
response_body_ref: None,
response_body_state: None,
client_response_headers: None,
client_response_body: None,
client_response_body_ref: None,
client_response_body_state: None,
candidate_id: Some("candidate-1".to_string()),
candidate_index: Some(1),
key_name: Some("key-one".to_string()),
planner_kind: Some("default".to_string()),
route_family: Some("chat".to_string()),
route_kind: Some("completion".to_string()),
execution_path: Some("remote".to_string()),
local_execution_runtime_miss_reason: None,
request_metadata: Some(serde_json::json!({
"trace_id": "trace-1",
"upstream_is_stream": true,
})),
finalized_at_unix_secs: Some(updated_at),
created_at_unix_ms: Some(updated_at),
updated_at_unix_secs: updated_at,
}
}