use super::*;
use crate::client::{GitHubGist, GitHubGistFile};
fn mirror() -> (tempfile::TempDir, Mirror) {
let dir = tempfile::tempdir().expect("a scratch directory");
let mirror = Mirror::open(&dir.path().join(FILE)).expect("a mirror");
(dir, mirror)
}
fn issue(number: i64, state: &str, updated: &str) -> Document {
Document::of(
number.to_string(),
serde_json::json!({
"number": number,
"title": format!("Issue {number}"),
"state": state,
"updated_at": updated,
}),
)
}
fn gist(id: &str, updated: &str, content: Option<&str>) -> GitHubGist {
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),
},
);
GitHubGist {
id: id.to_string(),
description: Some(format!("gist {id}")),
public: false,
html_url: format!("https://gist.github.com/{id}"),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: updated.to_string(),
owner: None,
files,
}
}
fn gist_document(gist: &GitHubGist) -> Document {
let payload = serde_json::to_value(gist).expect("a gist serialises");
if gist_is_whole(gist) {
Document::of(gist.id.clone(), payload)
} else {
Document::summary(gist.id.clone(), payload)
}
}
#[test]
fn test10004_incremental_scopes_add_and_authoritative_scopes_replace() {
let (_dir, mirror) = mirror();
let issues = Scope::Issues("owner/repo".to_string());
assert!(!issues.is_authoritative(), "issues are fetched incrementally");
assert!(Scope::Gists.is_authoritative(), "gists are fetched whole");
mirror
.put(
&issues,
&[
issue(1, "open", "2026-01-02T00:00:00Z"),
issue(2, "open", "2026-01-03T00:00:00Z"),
],
)
.expect("stored");
assert_eq!(mirror.documents(&issues).expect("read").len(), 2);
mirror
.put(&issues, &[issue(2, "closed", "2026-01-04T00:00:00Z")])
.expect("stored");
let held = mirror.documents(&issues).expect("read");
assert_eq!(held.len(), 2, "an incremental answer must not clear the rest");
assert_eq!(held[0].key, "2");
assert_eq!(held[0].state.as_deref(), Some("closed"), "the update landed");
assert_eq!(held[1].state.as_deref(), Some("open"), "the untouched issue is untouched");
assert_eq!(
mirror.open_counts("issues").expect("counts").get("owner/repo"),
Some(&1)
);
mirror
.put(
&Scope::Gists,
&[
gist_document(&gist("aaa", "2026-01-02T00:00:00Z", None)),
gist_document(&gist("bbb", "2026-01-01T00:00:00Z", None)),
],
)
.expect("stored");
mirror
.put(
&Scope::Gists,
&[gist_document(&gist("aaa", "2026-01-03T00:00:00Z", None))],
)
.expect("stored");
assert_eq!(mirror.documents(&Scope::Gists).expect("read").len(), 1);
assert!(mirror.document(&Scope::Gists, "bbb").expect("read").is_none());
}
#[test]
fn test10005_a_failed_sync_keeps_the_data_and_reports_itself() {
let (_dir, mirror) = mirror();
let scope = Scope::Issues("owner/repo".to_string());
mirror
.put(&scope, &[issue(1, "open", "2026-01-02T00:00:00Z")])
.expect("stored");
mirror
.synced(&scope, Some("etag-one"), Some("2026-01-02T00:00:00Z"))
.expect("recorded");
mirror.failed(&scope, "GitHub API error status 403").expect("recorded");
let state = mirror.state(&scope).expect("state");
assert_eq!(state.last_error.as_deref(), Some("GitHub API error status 403"));
assert!(state.ever_synced(), "a failure must not erase that it once worked");
assert_eq!(
state.etag.as_deref(),
Some("etag-one"),
"the next attempt must still be conditional"
);
assert_eq!(state.cursor.as_deref(), Some("2026-01-02T00:00:00Z"));
assert_eq!(
mirror.documents(&scope).expect("read").len(),
1,
"a failed refresh must not empty the list"
);
mirror.synced(&scope, None, None).expect("recorded");
let state = mirror.state(&scope).expect("state");
assert_eq!(state.last_error, None);
assert_eq!(
state.etag.as_deref(),
Some("etag-one"),
"a 304 carries no ETag of its own; the one we sent is still current"
);
}
#[test]
fn test10006_a_summary_never_overwrites_what_is_whole() {
let (_dir, mirror) = mirror();
mirror
.put(
&Scope::Gists,
&[gist_document(&gist("aaa", "2026-01-02T00:00:00Z", None))],
)
.expect("stored");
assert!(
!mirror
.document(&Scope::Gists, "aaa")
.expect("read")
.expect("held")
.complete,
"a list row is a summary, and must say so"
);
mirror
.put_one(
&Scope::Gists,
gist_document(&gist("aaa", "2026-01-02T00:00:00Z", Some("the body"))),
)
.expect("stored");
let held = mirror.document(&Scope::Gists, "aaa").expect("read").expect("held");
assert!(held.complete);
assert_eq!(held.payload["files"]["note.md"]["content"], "the body");
mirror
.put(
&Scope::Gists,
&[gist_document(&gist("aaa", "2026-01-03T00:00:00Z", None))],
)
.expect("stored");
let held = mirror.document(&Scope::Gists, "aaa").expect("read").expect("held");
assert!(held.complete, "a summary must not overwrite known content");
assert_eq!(held.payload["files"]["note.md"]["content"], "the body");
assert_eq!(
held.updated_at.as_deref(),
Some("2026-01-03T00:00:00Z"),
"the columns still move: the summary knew something the content did not"
);
}
#[test]
fn test10007_forgetting_a_repository_forgets_every_kind_of_it() {
let (_dir, mirror) = mirror();
let gone = "owner/gone";
let kinds = [
Scope::Issues(gone.to_string()),
Scope::Pulls(gone.to_string()),
Scope::Releases(gone.to_string()),
Scope::Tags(gone.to_string()),
Scope::Repository(gone.to_string()),
Scope::Labels(gone.to_string()),
Scope::Comments {
repo: gone.to_string(),
number: 7,
},
];
for scope in &kinds {
mirror
.put(
scope,
&[Document::of("1", serde_json::json!({"state": "open"}))],
)
.expect("stored");
mirror.synced(scope, Some("etag"), None).expect("recorded");
}
let kept = Scope::Issues("owner/kept".to_string());
mirror
.put(&kept, &[issue(1, "open", "2026-01-02T00:00:00Z")])
.expect("stored");
mirror.synced(&kept, None, None).expect("recorded");
let forgotten = mirror.forget_repository(gone).expect("forgotten");
assert_eq!(forgotten, kinds.len(), "every scope of that repository goes");
for scope in &kinds {
assert!(
mirror.documents(scope).expect("read").is_empty(),
"{} still holds documents",
scope.key()
);
assert_eq!(mirror.state(scope).expect("state"), SyncState::default());
}
assert_eq!(
mirror.documents(&kept).expect("read").len(),
1,
"forgetting one repository must not touch another"
);
assert_eq!(Scope::Gists.repository(), None);
}
#[test]
fn test10008_the_cursor_is_the_newest_update_seen() {
let documents = [
issue(1, "open", "2026-01-02T00:00:00Z"),
issue(2, "open", "2026-01-05T00:00:00Z"),
issue(3, "closed", "2026-01-03T00:00:00Z"),
];
assert_eq!(
newest_update(documents.iter()).as_deref(),
Some("2026-01-05T00:00:00Z")
);
assert_eq!(newest_update(std::iter::empty()), None);
}
#[test]
fn test10026_every_scope_round_trips() {
let every = [
Scope::Issues("owner/repo".to_string()),
Scope::Pulls("owner/repo".to_string()),
Scope::Checks {
repo: "owner/repo".to_string(),
commit: "0123456789abcdef0123456789abcdef01234567".to_string(),
},
Scope::Releases("owner/repo".to_string()),
Scope::Tags("owner/repo".to_string()),
Scope::Repository("owner/repo".to_string()),
Scope::Milestones("owner/repo".to_string()),
Scope::Labels("owner/repo".to_string()),
Scope::Traffic("owner/repo".to_string()),
Scope::Comments {
repo: "owner/repo".to_string(),
number: 42,
},
Scope::Gists,
Scope::Notifications,
Scope::RateLimit,
Scope::Viewer,
Scope::Projects("machinefabric".to_string()),
Scope::ProjectItems("PVT_kwDOA".to_string()),
];
for scope in &every {
let key = scope.key();
assert_eq!(
Scope::parse(&key).as_ref(),
Some(scope),
"{key} does not read back as itself"
);
}
let keys: BTreeSet<String> = every.iter().map(Scope::key).collect();
assert_eq!(keys.len(), every.len(), "two scopes share a key");
assert_eq!(Scope::parse("nonsense"), None);
assert_eq!(Scope::parse("comments:owner/repo"), None, "a comment scope names an issue");
assert_eq!(Scope::parse("comments:owner/repo#x"), None, "and the number is a number");
assert_eq!(Scope::parse("checks:owner/repo"), None, "a checks scope names a commit");
assert_eq!(Scope::parse("checks:owner/repo@"), None, "and the commit is not empty");
assert_eq!(Scope::parse("issues:"), None, "a repository scope names a repository");
}
#[test]
fn a_write_that_changes_nothing_is_not_a_change() {
let (_dir, mirror) = mirror();
let scope = Scope::Issues("owner/repo".to_string());
assert_eq!(mirror.latest_change().expect("read"), 0, "nothing has ever changed");
assert!(mirror
.put(&scope, &[issue(1, "open", "2026-01-02T00:00:00Z")])
.expect("stored"));
let after_first = mirror.latest_change().expect("read");
assert_eq!(after_first, 1);
assert!(!mirror
.put(&scope, &[issue(1, "open", "2026-01-02T00:00:00Z")])
.expect("stored"));
assert_eq!(
mirror.latest_change().expect("read"),
after_first,
"an unchanged answer was logged as a change"
);
assert_eq!(
mirror.changes_since(after_first).expect("read"),
Changes::Moved {
scopes: Vec::new(),
latest: after_first
}
);
assert!(mirror
.put(&scope, &[issue(1, "closed", "2026-01-03T00:00:00Z")])
.expect("stored"));
assert_eq!(
mirror.changes_since(after_first).expect("read"),
Changes::Moved {
scopes: vec![scope.key()],
latest: after_first + 1
}
);
}
#[test]
fn a_change_made_by_one_opener_is_seen_by_another() {
let dir = tempfile::tempdir().expect("a scratch directory");
let path = dir.path().join(FILE);
let reader = Mirror::open(&path).expect("the reader's mirror");
let writer = Mirror::open(&path).expect("the writer's mirror");
let seen = reader.latest_change().expect("read");
writer
.put_one(
&Scope::Gists,
Document::of("abc", serde_json::json!({"id": "abc", "files": {}})),
)
.expect("stored");
match reader.changes_since(seen).expect("read") {
Changes::Moved { scopes, .. } => assert_eq!(scopes, vec!["gists".to_string()]),
other => panic!("the other opener's write was not seen: {other:?}"),
}
}
#[test]
fn a_reader_behind_the_log_is_told_it_lost_track() {
let (_dir, mirror) = mirror();
let scope = Scope::Issues("owner/repo".to_string());
for number in 0..(CHANGES_KEPT + 5) {
mirror
.put(&scope, &[issue(number, "open", "2026-01-02T00:00:00Z")])
.expect("stored");
}
let latest = mirror.latest_change().expect("read");
assert_eq!(latest, CHANGES_KEPT + 5);
assert_eq!(
mirror.changes_since(0).expect("read"),
Changes::Lost { latest },
"changes that fell off the log were reported as complete"
);
assert!(matches!(
mirror.changes_since(latest - 1).expect("read"),
Changes::Moved { ref scopes, .. } if scopes == &vec![scope.key()]
));
}
#[test]
fn a_mirror_of_another_schema_is_refused() {
let dir = tempfile::tempdir().expect("a scratch directory");
let path = dir.path().join(FILE);
{
let connection = rusqlite::Connection::open(&path).expect("a database");
connection
.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
.expect("versioned");
}
let refusal = Mirror::open(&path).expect_err("a newer mirror was opened");
assert!(
refusal.to_string().contains("schema version"),
"the refusal does not say why: {refusal}"
);
}
#[test]
fn a_held_scope_is_known_and_unread() {
let (_dir, mirror) = mirror();
let thread = Scope::Comments {
repo: "owner/repo".to_string(),
number: 3,
};
mirror.hold(&thread).expect("held");
assert!(mirror.states().expect("read").contains_key(&thread.key()));
let state = mirror.state(&thread).expect("read");
assert!(!state.ever_synced());
assert_eq!(state.attempted_at, None, "holding is not an attempt");
mirror.synced(&thread, Some("etag"), None).expect("recorded");
mirror.hold(&thread).expect("held again");
assert!(mirror.state(&thread).expect("read").ever_synced());
}
#[test]
fn test10027_a_single_document_scope_answers_with_the_thing() {
let (_dir, mirror) = mirror();
let scope = Scope::Repository("owner/repo".to_string());
assert_eq!(mirror.only(&scope).expect("read"), None, "nothing is held yet");
mirror
.put_only(
&scope,
serde_json::json!({ "full_name": "owner/repo", "private": true }),
)
.expect("stored");
let held = mirror.only(&scope).expect("read").expect("held");
assert_eq!(held["private"], true);
mirror
.put_only(
&scope,
serde_json::json!({ "full_name": "owner/repo", "private": false }),
)
.expect("stored");
assert_eq!(mirror.documents(&scope).expect("read").len(), 1);
assert_eq!(
mirror.only(&scope).expect("read").expect("held")["private"],
false
);
}
#[test]
fn test10197_a_project_column_write_replaces_board_and_cards_together() {
let (_dir, mirror) = mirror();
let boards = Scope::Projects("machinefabric".to_string());
let items = Scope::ProjectItems("PVT_1".to_string());
mirror
.put(
&boards,
&[Document::of(
"PVT_1",
serde_json::json!({ "id": "PVT_1", "columns": ["Todo", "Done"] }),
)],
)
.expect("old board stored");
mirror
.put(
&items,
&[
Document::of("PVTI_1", serde_json::json!({ "column": "Todo" })),
Document::of("PVTI_2", serde_json::json!({ "column": "Done" })),
],
)
.expect("old cards stored");
mirror
.put_project_write(
&boards,
Document::of(
"PVT_1",
serde_json::json!({ "id": "PVT_1", "columns": ["Done"] }),
),
&items,
&[Document::of(
"PVTI_2",
serde_json::json!({ "column": serde_json::Value::Null }),
)],
)
.expect("the post-mutation snapshot stored");
assert_eq!(
mirror
.document(&boards, "PVT_1")
.expect("board read")
.expect("board held")
.payload["columns"],
serde_json::json!(["Done"]),
);
assert!(
mirror
.document(&items, "PVTI_1")
.expect("card read")
.is_none(),
"the complete post-write card answer must prune absent cards",
);
assert_eq!(
mirror
.document(&items, "PVTI_2")
.expect("card read")
.expect("card held")
.payload["column"],
serde_json::Value::Null,
"the affected card is stored from GitHub's post-mutation answer",
);
assert!(
mirror
.put_project_write(
&Scope::Gists,
Document::of("PVT_1", serde_json::json!({})),
&items,
&[],
)
.is_err(),
"an unrelated scope must be rejected before a transaction starts",
);
assert!(
mirror
.put_project_write(
&boards,
Document::of("PVT_1", serde_json::json!({})),
&Scope::ProjectItems("PVT_OTHER".to_string()),
&[],
)
.is_err(),
"a board and another project's cards cannot become one snapshot",
);
}