#![cfg(feature = "live")]
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use std::collections::BTreeSet;
use std::path::Path;
use ytcli::api::{Client, ClientConfig};
use ytcli::config::OrgKind;
fn setting(name: &str) -> Option<String> {
if let Ok(value) = std::env::var(name)
&& !value.is_empty()
{
return Some(value);
}
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".env");
let text = std::fs::read_to_string(path).ok()?;
text.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.filter_map(|line| line.split_once('='))
.find(|(key, _)| key.trim() == name)
.map(|(_, value)| value.trim().trim_matches('"').to_owned())
}
fn client() -> Client {
let (org, kind, account) = organisation();
let from_account = || {
account
.as_deref()
.and_then(|account| ytcli::secrets::token(account).ok())
};
let token = if setting("YTCLI_PROFILE").is_some() {
from_account().or_else(|| setting("YTCLI_TOKEN"))
} else {
setting("YTCLI_TOKEN").or_else(from_account)
}
.expect("no token: set YTCLI_TOKEN, or run `ytcli auth login`");
Client::new(&ClientConfig::new(token, org, kind)).expect("client")
}
fn organisation() -> (String, OrgKind, Option<String>) {
if let Some(org) = setting("YTCLI_ORG_ID") {
let kind = match setting("YTCLI_ORG_KIND").as_deref() {
Some("cloud") => OrgKind::Cloud,
_ => OrgKind::Yandex360,
};
return (org, kind, None);
}
let file = ytcli::config::paths::config_file().expect("config path");
let config = ytcli::config::Config::load(&file).expect("config");
let here = std::env::current_dir().expect("cwd");
let resolved = config
.resolve(None, setting("YTCLI_PROFILE").as_deref(), &here)
.expect("no profile: set YTCLI_ORG_ID, or run `ytcli auth login`");
(
resolved.profile.org_id.clone(),
resolved.profile.org_kind,
Some(resolved.profile.account.clone()),
)
}
async fn a_queue(client: &Client) -> String {
if let Some(queue) = setting("YTCLI_TEST_QUEUE") {
return queue;
}
let queues = client.queues().await.expect("queues");
queues
.first()
.expect("the organisation has no queues")
.key
.clone()
}
fn keys(value: &serde_json::Value) -> BTreeSet<String> {
value
.as_object()
.map(|map| map.keys().cloned().collect())
.unwrap_or_default()
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn the_token_belongs_to_somebody() {
let user = client().myself().await.expect("myself");
assert!(!user.id.is_empty(), "a user with no id");
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn every_queue_parses_and_has_a_key() {
let queues = client().queues().await.expect("queues");
assert!(!queues.is_empty(), "no queues to test against");
for queue in &queues {
assert!(!queue.key.is_empty(), "a queue with no key: {queue:?}");
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn an_issue_found_by_search_can_be_fetched() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 5)
.await
.expect("search");
let Some(first) = page.items.first() else {
return; };
let (fetched, _) = client.issue(&first.key).await.expect("issue");
assert_eq!(fetched.key, first.key);
assert!(!fetched.summary.is_empty(), "an issue with no summary");
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn links_are_typed_whatever_language_the_organisation_speaks() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 25)
.await
.expect("search");
for issue in &page.items {
let links = client.issue_links(&issue.key).await.expect("links");
for link in &links {
assert!(
link.kind != ytcli::api::models::LinkKind::Other || link.relation.is_some(),
"{}: a link with neither a known type nor Tracker's own wording",
issue.key
);
}
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_real_issue_has_the_fields_every_command_depends_on() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 1)
.await
.expect("search");
let Some(found) = page.items.first() else {
return;
};
let (issue, raw) = client.issue(&found.key).await.expect("issue");
for field in [
"key",
"summary",
"status",
"queue",
"createdAt",
"updatedAt",
] {
assert!(
keys(&raw).contains(field),
"the API no longer returns `{field}`"
);
}
assert!(!issue.key.is_empty());
assert!(!issue.summary.is_empty());
assert!(issue.status.is_some(), "status did not parse");
assert!(issue.queue.is_some(), "queue did not parse");
assert!(
issue.updated_at.is_some(),
"updatedAt did not parse — Tracker sends `+0300`, not `+03:00`"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn every_entity_kind_accepts_the_fields_we_ask_for() {
let client = client();
for kind in ["project", "portfolio", "goal"] {
client
.entities(kind, None, 1, 5)
.await
.unwrap_or_else(|error| panic!("{kind} search rejected: {error}"));
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn boards_parse_and_keep_their_columns() {
let boards = client().boards().await.expect("boards");
for board in &boards {
assert!(!board.id.is_empty(), "a board with no id");
assert!(
!board.columns.is_empty(),
"board {} has no columns, which no board has",
board.id
);
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn asking_a_board_for_its_sprints_either_answers_or_is_refused() {
let client = client();
let Some(board) = client.boards().await.expect("boards").first().cloned() else {
return;
};
match client.sprints(&board.id).await {
Ok(sprints) => {
for sprint in &sprints {
assert!(!sprint.id.is_empty(), "a sprint with no id");
}
}
Err(error) => {
let said = error.to_string();
assert!(
said.contains("400") || said.contains("404"),
"unexpected failure: {said}"
);
}
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn fields_and_templates_parse() {
let client = client();
let fields = client.fields().await.expect("fields");
assert!(!fields.is_empty(), "an organisation with no fields");
for field in &fields {
assert!(!field.key.is_empty(), "a field with no key: {field:?}");
}
for kind in [
ytcli::api::TemplateKind::Issue,
ytcli::api::TemplateKind::Comment,
] {
for template in &client.templates(kind).await.expect("templates") {
assert!(
!template.name.is_empty(),
"{kind:?}: a template with no name — the payload does not call it `name`"
);
}
}
}
#[tokio::test]
#[ignore = "creates and deletes real entities; needs YTCLI_TEST_QUEUE"]
async fn a_project_can_be_put_in_a_portfolio_and_taken_out() {
if setting("YTCLI_TEST_QUEUE").is_none() {
return;
}
let client = client();
let portfolio = client
.create_entity(
"portfolio",
&serde_json::json!({"summary": "ytcli live test — deleted by this test"}),
)
.await
.expect("create portfolio");
let project = client
.create_entity(
"project",
&serde_json::json!({"summary": "ytcli live test — deleted by this test"}),
)
.await
.expect("create project");
let placed = client
.place_entity("project", &project.id, Some(&portfolio.id), project.version)
.await
.expect("place");
assert_eq!(
placed.parent.as_deref(),
Some(portfolio.id.as_str()),
"the write did not come back as the read we believe in"
);
let removed = client
.place_entity("project", &project.id, None, placed.version)
.await
.expect("remove");
assert!(
removed.parent.is_none(),
"the parent survived being cleared"
);
client
.delete_entity("project", &project.id)
.await
.expect("delete project");
client
.delete_entity("portfolio", &portfolio.id)
.await
.expect("delete portfolio");
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_queue_can_still_be_used_as_a_blueprint() {
let client = client();
let queue = a_queue(&client).await;
let blueprint = client.queue_blueprint(&queue).await.expect("blueprint");
assert!(
blueprint.default_type.is_some(),
"{queue} has no default type"
);
for config in &blueprint.issue_types {
assert!(
config.get("workflow").and_then(|w| w.as_str()).is_some(),
"an issue type with no workflow id: {config}"
);
assert!(
config.get("issueType").and_then(|t| t.as_str()).is_some(),
"an issue type with no key: {config}"
);
}
}
#[tokio::test]
#[ignore = "creates real issues; needs YTCLI_TEST_QUEUE"]
async fn a_link_reads_the_same_way_from_both_ends() {
let Some(queue) = setting("YTCLI_TEST_QUEUE") else {
return;
};
let client = client();
let make = |summary: &'static str| {
let queue = queue.clone();
let client = &client;
async move {
client
.create_issue(&serde_json::json!({
"queue": queue,
"summary": summary,
}))
.await
.expect("create")
}
};
let dependent = make("ytcli live test — depends on the other").await;
let blocker = make("ytcli live test — blocks the other").await;
client
.add_link(&dependent.key, "depends on", &blocker.key)
.await
.expect("link");
let from_dependent = client.issue_links(&dependent.key).await.expect("links");
let from_blocker = client.issue_links(&blocker.key).await.expect("links");
let one = from_dependent
.first()
.expect("no link on the dependent end");
let other = from_blocker.first().expect("no link on the blocking end");
assert_eq!(
one.kind,
ytcli::api::models::LinkKind::Depends,
"{} depends on {}, and its own listing says otherwise",
dependent.key,
blocker.key
);
assert_eq!(
other.kind,
ytcli::api::models::LinkKind::IsDependentBy,
"{} is depended on, and its own listing says otherwise",
blocker.key
);
assert!(!one.id.is_empty(), "a link with no id: {one:?}");
client
.delete_link(&dependent.key, &one.id)
.await
.expect("unlink");
}
fn tags_of(raw: &serde_json::Value) -> Vec<String> {
raw.get("tags")
.and_then(serde_json::Value::as_array)
.map(|tags| {
tags.iter()
.filter_map(serde_json::Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_bulk_change_takes_keys_and_refuses_a_list_it_cannot_do_whole() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 1)
.await
.expect("search");
let Some(issue) = page.items.first() else {
return;
};
let missing = format!("{queue}-99999999");
let error = client
.bulk_update(
&[issue.key.clone(), missing.clone()],
&serde_json::json!({"tags": {"add": ["ytcli-live-should-not-happen"]}}),
)
.await
.expect_err("a change naming an issue that does not exist was accepted");
assert!(
error.to_string().contains(&missing),
"the refusal does not name the key it refused: {error}"
);
let (_, raw) = client.issue(&issue.key).await.expect("issue");
assert!(
!tags_of(&raw).contains(&"ytcli-live-should-not-happen".to_owned()),
"{}: a refused bulk change wrote a tag anyway",
issue.key
);
}
#[tokio::test]
#[ignore = "creates real issues; needs YTCLI_TEST_QUEUE"]
async fn a_bulk_change_finishes_and_counts_what_it_changed() {
const TAG: &str = "ytcli-live-bulk";
let Some(queue) = setting("YTCLI_TEST_QUEUE") else {
return;
};
let client = client();
let mut keys = Vec::new();
for summary in [
"ytcli live test — bulk change, one of two",
"ytcli live test — bulk change, two of two",
] {
let issue = client
.create_issue(&serde_json::json!({"queue": queue, "summary": summary}))
.await
.expect("create");
keys.push(issue.key);
}
let mut change = client
.bulk_update(&keys, &serde_json::json!({"tags": {"add": [TAG]}}))
.await
.expect("bulk update");
for _ in 0..60 {
if change.finished() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
change = client.bulk_change(&change.id).await.expect("bulk status");
}
assert!(change.finished(), "{} never finished", change.id);
assert_eq!(change.total, Some(keys.len() as u64), "{change:?}");
assert_eq!(change.done, change.total, "{change:?}");
assert!(change.succeeded(), "{change:?}");
for key in &keys {
let (_, raw) = client.issue(key).await.expect("issue");
assert!(
tags_of(&raw).iter().any(|tag| tag == TAG),
"{key}: the change said it worked and the issue disagrees"
);
}
client
.bulk_update(&keys, &serde_json::json!({"tags": {"remove": [TAG]}}))
.await
.expect("undo");
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn bulk_transition_and_move_refuse_a_list_they_cannot_do_whole() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 1)
.await
.expect("search");
let Some(issue) = page.items.first() else {
return;
};
let before = client.issue(&issue.key).await.expect("issue").0;
let missing = format!("{queue}-99999999");
let refused = client
.bulk_transition(
&[issue.key.clone(), missing.clone()],
"close",
&serde_json::json!({}),
)
.await
.expect_err("a transition naming an issue that does not exist was accepted");
assert!(
refused.to_string().contains(&missing),
"the refusal does not name the key it refused: {refused}"
);
let refused = client
.bulk_move(
std::slice::from_ref(&issue.key),
"NOSUCHQUEUE0",
false,
false,
)
.await
.expect_err("a move into a queue that does not exist was accepted");
assert!(
refused.to_string().contains("NOSUCHQUEUE0"),
"the refusal does not name the queue it refused: {refused}"
);
let after = client.issue(&issue.key).await.expect("issue").0;
assert_eq!(
before.status_key, after.status_key,
"{}: a refused bulk transition moved the issue anyway",
issue.key
);
assert_eq!(
before.key, after.key,
"a refused bulk move changed a key anyway"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn link_types_are_not_the_names_a_write_takes() {
let types = client().link_types().await.expect("link types");
assert!(!types.is_empty(), "an organisation with no link types");
for kind in &types {
assert!(
kind.outward.is_some() || kind.inward.is_some(),
"{} has no wording for either direction",
kind.id
);
}
let refused = client()
.add_link("PROJ-0", "depends", "PROJ-0")
.await
.expect_err("a type id was accepted as a relationship");
assert!(
format!("{refused}").contains("depends"),
"unexpected refusal: {refused}"
);
}
#[tokio::test]
#[ignore = "creates a real issue; needs YTCLI_TEST_QUEUE"]
async fn a_created_issue_can_be_read_back_and_commented_on() {
let Some(queue) = setting("YTCLI_TEST_QUEUE") else {
return;
};
let client = client();
let created = client
.create_issue(&serde_json::json!({
"queue": queue,
"summary": "ytcli live test — safe to close",
"description": "Created by the ytcli live suite.",
}))
.await
.expect("create");
let (fetched, _) = client.issue(&created.key).await.expect("read back");
assert_eq!(fetched.summary, "ytcli live test — safe to close");
client
.add_comment(&created.key, "ytcli live test comment")
.await
.expect("comment");
let comments = client.issue_comments(&created.key).await.expect("comments");
assert!(
comments
.iter()
.any(|c| c.text.contains("live test comment")),
"the comment did not come back"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn every_dictionary_answers_with_keys() {
let client = client();
for kind in ytcli::api::Dictionary::ALL {
let entries = client.dictionary(kind).await.expect("dictionary");
assert!(!entries.is_empty(), "{} is empty", kind.label());
for entry in &entries {
assert!(!entry.key.is_empty(), "an entry with no key: {entry:?}");
}
}
let statuses = client
.dictionary(ytcli::api::Dictionary::Statuses)
.await
.expect("statuses");
assert!(
statuses.iter().any(|status| status.category.is_some()),
"no status carried a category, which is the column that makes the list readable"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_field_says_what_it_accepts() {
let client = client();
let fields = client.fields().await.expect("fields");
assert!(!fields.is_empty(), "an organisation with no fields");
for field in fields.iter().take(5) {
let spec = client
.field(&field.key)
.await
.unwrap_or_else(|error| panic!("field {}: {error}", field.key));
assert_eq!(spec.key, field.key);
assert!(
!spec.field_type.is_empty() && spec.field_type != "unknown",
"{} has no schema type",
field.key
);
}
let constrained = client.field("assignee").await.expect("assignee");
assert!(
constrained.options.is_some(),
"assignee accepts anybody? {constrained:?}"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn remote_links_answer_with_a_list() {
let client = client();
let queue = a_queue(&client).await;
let page = client
.search(&format!("Queue: {queue}"), 1, 1)
.await
.expect("search");
let Some(issue) = page.items.first() else {
return;
};
client
.issue_remote_links(&issue.key)
.await
.expect("remote links");
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn automation_survives_a_section_it_may_not_read() {
let client = client();
let queue = a_queue(&client).await;
let automation = client
.queue_automation(&queue)
.await
.unwrap_or_else(|error| panic!("automation of {queue}: {error}"));
for refusal in &automation.unreadable {
assert!(
!refusal.reason.is_empty(),
"{} was refused with no reason",
refusal.section
);
}
assert!(
automation.unreadable.len() < 3,
"every section refused should have been an error, not an answer"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn access_resolves_the_roles_that_permissions_only_names() {
let client = client();
let queues = client.queues().await.expect("queues");
let mut refusals = 0;
for queue in &queues {
let Ok(access) = client.queue_access(&queue.key).await else {
refusals += 1;
continue;
};
assert!(
access.access.iter().all(|entry| entry.roles.is_empty()),
"access answered with roles: it is not a resolved list of people"
);
assert!(
access
.permissions
.iter()
.any(|entry| !entry.roles.is_empty()),
"permissions answered without a single role"
);
assert!(
access.you.is_some(),
"the token could not name its own user"
);
return;
}
assert_eq!(
refusals,
queues.len(),
"no queue answered and none was counted as refused"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn sprints_and_local_fields_answer_with_lists() {
let client = client();
let queue = a_queue(&client).await;
client.all_sprints().await.expect("sprints");
client
.queue_local_fields(&queue)
.await
.unwrap_or_else(|error| panic!("local fields of {queue}: {error}"));
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn components_belong_to_the_queue_they_say_they_do() {
let client = client();
let all = client.components(None).await.expect("components");
let Some(queue) = all.iter().find_map(|component| component.queue.clone()) else {
return;
};
let scoped = client
.components(Some(&queue))
.await
.unwrap_or_else(|error| panic!("components of {queue}: {error}"));
assert!(!scoped.is_empty(), "{queue} answered with none of its own");
for component in &scoped {
assert_eq!(component.queue.as_deref(), Some(queue.as_str()));
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn the_directory_pages_and_reports_its_size() {
let page = client().users(1, 3).await.expect("users");
assert!(!page.items.is_empty(), "an organisation with nobody in it");
assert!(
page.items.len() <= 3,
"perPage was ignored: {} came back",
page.items.len()
);
for person in &page.items {
assert!(
!person.login.is_empty(),
"a person with no login: {person:?}"
);
assert!(!person.uid.is_empty(), "a person with no uid: {person:?}");
}
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_login_from_the_listing_can_be_fetched_back() {
let client = client();
let page = client.users(1, 1).await.expect("users");
let Some(first) = page.items.first() else {
return;
};
let fetched = client.user(&first.login).await.expect("user");
assert_eq!(fetched.login, first.login);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn the_worklog_search_takes_a_login_and_not_me() {
let client = client();
let me = client.myself().await.expect("myself");
let login = me.login.clone().unwrap_or(me.id.clone());
client
.worklog_search(Some(&login), Some("2020-01-01"), None, 5)
.await
.expect("worklog search by login");
let refused = client.worklog_search(Some("me"), None, None, 5).await;
assert!(
refused.is_err(),
"Tracker accepted `me` as a login; the CLI resolves it for nothing"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn a_project_can_be_created_changed_and_deleted() {
let client = client();
let created = client
.create_entity(
"project",
&serde_json::json!({"summary": "ytcli live test — deleted by this test"}),
)
.await
.expect("create");
let changed = client
.update_entity(
"project",
&created.id,
&serde_json::json!({"summary": "ytcli live test — renamed"}),
created.version,
)
.await;
let read_back = client.entity("project", &created.id).await;
client
.delete_entity("project", &created.id)
.await
.expect("delete");
let changed = changed.expect("update");
assert_eq!(changed.summary, "ytcli live test — renamed");
assert_eq!(
read_back.expect("read back").summary,
"ytcli live test — renamed",
"the rename did not survive being read through a different endpoint"
);
}
#[tokio::test]
#[ignore = "needs real credentials"]
async fn every_query_the_skill_teaches_is_accepted() {
let client = client();
let queue = a_queue(&client).await;
let text =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("skills/ytcli/yql.md"))
.expect("yql.md");
let queries: Vec<String> = text
.lines()
.filter_map(|line| line.trim().strip_prefix("ytcli issue find --yql "))
.map(|rest| {
rest.trim()
.trim_matches('\'')
.replace("Queue: TRACKER", &format!("Queue: {queue}"))
})
.collect();
assert!(queries.len() >= 10, "found {} queries", queries.len());
let mut refused = Vec::new();
for query in queries {
if client.count(&query).await.is_err() {
refused.push(query);
}
}
assert!(
refused.is_empty(),
"Tracker refused queries the skill teaches: {refused:#?}"
);
}