use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::thread;
use onetaskgraph_github_projects::accounting::{Accounting, Budget, Method, RateLimit};
use onetaskgraph_live::{RETAINED_BUFFER, Unaffordable};
use serde_json::{Value, json};
#[allow(dead_code)]
mod journey;
#[allow(dead_code)]
mod lane;
use journey::budget;
const LIMIT: u64 = 4_321;
const RESETS_AT: u64 = 1_775_000_000;
struct Standin {
host: String,
asked: Arc<Mutex<Vec<String>>>,
}
impl Standin {
fn serving(answer: (&'static str, String)) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("a stand-in listener");
let host = format!("http://{}", listener.local_addr().unwrap());
let asked = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&asked);
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.expect("a stand-in connection");
let mut request = [0_u8; 8192];
let read = stream.read(&mut request).expect("a stand-in request");
let head = String::from_utf8_lossy(&request[..read]).into_owned();
let line = head.lines().next().unwrap_or_default().to_owned();
let mut parts = line.split(' ');
let called = format!(
"{} {}",
parts.next().unwrap_or_default(),
parts.next().unwrap_or_default()
);
recorded.lock().unwrap().push(called.clone());
let (status, body) = if called == "GET /rate_limit" {
(answer.0, answer.1.clone())
} else {
(
"404 Not Found",
r#"{"message":"this stand-in answers the allowance read alone"}"#
.to_owned(),
)
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\n\
x-ratelimit-limit: {LIMIT}\r\nx-ratelimit-used: 0\r\n\
x-ratelimit-remaining: {LIMIT}\r\nx-ratelimit-resource: core\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
}
});
Self { host, asked }
}
fn with(graphql_remaining: u64, rest_remaining: u64) -> Self {
Self::serving((
"200 OK",
budget::documented_answer(LIMIT, graphql_remaining, rest_remaining, RESETS_AT)
.to_string(),
))
}
fn asked(&self) -> Vec<String> {
self.asked.lock().unwrap().clone()
}
}
async fn decide(standin: &Standin) -> (Accounting, Result<(), onetaskgraph_live::Declined>) {
let into = Accounting::new();
let decided = budget::precondition("test-token", &standin.host, &into).await;
(into, decided)
}
fn estimated(budget: Budget) -> u64 {
budget::estimate()
.get(&budget)
.copied()
.expect("this session draws on both of GitHub's budgets")
}
#[tokio::test]
async fn a_budget_with_room_starts_the_session_after_exactly_one_allowance_read() {
let standin = Standin::with(LIMIT, LIMIT);
let (into, decided) = decide(&standin).await;
decided.expect("an untouched allowance affords this session and its buffer");
assert_eq!(standin.asked(), vec!["GET /rate_limit".to_owned()]);
let session = into.snapshot();
assert_eq!(session.total_requests(), 1);
let read = &session.requests()[0];
assert_eq!(read.name(), "GET /rate_limit");
assert_eq!(read.budget(), Budget::Rest);
assert_eq!(read.rate_limit().limit(), Some(LIMIT));
assert_eq!(read.rate_limit().remaining(), Some(LIMIT));
for budget in [Budget::Graphql, Budget::Rest] {
assert_eq!(session.estimated(budget), Some(estimated(budget)));
}
let report = session.report();
assert!(
report.contains(&format!(
"a precondition estimated {} points before this session started",
estimated(Budget::Graphql)
)),
"{report}"
);
assert!(
report.contains(&format!(
"a precondition estimated {} requests before this session started",
estimated(Budget::Rest)
)),
"{report}"
);
}
#[tokio::test]
async fn a_remainder_that_would_dip_into_the_retained_buffer_does_not_start() {
let buffer = RETAINED_BUFFER.of(LIMIT);
let remaining = estimated(Budget::Graphql) + buffer - 1;
let standin = Standin::with(remaining, LIMIT);
let (_, decided) = decide(&standin).await;
let declined = decided.expect_err("a session that would dip into the buffer must not run");
let cause = declined
.unaffordable_because()
.expect("a budget refusal carries the decision it was made on");
assert_eq!(
cause,
&Unaffordable::Short {
metered: budget::metered(Budget::Graphql),
limit: LIMIT,
remaining,
estimated_cost: estimated(Budget::Graphql),
retained_buffer: buffer,
reset: RESETS_AT,
}
);
let message = declined.message();
assert!(message.contains("DID NOT RUN"), "{message}");
assert!(
message.contains("not a test failure in the code under test"),
"{message}"
);
for figure in [
LIMIT,
remaining,
estimated(Budget::Graphql),
buffer,
RESETS_AT,
] {
assert!(message.contains(&figure.to_string()), "{figure}: {message}");
}
assert!(message.contains("nothing here waits for it"), "{message}");
}
#[tokio::test]
async fn two_budgets_where_only_one_is_short_decline_naming_that_one() {
let buffer = RETAINED_BUFFER.of(LIMIT);
let rest_remaining = estimated(Budget::Rest) + buffer - 1;
let standin = Standin::with(LIMIT, rest_remaining);
let (_, decided) = decide(&standin).await;
let declined = decided.expect_err("one short budget is enough to decline");
let cause = declined
.unaffordable_because()
.expect("a budget refusal carries the decision it was made on");
assert_eq!(cause.budget(), "rest");
assert!(cause.reason().contains("requests"), "{}", cause.reason());
let (_, allowed) = decide(&Standin::with(LIMIT, rest_remaining + 1)).await;
allowed.expect("one more request is what that budget was short of");
}
#[tokio::test]
async fn an_allowance_read_the_stand_in_refuses_does_not_start_and_says_which_read_failed() {
for (status, body) in [
("503 Service Unavailable", json!({"message":"unavailable"})),
("200 OK", json!({"resources":{"search":{"limit":30}}})),
] {
let standin = Standin::serving((status, body.to_string()));
let (into, decided) = decide(&standin).await;
let declined = decided.expect_err("an unread allowance is not an affordable one");
let cause = declined
.unaffordable_because()
.expect("an unread budget carries the read that was not answered");
let Unaffordable::Unread { metered, why } = cause else {
panic!("an unanswered read is unread rather than short: {cause:?}");
};
assert_eq!(metered, &budget::metered(Budget::Graphql));
assert!(why.contains("/rate_limit"), "{why}");
assert!(declined.message().contains("DID NOT RUN"), "{status}");
assert_eq!(into.snapshot().total_requests(), 1);
}
}
#[tokio::test]
async fn an_allowance_read_that_reaches_nothing_at_all_does_not_start() {
let nowhere = TcpListener::bind("127.0.0.1:0").expect("a port to close");
let host = format!("http://{}", nowhere.local_addr().unwrap());
drop(nowhere);
let into = Accounting::new();
let declined = budget::precondition("test-token", &host, &into)
.await
.expect_err("a read that reached nothing leaves the budget unknown");
assert!(matches!(
declined.unaffordable_because(),
Some(Unaffordable::Unread { .. })
));
assert!(declined.message().contains("DID NOT RUN"));
}
#[tokio::test]
async fn the_allowance_read_matches_its_pinned_artifact() {
let pinned: Value =
serde_json::from_str(include_str!("fixtures/rate-limits.json")).expect("the pin parses");
let allowance = &pinned["allowance"];
assert_eq!(
allowance["endpoint"].as_str(),
Some(budget::ALLOWANCE_ENDPOINT),
"the precondition calls an endpoint fixtures/rate-limits.json does not pin"
);
assert_eq!(
allowance["method"].as_str().and_then(Method::parse),
Some(budget::ALLOWANCE_METHOD),
"the precondition addresses that endpoint with a method the pin does not record"
);
let pinned_resources = allowance["resources"]
.as_object()
.expect("the pin records one resource name per budget");
for budget in [Budget::Graphql, Budget::Rest] {
assert_eq!(
pinned_resources.get(budget.name()).and_then(Value::as_str),
Some(budget::resource_of(budget)),
"the {} budget is read out of a resources object the pin does not record",
budget.name()
);
}
assert_eq!(
pinned_resources.len(),
2,
"the pin records a resource this precondition does not read a budget out of"
);
let pinned_fields: Vec<&str> = allowance["fields"]
.as_array()
.expect("the pin records the fields of a budget's object")
.iter()
.map(|field| field.as_str().expect("each pinned field is a string"))
.collect();
assert_eq!(
pinned_fields,
budget::ALLOWANCE_FIELDS,
"the fields this precondition reads and the fields the pin records have parted"
);
let unmetered = allowance["unmetered"]
.as_str()
.expect("the pin records GitHub's own sentence about that endpoint")
.trim_end_matches('.');
let observed = budget::observation(
&Ok(budget::documented_answer(LIMIT, LIMIT, LIMIT, RESETS_AT)),
&RateLimit::default(),
);
assert!(
observed.contains(unmetered),
"the observation quotes a sentence the pin does not record: {observed}"
);
for absent in budget::ALLOWANCE_FIELDS {
let mut answer = budget::documented_answer(LIMIT, LIMIT, LIMIT, RESETS_AT);
answer["resources"][budget::GRAPHQL_RESOURCE]
.as_object_mut()
.expect("the answer carries the graphql budget")
.remove(absent);
let standin = Standin::serving(("200 OK", answer.to_string()));
let (_, decided) = decide(&standin).await;
let declined = decided.expect_err(&format!(
"an allowance missing {absent} must not be assumed"
));
let cause = declined.unaffordable_because().expect("a budget refusal");
let Unaffordable::Unread { metered, why } = cause else {
panic!("an allowance missing {absent} is unread rather than short: {cause:?}");
};
assert_eq!(metered, &budget::metered(Budget::Graphql));
assert!(why.contains(absent), "{absent} is not named in {why}");
}
}
#[tokio::test]
async fn an_allowance_reporting_more_left_than_it_holds_is_not_one_to_decide_on() {
let standin = Standin::serving((
"200 OK",
budget::documented_answer(LIMIT, LIMIT + 1, LIMIT, RESETS_AT).to_string(),
));
let (_, decided) = decide(&standin).await;
let declined = decided.expect_err("an impossible allowance is not an affordable one");
let cause = declined.unaffordable_because().expect("a budget refusal");
let Unaffordable::Unread { metered, why } = cause else {
panic!("an impossible allowance is unread rather than short: {cause:?}");
};
assert_eq!(metered, &budget::metered(Budget::Graphql));
assert!(why.contains("cannot both be true"), "{why}");
}
#[tokio::test]
async fn a_rest_budget_the_answer_omits_declines_although_the_graphql_one_read() {
let mut answer = budget::documented_answer(LIMIT, LIMIT, LIMIT, RESETS_AT);
answer["resources"]
.as_object_mut()
.expect("the answer carries a resources object")
.remove(budget::REST_RESOURCE);
let standin = Standin::serving(("200 OK", answer.to_string()));
let (_, decided) = decide(&standin).await;
let declined = decided.expect_err("a budget with no object is one this session did not read");
let cause = declined.unaffordable_because().expect("a budget refusal");
assert_eq!(cause.budget(), "rest");
assert!(
cause.reason().contains(budget::REST_RESOURCE),
"{}",
cause.reason()
);
}
#[tokio::test]
async fn the_estimate_is_derived_from_the_branchs_own_record_of_the_session() {
let record = include_str!("fixtures/session-cost.txt");
let rows: Vec<(u64, &str)> = record
.lines()
.skip_while(|line| line.trim() != "requests per call")
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut fields = line.split_whitespace();
let requests: u64 = fields.next().unwrap().parse().unwrap();
let _nodes = fields.next().unwrap();
(requests, line.split_whitespace().nth(2).unwrap())
})
.collect();
let rest_calls: u64 = rows
.iter()
.filter(|(_, first)| Method::parse(first).is_some())
.map(|(requests, _)| requests)
.sum();
let calls: u64 = rows.iter().map(|(requests, _)| requests).sum();
let estimate = budget::estimate();
assert_eq!(estimate[&Budget::Rest], rest_calls);
assert!(rest_calls > 0 && rest_calls < calls);
let graphql = estimate[&Budget::Graphql];
let nodes: u64 = record
.lines()
.skip_while(|line| line.trim() != "requests per call")
.skip(1)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| line.split_whitespace().nth(1)?.parse::<u64>().ok())
.sum();
assert!(
graphql * 100 < nodes,
"{graphql} points against {nodes} nodes"
);
assert!(
graphql > calls - rest_calls,
"{graphql} points against {} GraphQL calls",
calls - rest_calls
);
}
#[tokio::test]
async fn the_credentialed_lane_records_what_the_allowance_read_reported() {
let standin = Standin::with(LIMIT - 7, LIMIT - 3);
let (into, decided) = decide(&standin).await;
decided.expect("an allowance this large affords the session and its buffer");
let session = into.snapshot();
let read = &session.requests()[0];
let answered = json!(budget::documented_answer(
LIMIT,
LIMIT - 7,
LIMIT - 3,
RESETS_AT
));
let observed = budget::observation(&Ok(answered), read.rate_limit());
for figure in [
format!(
"resources.{} {} of {LIMIT}",
budget::GRAPHQL_RESOURCE,
LIMIT - 7
),
format!(
"resources.{} {} of {LIMIT}",
budget::REST_RESOURCE,
LIMIT - 3
),
format!("{LIMIT} of {LIMIT} on the core budget"),
"not a verdict".to_owned(),
] {
assert!(
observed.contains(&figure),
"{figure} is missing from {observed}"
);
}
let unread = budget::observation(
&Err("GET /rate_limit answered 503".to_owned()),
read.rate_limit(),
);
assert!(
unread.contains(&format!(
"no resources.{} allowance",
budget::GRAPHQL_RESOURCE
)),
"{unread}"
);
}
const FOLLOW_THROUGH: &str = "ONETASKGRAPH_BUDGET_DECLINE_FOLLOW_THROUGH";
#[test]
fn a_journey_the_account_cannot_afford_does_not_run_and_says_which_budget_was_short() {
let buffer = RETAINED_BUFFER.of(LIMIT);
let remaining = estimated(Budget::Graphql) + buffer - 1;
let standin = Standin::with(remaining, LIMIT);
journey::against(journey::Endpoints {
graphql: format!("{}/graphql", standin.host),
rest_host: standin.host.clone(),
source: Some(json!({"endpoint": format!("{}/graphql", standin.host)})),
});
let declined = thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime for the declined journey")
.block_on(journey::run(journey::Nomination {
token: "test-token".to_owned(),
owner: "octo-org".to_owned(),
project_number: 7,
repository: "acme/work".to_owned(),
}));
})
.join()
.expect_err("a journey the account cannot afford must not run");
let message = declined
.downcast_ref::<String>()
.cloned()
.unwrap_or_else(|| panic!("the decline carries its message: {declined:?}"));
assert!(message.contains("DID NOT RUN"), "{message}");
assert!(
message.contains("not a test failure in the code under test"),
"{message}"
);
assert!(message.contains("graphql"), "{message}");
for figure in [
LIMIT,
remaining,
estimated(Budget::Graphql),
buffer,
RESETS_AT,
] {
assert!(message.contains(&figure.to_string()), "{figure}: {message}");
}
assert_eq!(standin.asked(), vec!["GET /rate_limit".to_owned()]);
if std::env::var_os(FOLLOW_THROUGH).is_some() {
std::panic::resume_unwind(declined);
}
}