use std::num::NonZeroU32;
use haematite::db::DatabaseConfig;
use haematite::store::NodeCacheBudget;
use lys_pass::drafts::{ApprovedDraft, Execution, RunError, execute_once, request_digest};
use lys_pass::{Mode, Target};
use serde_json::{Value, json};
use super::Drafts;
use crate::serve::live::LysLive;
use crate::serve::protocol::{BranchPage, Operation, Version};
use crate::serve::service::Service;
use crate::serve::storage::Storage;
type TestResult = Result<(), Box<dyn std::error::Error>>;
const LIMIT: NonZeroU32 = NonZeroU32::MIN.saturating_add(9);
fn with_service(test: impl FnOnce(&Service) -> TestResult) -> TestResult {
let directory = tempfile::tempdir()?;
let storage = Storage::create(
DatabaseConfig {
data_dir: directory.path().join("database"),
shard_count: 1,
distributed: None,
executor_threads: Some(1),
node_cache_budget: Some(NodeCacheBudget::Unlimited),
retention: haematite::Retention::KeepAll,
},
NodeCacheBudget::Unlimited,
fixture_long_wait_interval(),
)?;
let service = Service::new(storage, LIMIT)?;
test(&service)
}
fn branches(service: &Service) -> Result<Vec<Value>, Box<dyn std::error::Error>> {
let answer = service.dispatch(Operation::Branches(BranchPage {
after: None,
limit: LIMIT,
}))?;
Ok(answer["entries"]
.as_array()
.ok_or("branches has no entries")?
.clone())
}
fn head(service: &Service) -> Result<Version, Box<dyn std::error::Error>> {
let main = branches(service)?
.into_iter()
.find(|row| row["branch"] == "main")
.ok_or("main branch missing")?;
Ok(serde_json::from_value(main["version"].clone())?)
}
fn fork_words(service: &Service) -> Result<String, Box<dyn std::error::Error>> {
let head = head(service)?;
Ok(
json!({"id": "7", "operation": {"method": "mutation.apply", "params": {
"request_id": "f", "branch": "main", "expected": head,
"change": {"kind": "branch.fork", "value": {"name": "trial", "from": head}},
}}})
.to_string(),
)
}
fn approved(words: &str, target: Target) -> ApprovedDraft {
ApprovedDraft {
id: "draft-1".to_owned(),
app: "haematite".to_owned(),
grant: "branch-by-draft".to_owned(),
target,
request_digest: request_digest(words),
approved_by: vec!["person-tom".to_owned()],
mode: Mode::ByDraft,
words: words.to_owned(),
}
}
fn forks(service: &Service) -> Result<usize, Box<dyn std::error::Error>> {
Ok(branches(service)?
.iter()
.filter(|row| row["branch"] == "trial")
.count())
}
#[test]
fn an_approved_change_is_applied_exactly_once() -> TestResult {
with_service(|service| {
let words = fork_words(service)?;
let draft = approved(
&words,
Target::new("haematite.database", "wards", "branch")?,
);
let mut drafts = Drafts::new(service, "wards");
let first = execute_once(&draft, &mut drafts)?;
let Execution::Executed { request_digest, .. } = &first else {
return Err(format!("the draft was not executed: {first:?}").into());
};
assert_eq!(request_digest, &draft.request_digest);
assert_eq!(forks(service)?, 1);
assert_eq!(execute_once(&draft, &mut drafts)?, first);
assert_eq!(forks(service)?, 1, "a second run forked again");
Ok(())
})
}
#[test]
fn a_draft_whose_words_act_on_another_resource_is_refused() -> TestResult {
with_service(|service| {
let words = fork_words(service)?;
let draft = approved(
&words,
Target::new("haematite.database", "clinic", "branch")?,
);
let mut drafts = Drafts::new(service, "wards");
let refused = execute_once(&draft, &mut drafts)?;
let Execution::RefusedOnExecution { refusal, .. } = &refused else {
return Err(format!("a draft for another database was applied: {refused:?}").into());
};
assert_eq!(refusal, "draft_target_differs");
assert_eq!(forks(service)?, 0);
Ok(())
})
}
#[test]
fn a_draft_of_a_read_is_refused() -> TestResult {
with_service(|service| {
let words = r#"{"id":"8","operation":{"method":"branch.merge_prepare","params":{"branch":"main","limit":10}}}"#;
let draft = approved(
words,
Target::new("haematite.branch", "wards.main", "merge")?,
);
let mut drafts = Drafts::new(service, "wards");
let refused = execute_once(&draft, &mut drafts)?;
let Execution::RefusedOnExecution {
refusal, reason, ..
} = &refused
else {
return Err(format!("a read was run from a draft: {refused:?}").into());
};
assert_eq!(refusal, "draft_not_a_change");
assert!(
!reason.is_empty(),
"a refusal on execution carries no reason"
);
Ok(())
})
}
#[test]
fn a_run_against_an_unreachable_lys_applies_nothing() -> TestResult {
with_service(|service| {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let live = LysLive::new(&format!("http://127.0.0.1:{port}/"))?;
let mut drafts = Drafts::new(service, "wards");
let Err(RunError::Pull(error)) = live
.run_approved("connector", &mut drafts, None)
.map_err(|error| *error)
else {
return Err("an unreachable Lys answered a page".into());
};
assert_eq!(error.name(), "lys_could_not_be_asked");
assert_eq!(forks(service)?, 0);
Ok(())
})
}
const fn fixture_long_wait_interval() -> std::time::Duration {
std::time::Duration::from_secs(1)
}
struct ApprovedPage(ApprovedDraft);
impl crate::serve::pass::LiveCheck for ApprovedPage {
fn ask(
&self,
_token: &str,
_holder: &str,
_target: &Target,
) -> Result<lys_pass::deliberate::CheckAnswer, lys_pass::Error> {
Err(lys_pass::Error::CannotAsk("this double answers no check"))
}
fn draft(
&self,
_token: &str,
_request: &lys_pass::drafts::DraftRequest,
_judgment: &lys_pass::deliberate::CheckAnswer,
) -> Result<String, lys_pass::Error> {
Err(lys_pass::Error::CannotAsk("this double records no draft"))
}
fn run_drafts(
&self,
_connector: &str,
drafts: &mut Drafts<'_>,
_after: Option<&str>,
) -> Result<lys_pass::drafts::RunReport, Box<RunError<crate::serve::storage::Failure>>> {
execute_once(&self.0, drafts).map_err(|source| {
Box::new(RunError::Execution {
draft: self.0.id.clone(),
source,
})
})?;
Ok(lys_pass::drafts::RunReport {
acknowledged: 1,
next: None,
})
}
}
fn gate(
live: impl crate::serve::pass::LiveCheck + Send + Sync + 'static,
) -> Result<crate::serve::pass::Gate, lys_pass::Error> {
crate::serve::pass::Gate::new(
lys_pass::conformance::KEYS,
lys_pass::conformance::ISSUER,
"wards",
std::sync::Arc::new(live),
)
}
#[test]
fn drafts_run_applies_the_approved_page_and_answers_its_report() -> TestResult {
with_service(|service| {
let words = fork_words(service)?;
let draft = approved(
&words,
Target::new("haematite.database", "wards", "branch")?,
);
let answer = gate(ApprovedPage(draft))?.run_drafts(service, Some("connector"), None)?;
assert_eq!(answer, json!({"acknowledged": 1, "next": null}));
assert_eq!(forks(service)?, 1);
Ok(())
})
}
#[test]
fn drafts_run_needs_a_pass_and_a_reachable_lys() -> TestResult {
with_service(|service| {
let words = fork_words(service)?;
let draft = approved(
&words,
Target::new("haematite.database", "wards", "branch")?,
);
let missing = gate(ApprovedPage(draft))?
.run_drafts(service, None, None)
.err()
.ok_or("drafts ran without a pass")?;
assert_eq!(missing.code, "pass_missing");
let unreachable = gate(crate::serve::pass::Unreachable)?
.run_drafts(service, Some("connector"), None)
.err()
.ok_or("drafts ran with no Lys to ask")?;
assert_eq!(unreachable.code, "lys_could_not_be_asked");
assert_eq!(forks(service)?, 0);
Ok(())
})
}
#[test]
fn drafts_run_without_lys_is_an_invalid_operation() -> TestResult {
with_service(|service| {
let refused = service
.run_drafts(
Some("connector"),
&crate::serve::protocol::DraftsRun { after: None },
)
.err()
.ok_or("a service without Lys ran drafts")?;
assert_eq!(refused.code, "invalid_operation");
Ok(())
})
}