use super::*;
use crate::client::{GitHubCheckRun, GitHubChecks, GitHubGistFile, GitHubLabel, GitHubUser};
use crate::mirror::{gist_is_whole, SINGLETON};
use serde_json::json;
fn service() -> (tempfile::TempDir, Service) {
let dir = tempfile::tempdir().expect("a scratch directory");
let mirror = Mirror::open(&dir.path().join(crate::mirror::FILE)).expect("a mirror");
(dir, Service::new(mirror))
}
fn issue(number: i64, state: &str, updated: &str) -> Document {
let issue = GitHubIssue {
number,
title: format!("Issue {number}"),
body: None,
state: state.to_string(),
user: GitHubUser {
login: "someone".to_string(),
avatar_url: None,
},
labels: vec![GitHubLabel {
name: "bug".to_string(),
color: "ff0000".to_string(),
description: Some("Bug".to_string()),
}],
comments: 0,
created_at: "2026-03-28T10:00:00Z".to_string(),
updated_at: updated.to_string(),
closed_at: None,
html_url: format!("https://github.com/example/repo/issues/{number}"),
};
Document::of(number.to_string(), serde_json::to_value(issue).expect("serialises"))
}
fn gist(id: &str, content: Option<&str>) -> Document {
let mut files = std::collections::BTreeMap::new();
files.insert(
"note.md".to_string(),
GitHubGistFile {
filename: Some("note.md".to_string()),
mime_type: None,
language: None,
raw_url: None,
size: None,
truncated: None,
content: content.map(str::to_string),
},
);
let gist = GitHubGist {
id: id.to_string(),
description: Some("notes".to_string()),
public: false,
html_url: format!("https://gist.github.com/{id}"),
created_at: "2026-03-01T10:00:00Z".to_string(),
updated_at: "2026-03-02T10:00:00Z".to_string(),
owner: None,
files,
};
let payload = serde_json::to_value(&gist).expect("serialises");
if gist_is_whole(&gist) {
Document::of(id, payload)
} else {
Document::summary(id, payload)
}
}
fn board_payload(id: &str, number: i64, options: serde_json::Value) -> serde_json::Value {
json!({
"id": id, "number": number, "title": format!("board {number}"),
"url": format!("https://github.com/orgs/o/projects/{number}"),
"short_description": null, "closed": false,
"updated_at": "2026-08-21T00:00:00Z", "owner": "o",
"fields": [{ "id": "F_STATUS", "name": "Status", "kind": "single_select", "options": options }],
})
}
#[test]
fn test10013_a_read_serves_the_mirror_with_its_age() {
let (_dir, service) = service();
let scope = Scope::Issues("example/repo".to_string());
service
.mirror()
.put(
&scope,
&[
issue(1, "open", "2026-03-28T10:00:00Z"),
issue(2, "closed", "2026-03-29T10:00:00Z"),
issue(3, "open", "2026-03-30T10:00:00Z"),
],
)
.expect("stored");
service
.mirror()
.synced(&scope, Some("etag"), Some("2026-03-30T10:00:00Z"))
.expect("recorded");
let repos = vec!["example/repo".to_string()];
let all = service.issues(&repos, IssueState::All).expect("read");
assert_eq!(all.issues.len(), 3);
assert!(all.failures.is_empty(), "a repository that synced cleanly reports no failure");
let standing = &all.sync["example/repo"];
assert!(standing.ever_synced);
assert!(standing.synced_at.is_some(), "a reader must be able to say how old this is");
assert_eq!(standing.last_error, None);
assert_eq!(all.issues[0]["number"], 3);
assert_eq!(all.issues[2]["number"], 1);
assert_eq!(all.issues[0]["repo"], "example/repo");
assert_eq!(service.issues(&repos, IssueState::Open).expect("read").issues.len(), 2);
assert_eq!(service.issues(&repos, IssueState::Closed).expect("read").issues.len(), 1);
assert!("sometimes".parse::<IssueState>().is_err());
}
#[test]
fn test10014_a_failed_repository_still_serves_what_it_holds() {
let (_dir, service) = service();
let scope = Scope::Issues("example/repo".to_string());
service
.mirror()
.put(&scope, &[issue(1, "open", "2026-03-28T10:00:00Z")])
.expect("stored");
service.mirror().synced(&scope, Some("etag"), None).expect("recorded");
service.mirror().failed(&scope, "GitHub API error status 403").expect("recorded");
let held = service
.issues(&["example/repo".to_string()], IssueState::Open)
.expect("read");
assert_eq!(held.issues.len(), 1, "the data survives its refresh failing");
assert_eq!(
held.failures,
vec![RepoFailure {
repo: "example/repo".to_string(),
error: "GitHub API error status 403".to_string()
}]
);
assert!(held.sync["example/repo"].ever_synced, "a failure must not erase that it once worked");
}
#[test]
fn test10015_an_unsynced_repository_is_not_a_failure() {
let (_dir, service) = service();
let held = service
.issues(&["example/fresh".to_string()], IssueState::Open)
.expect("read");
assert!(held.issues.is_empty());
assert!(held.failures.is_empty(), "never asked is not the same as failed");
assert_eq!(held.sync["example/fresh"], Standing::default());
}
#[test]
fn test10016_auth_errors_are_told_apart() {
assert!(Failure::GitHub(anyhow::anyhow!("failed with GitHub API status 401")).is_authorization());
assert!(Failure::GitHub(anyhow::anyhow!("listing rulesets failed with GitHub API status 403")).is_authorization());
assert!(!Failure::GitHub(anyhow::anyhow!("connection reset")).is_authorization());
assert!(!Failure::Invalid("status 401 in a title".to_string()).is_authorization());
assert!(Failure::NoToken("no token".to_string()).is_authorization());
}
#[test]
fn test10017_the_list_serves_without_a_credential() {
let (_dir, service) = service();
service.mirror().put(&Scope::Gists, &[gist("aaa", None), gist("bbb", None)]).expect("stored");
service.mirror().synced(&Scope::Gists, Some("etag"), None).expect("recorded");
let listed = service.gists().expect("read");
assert_eq!(listed.items.len(), 2);
assert!(listed.sync.ever_synced);
}
#[tokio::test]
async fn test10018_a_summary_is_a_miss_and_a_whole_gist_is_a_hit() {
let (_dir, service) = service();
service.mirror().put(&Scope::Gists, &[gist("aaa", None)]).expect("stored");
let miss = service.gist(None, "aaa").await.expect_err("a summary was served as the gist");
assert!(matches!(miss, Failure::NoToken(_)), "{miss}");
assert!(
miss.to_string().contains("summary"),
"the refusal must say why the gist cannot be shown: {miss}"
);
service.mirror().put_one(&Scope::Gists, gist("aaa", Some("# notes"))).expect("stored");
let hit = service.gist(None, "aaa").await.expect("a whole gist is served from the mirror");
assert!(hit.from_mirror);
assert_eq!(hit.gist["files"]["note.md"]["content"], "# notes");
}
#[test]
fn test10028_never_synced_is_not_the_same_as_empty() {
let (_dir, service) = service();
let scope = Scope::Pulls("owner/repo".to_string());
let never = service.list(&scope).expect("read");
assert!(never.items.is_empty());
assert_eq!(never.sync, Standing::default(), "never asked is neither synced nor failed");
service.mirror().put(&scope, &[]).expect("stored");
service.mirror().synced(&scope, Some("etag"), None).expect("recorded");
let empty = service.list(&scope).expect("read");
assert!(empty.items.is_empty());
assert!(empty.sync.ever_synced && empty.sync.synced_at.is_some());
}
#[test]
fn test10029_a_failed_scope_serves_and_reports() {
let (_dir, service) = service();
let scope = Scope::Releases("owner/repo".to_string());
service
.mirror()
.put(&scope, &[Document::of("1", json!({ "tag_name": "v1.0.0", "draft": false }))])
.expect("stored");
service.mirror().synced(&scope, None, None).expect("recorded");
service.mirror().failed(&scope, "status 403").expect("recorded");
let held = service.list(&scope).expect("read");
assert_eq!(held.items.len(), 1, "a failed refresh must not empty a list");
assert_eq!(held.sync.last_error.as_deref(), Some("status 403"));
assert!(held.sync.ever_synced, "what worked before still counts");
}
#[test]
fn test10030_privacy_and_ci_are_null_until_known() {
let (_dir, service) = service();
let checkout = vec![Repository {
name: "owner/repo".to_string(),
path: std::path::PathBuf::from("/nowhere"),
head: Some("c2".to_string()),
}];
let unknown = service.overview(&checkout).expect("read");
assert_eq!(unknown["owner/repo"].private, None, "privacy was guessed before it was read");
assert_eq!(unknown["owner/repo"].checks, None);
service
.mirror()
.put_only(
&Scope::Repository("owner/repo".to_string()),
json!({ "full_name": "owner/repo", "private": true, "archived": false, "stargazers_count": 12 }),
)
.expect("stored");
for (commit, state) in [("c1", "failure"), ("c2", "success")] {
service
.mirror()
.put(
&Scope::Checks { repo: "owner/repo".to_string(), commit: commit.to_string() },
&[Document::of(SINGLETON, json!({ "sha": commit, "state": state, "runs": [] }))
.with_state(state)],
)
.expect("stored");
}
service.mirror().put(&Scope::Issues("owner/repo".to_string()), &[issue(1, "open", "2026-01-01T00:00:00Z")]).expect("stored");
let known = service.overview(&checkout).expect("read");
let facts = &known["owner/repo"];
assert_eq!(facts.private, Some(true));
assert_eq!(facts.stars, Some(12));
assert_eq!(facts.open_issues, 1);
assert_eq!(
facts.checks.as_deref(),
Some("success"),
"CI was read for a commit this checkout is not on"
);
}
#[test]
fn test10031_the_check_rollup_reports_the_worst() {
fn run(name: &str, status: &str, conclusion: Option<&str>) -> GitHubCheckRun {
GitHubCheckRun {
name: name.to_string(),
status: status.to_string(),
conclusion: conclusion.map(str::to_string),
url: None,
}
}
assert_eq!(GitHubChecks::rollup(&[]), "neutral", "nothing ran");
assert_eq!(GitHubChecks::rollup(&[run("build", "completed", Some("success"))]), "success");
assert_eq!(
GitHubChecks::rollup(&[
run("build", "completed", Some("success")),
run("test", "completed", Some("failure")),
]),
"failure"
);
assert_eq!(
GitHubChecks::rollup(&[
run("build", "completed", Some("success")),
run("test", "in_progress", None),
]),
"pending"
);
assert_eq!(
GitHubChecks::rollup(&[
run("build", "completed", Some("skipped")),
run("test", "completed", Some("cancelled")),
]),
"neutral"
);
assert_eq!(GitHubChecks::rollup(&[run("build", "completed", Some("timed_out"))]), "failure");
}
#[test]
fn test10032_a_card_from_an_older_version_reads_in_todays_shape() {
let (_dir, service) = service();
let scope = Scope::ProjectItems("PVT_1".to_string());
service
.mirror()
.put(
&scope,
&[Document::of(
"ITEM_1",
json!({
"id": "ITEM_1", "kind": "ISSUE", "title": "card ITEM_1",
"updated_at": "2026-03-01T00:00:00Z",
"field_values": { "FIELD": { "kind": "single_select", "option_id": "OPT_TODO", "name": "Todo" } }
}),
)],
)
.expect("stored");
let served = service.cards("PVT_1").expect("the cards read");
assert_eq!(served.items.len(), 1);
let card = serde_json::to_value(&served.items[0]).expect("serialises");
assert_eq!(card["title"], "card ITEM_1");
assert_eq!(card["labels"], json!([]));
assert_eq!(card["assignees"], json!([]));
assert_eq!(card["comment_count"], 0);
assert_eq!(card["archived"], false);
assert!(card["body"].is_null());
assert_eq!(card["field_values"]["FIELD"]["option_id"], "OPT_TODO");
}
#[test]
fn test10036_a_payload_that_is_not_a_card_is_raised() {
let (_dir, service) = service();
service
.mirror()
.put(&Scope::ProjectItems("PVT_1".to_string()), &[Document::of("ITEM_1", json!({ "what": "not a card" }))])
.expect("stored");
let refusal = service.cards("PVT_1").expect_err("a payload that is not a card read as one");
assert!(
refusal.to_string().contains("sync the board"),
"the refusal has to say what to do about it: {refusal}"
);
}
#[test]
fn test10198_an_incomplete_column_configuration_is_refused() {
let (_dir, service) = service();
let lists = Scope::Projects("o".to_string());
service
.mirror()
.put(&lists, &[Document::of("PVT_1", board_payload("PVT_1", 1, json!([{ "id": "OPT_1", "name": "Todo", "color": "GRAY" }])))])
.expect("stored");
service.mirror().synced(&lists, None, None).expect("recorded");
let refusal = service
.boards(&["o/1".parse().expect("a board")])
.expect_err("an incomplete board was served");
assert!(refusal.to_string().contains("sync again"), "{refusal}");
}
#[test]
fn referenced_boards_are_served_in_the_projects_order() {
let (_dir, service) = service();
let lists = Scope::Projects("o".to_string());
let options = json!([{ "id": "OPT_1", "name": "Todo", "color": "GRAY", "description": "" }]);
service
.mirror()
.put(
&lists,
&[
Document::of("PVT_1", board_payload("PVT_1", 1, options.clone())),
Document::of("PVT_2", board_payload("PVT_2", 2, options)),
],
)
.expect("stored");
service.mirror().synced(&lists, None, None).expect("recorded");
let list = service
.boards(&["o/2".parse().unwrap(), "o/1".parse().unwrap(), "o/9".parse().unwrap()])
.expect("read");
let numbers: Vec<i64> = list.boards.iter().map(|board| board.number).collect();
assert_eq!(numbers, vec![2, 1]);
assert_eq!(list.problems, vec!["o has no board 9".to_string()]);
assert!(list.sync["projects:o"].ever_synced);
}
fn owned_board(id: &str, owner: &str, number: i64, closed: bool, updated: &str) -> Document {
Document::of(
id,
json!({
"id": id, "number": number, "title": format!("{owner} {number}"),
"url": format!("https://github.com/orgs/{owner}/projects/{number}"),
"short_description": null, "closed": closed,
"updated_at": updated, "owner": owner, "fields": [],
}),
)
}
fn hold(service: &Service, owner: &str, boards: &[Document]) {
let list = Scope::Projects(owner.to_string());
service.mirror().put(&list, boards).expect("stored");
service.mirror().synced(&list, None, None).expect("recorded");
}
#[test]
fn test20005_every_owners_boards_are_listed_and_references_are_marked() {
let (_dir, service) = service();
service
.mirror()
.put_only(&Scope::Viewer, json!({ "login": "bahram", "organizations": ["zeta", "Alpha"] }))
.expect("stored");
service.mirror().synced(&Scope::Viewer, None, None).expect("recorded");
hold(&service, "bahram", &[owned_board("PVT_B1", "bahram", 1, false, "2026-01-01T00:00:00Z")]);
hold(
&service,
"zeta",
&[
owned_board("PVT_Z1", "zeta", 1, true, "2026-09-01T00:00:00Z"),
owned_board("PVT_Z2", "zeta", 2, false, "2026-02-01T00:00:00Z"),
owned_board("PVT_Z3", "zeta", 3, false, "2026-03-01T00:00:00Z"),
],
);
hold(&service, "Alpha", &[owned_board("PVT_A4", "Alpha", 4, false, "2026-01-01T00:00:00Z")]);
hold(&service, "someone", &[owned_board("PVT_S7", "someone", 7, false, "2026-01-01T00:00:00Z")]);
let all = service
.all_boards(&["someone/7".parse().unwrap(), "zeta/2".parse().unwrap()])
.expect("read");
let owners: Vec<(&str, Relation)> =
all.owners.iter().map(|owner| (owner.login.as_str(), owner.relation)).collect();
assert_eq!(
owners,
vec![
("bahram", Relation::Account),
("Alpha", Relation::Organization),
("zeta", Relation::Organization),
("someone", Relation::Referenced),
],
"the account, then its organisations by name, then other referenced owners"
);
let ids: Vec<&str> = all.boards.iter().map(|board| board.id.as_str()).collect();
assert_eq!(
ids,
vec!["PVT_B1", "PVT_A4", "PVT_Z3", "PVT_Z2", "PVT_Z1", "PVT_S7"],
"owner by owner; open before closed; most recently changed first"
);
assert_eq!(all.referenced, vec!["PVT_S7".to_string(), "PVT_Z2".to_string()], "in the project's order");
assert!(all.problems.is_empty(), "{:?}", all.problems);
assert!(all.account.ever_synced);
}
#[test]
fn test20006_what_could_not_be_listed_is_said() {
let (_dir, service) = service();
hold(&service, "o", &[owned_board("PVT_1", "o", 1, false, "2026-01-01T00:00:00Z")]);
let all = service
.all_boards(&["o/1".parse().unwrap(), "o/9".parse().unwrap(), "unread/1".parse().unwrap()])
.expect("read");
assert_eq!(all.boards.len(), 1, "what is held is still listed");
assert_eq!(all.referenced, vec!["PVT_1".to_string()]);
assert!(
all.problems.iter().any(|p| p.starts_with("who the GitHub token belongs to has not been read")),
"{:?}",
all.problems
);
assert!(all.problems.contains(&"o has no board 9".to_string()), "{:?}", all.problems);
assert!(
all.problems.contains(&"unread's boards have not been read yet".to_string()),
"{:?}",
all.problems
);
assert_eq!(
all.problems.iter().filter(|p| p.contains("unread")).count(),
1,
"an unread owner is reported once, not again for each board referenced on it: {:?}",
all.problems
);
}