use std::sync::Arc;
use async_trait::async_trait;
use reqwest::{Method, StatusCode};
use crate::backend::{BackendConnector, BackendFeature, DeleteReason};
use crate::http::{client, ClientOpts, HttpClient};
use crate::record::{Record, RecordId, RecordStatus};
use crate::taint::Untainted;
use crate::{Error, Result};
#[derive(Debug, Clone)]
pub struct SimBackend {
http: Arc<HttpClient>,
origin: String,
agent_header: String,
}
impl SimBackend {
pub fn new(origin: String) -> Result<Self> {
Self::with_agent_suffix(origin, None)
}
pub fn with_agent_suffix(origin: String, suffix: Option<&str>) -> Result<Self> {
let http = client(ClientOpts::default())?;
let agent_header = match suffix {
Some(s) => format!("reposix-core-simbackend-{}-{s}", std::process::id()),
None => format!("reposix-core-simbackend-{}", std::process::id()),
};
Ok(Self {
http: Arc::new(http),
origin,
agent_header,
})
}
fn base(&self) -> &str {
self.origin.trim_end_matches('/')
}
fn agent_only(&self) -> Vec<(&str, &str)> {
vec![("X-Reposix-Agent", self.agent_header.as_str())]
}
fn json_headers(&self) -> Vec<(&str, &str)> {
vec![
("Content-Type", "application/json"),
("X-Reposix-Agent", self.agent_header.as_str()),
]
}
}
async fn decode_issue(resp: reqwest::Response, context: &str) -> Result<Record> {
let status = resp.status();
let bytes = resp.bytes().await?;
if status == StatusCode::NOT_FOUND {
let (project, id) = parse_project_id_from_url(context);
return Err(Error::NotFound { project, id });
}
if !status.is_success() {
return Err(Error::Other(format!(
"sim returned {status} for {context}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let issue: Record = serde_json::from_slice(&bytes)?;
Ok(issue)
}
fn parse_project_id_from_url(url: &str) -> (String, String) {
let path = url.split('?').next().unwrap_or(url);
if let Some(rest) = path.split("/projects/").nth(1) {
let mut parts = rest.splitn(3, '/');
let project = parts.next().unwrap_or("").to_owned();
let _ = parts.next();
let id = parts.next().unwrap_or("").to_owned();
return (project, id);
}
(String::new(), url.to_owned())
}
async fn decode_issues(resp: reqwest::Response, context: &str) -> Result<Vec<Record>> {
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Error::Other(format!(
"sim returned {status} for {context}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let list: Vec<Record> = serde_json::from_slice(&bytes)?;
Ok(list)
}
fn render_patch_body(issue: &Record) -> Result<Vec<u8>> {
let mut map = serde_json::Map::new();
map.insert(
"title".into(),
serde_json::Value::String(issue.title.clone()),
);
map.insert("body".into(), serde_json::Value::String(issue.body.clone()));
map.insert(
"status".into(),
serde_json::Value::String(status_to_str(issue.status).to_owned()),
);
match &issue.assignee {
None => {
map.insert("assignee".into(), serde_json::Value::Null);
}
Some(a) => {
map.insert("assignee".into(), serde_json::Value::String(a.clone()));
}
}
map.insert(
"labels".into(),
serde_json::Value::Array(
issue
.labels
.iter()
.map(|l| serde_json::Value::String(l.clone()))
.collect(),
),
);
Ok(serde_json::to_vec(&serde_json::Value::Object(map))?)
}
fn render_create_body(issue: &Record) -> Result<Vec<u8>> {
let mut map = serde_json::Map::new();
map.insert(
"title".into(),
serde_json::Value::String(issue.title.clone()),
);
map.insert("body".into(), serde_json::Value::String(issue.body.clone()));
map.insert(
"status".into(),
serde_json::Value::String(status_to_str(issue.status).to_owned()),
);
if let Some(a) = &issue.assignee {
map.insert("assignee".into(), serde_json::Value::String(a.clone()));
}
map.insert(
"labels".into(),
serde_json::Value::Array(
issue
.labels
.iter()
.map(|l| serde_json::Value::String(l.clone()))
.collect(),
),
);
Ok(serde_json::to_vec(&serde_json::Value::Object(map))?)
}
fn status_to_str(s: RecordStatus) -> &'static str {
s.as_str()
}
fn parse_version_mismatch_body(body: &str, expected: Option<u64>) -> (String, String) {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let current = parsed
.as_ref()
.and_then(|v| v.get("current"))
.and_then(|c| {
c.as_u64()
.map(|n| n.to_string())
.or_else(|| c.as_str().map(ToOwned::to_owned))
})
.unwrap_or_default();
let requested = parsed
.as_ref()
.and_then(|v| v.get("sent"))
.and_then(|s| {
s.as_str()
.map(ToOwned::to_owned)
.or_else(|| s.as_u64().map(|n| n.to_string()))
})
.or_else(|| expected.map(|v| v.to_string()))
.unwrap_or_default();
(current, requested)
}
#[async_trait]
impl BackendConnector for SimBackend {
fn name(&self) -> &'static str {
"simulator"
}
fn supports(&self, feature: BackendFeature) -> bool {
matches!(
feature,
BackendFeature::Delete
| BackendFeature::Transitions
| BackendFeature::StrongVersioning
| BackendFeature::BulkEdit
| BackendFeature::Workflows
)
}
async fn list_records(&self, project: &str) -> Result<Vec<Record>> {
let url = format!("{}/projects/{}/issues", self.base(), project);
let resp = self
.http
.request_with_headers(Method::GET, &url, &self.agent_only())
.await?;
decode_issues(resp, &url).await
}
async fn list_changed_since(
&self,
project: &str,
since: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<RecordId>> {
let since_iso = since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let url = format!(
"{}/projects/{}/issues?since={}",
self.base(),
project,
since_iso
);
let resp = self
.http
.request_with_headers(Method::GET, &url, &self.agent_only())
.await?;
let issues = decode_issues(resp, &url).await?;
Ok(issues.into_iter().map(|i| i.id).collect())
}
async fn get_record(&self, project: &str, id: RecordId) -> Result<Record> {
let url = format!("{}/projects/{}/issues/{}", self.base(), project, id.0);
let resp = self
.http
.request_with_headers(Method::GET, &url, &self.agent_only())
.await?;
decode_issue(resp, &url).await
}
async fn create_record(&self, project: &str, issue: Untainted<Record>) -> Result<Record> {
let url = format!("{}/projects/{}/issues", self.base(), project);
let body = render_create_body(issue.inner_ref())?;
let resp = self
.http
.request_with_headers_and_body(Method::POST, &url, &self.json_headers(), Some(body))
.await?;
decode_issue(resp, &url).await
}
async fn update_record(
&self,
project: &str,
id: RecordId,
patch: Untainted<Record>,
expected_version: Option<u64>,
) -> Result<Record> {
let url = format!("{}/projects/{}/issues/{}", self.base(), project, id.0);
let body = render_patch_body(patch.inner_ref())?;
let if_match_val = expected_version.map(|v| format!("\"{v}\""));
let mut headers = self.json_headers();
if let Some(ref v) = if_match_val {
headers.push(("If-Match", v.as_str()));
}
let resp = self
.http
.request_with_headers_and_body(Method::PATCH, &url, &headers, Some(body))
.await?;
let status = resp.status();
if status == StatusCode::CONFLICT {
let bytes = resp.bytes().await?;
let body = String::from_utf8_lossy(&bytes).into_owned();
let (current, requested) = parse_version_mismatch_body(&body, expected_version);
return Err(Error::VersionMismatch {
current,
requested,
body,
});
}
decode_issue(resp, &url).await
}
async fn delete_or_close(
&self,
project: &str,
id: RecordId,
_reason: DeleteReason,
) -> Result<()> {
let url = format!("{}/projects/{}/issues/{}", self.base(), project, id.0);
let resp = self
.http
.request_with_headers(Method::DELETE, &url, &self.agent_only())
.await?;
let status = resp.status();
if status == StatusCode::NOT_FOUND {
let (project_p, id_p) = parse_project_id_from_url(&url);
return Err(Error::NotFound {
project: project_p,
id: id_p,
});
}
if !status.is_success() {
let bytes = resp.bytes().await?;
return Err(Error::Other(format!(
"sim returned {status} for DELETE {url}: {}",
String::from_utf8_lossy(&bytes)
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::taint::{sanitize, ServerMetadata, Tainted};
use chrono::{TimeZone, Utc};
use wiremock::matchers::{body_partial_json, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_issue_json(id: u64) -> serde_json::Value {
serde_json::json!({
"id": id,
"title": "hello",
"status": "open",
"labels": [],
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z",
"version": 1,
"body": ""
})
}
fn sample_tainted() -> Tainted<Record> {
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
Tainted::new(Record {
id: RecordId(0),
title: "agent authored".into(),
status: RecordStatus::Open,
assignee: None,
labels: vec!["bug".into()],
created_at: t,
updated_at: t,
version: 0,
body: "body here".into(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
})
}
fn sample_untainted() -> Untainted<Record> {
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
sanitize(
sample_tainted(),
ServerMetadata {
id: RecordId(42),
created_at: t,
updated_at: t,
version: 3,
},
)
}
#[tokio::test]
async fn list_builds_the_right_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
sample_issue_json(1),
sample_issue_json(2),
])))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let issues = backend.list_records("demo").await.expect("list");
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].id, RecordId(1));
assert_eq!(issues[1].id, RecordId(2));
}
#[tokio::test]
async fn get_builds_the_right_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(7)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let issue = backend.get_record("demo", RecordId(7)).await.expect("get");
assert_eq!(issue.id, RecordId(7));
assert_eq!(issue.title, "hello");
}
#[tokio::test]
async fn get_maps_404_to_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues/9999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let err = backend
.get_record("demo", RecordId(9999))
.await
.expect_err("404");
match err {
Error::NotFound { project, id } => {
assert_eq!(project, "demo", "expected project='demo', got {project}");
assert_eq!(id, "9999", "expected id='9999', got {id}");
}
other => panic!("expected Error::NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn update_with_expected_version_attaches_if_match() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/42"))
.and(header("If-Match", "\"5\""))
.and(header("Content-Type", "application/json"))
.and(body_partial_json(
serde_json::json!({ "status": "in_progress" }),
))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(42)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
let u = sanitize(
Tainted::new(Record {
id: RecordId(0),
title: "hello".into(),
status: RecordStatus::InProgress,
assignee: None,
labels: vec![],
created_at: t,
updated_at: t,
version: 0,
body: String::new(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
}),
ServerMetadata {
id: RecordId(42),
created_at: t,
updated_at: t,
version: 5,
},
);
let out = backend
.update_record("demo", RecordId(42), u, Some(5))
.await
.expect("update");
assert_eq!(out.id, RecordId(42));
}
#[tokio::test]
async fn update_issue_409_prefix_is_version_mismatch() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/42"))
.and(header("If-Match", "\"1\""))
.respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
"error": "version_mismatch",
"current": 7,
"sent": "1",
})))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let err = backend
.update_record("demo", RecordId(42), u, Some(1))
.await
.expect_err("409");
match err {
Error::VersionMismatch {
ref current,
ref requested,
ref body,
} => {
assert_eq!(current, "7", "expected current='7', got {current}");
assert_eq!(requested, "1", "expected requested='1', got {requested}");
assert!(
body.contains("\"current\":7"),
"expected body to contain '\"current\":7', got {body}"
);
assert!(
err.to_string().starts_with("version mismatch:"),
"expected display prefix 'version mismatch:', got {err}"
);
}
other => panic!("expected Error::VersionMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn update_issue_409_current_field_present_as_json() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/42"))
.and(header("If-Match", "\"1\""))
.respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({
"error": "version_mismatch",
"current": 7,
"sent": "1",
})))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let err = backend
.update_record("demo", RecordId(42), u, Some(1))
.await
.expect_err("409");
let Error::VersionMismatch { body, .. } = err else {
panic!("expected Error::VersionMismatch, got {err:?}");
};
let parsed: serde_json::Value = serde_json::from_str(&body).expect("body parses as JSON");
let current = parsed
.get("current")
.expect("'current' key present")
.as_u64()
.expect("'current' is a u64");
assert_eq!(current, 7, "expected current=7, got {current}");
}
#[tokio::test]
async fn version_mismatch_round_trips_typed_body() {
let server = MockServer::start().await;
let hostile_body = "version mismatch: not really, just a label";
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/42"))
.and(header("If-Match", "\"1\""))
.respond_with(ResponseTemplate::new(409).set_body_string(hostile_body))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let err = backend
.update_record("demo", RecordId(42), u, Some(1))
.await
.expect_err("409");
match err {
Error::VersionMismatch { body, .. } => {
assert_eq!(
body, hostile_body,
"body field must carry the raw 409 response verbatim"
);
}
other => panic!("expected Error::VersionMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn not_found_round_trips_typed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/proj-x/issues/777"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let err = backend
.get_record("proj-x", RecordId(777))
.await
.expect_err("404");
match err {
Error::NotFound { project, id } => {
assert_eq!(project, "proj-x");
assert_eq!(id, "777");
}
other => panic!("expected Error::NotFound, got {other:?}"),
}
let again = backend
.get_record("proj-x", RecordId(777))
.await
.expect_err("404");
assert!(
again.to_string().starts_with("not found:"),
"expected display prefix 'not found:', got {again}"
);
}
#[tokio::test]
async fn update_without_expected_version_is_wildcard() {
use wiremock::Match;
struct NoIfMatch;
impl Match for NoIfMatch {
fn matches(&self, request: &wiremock::Request) -> bool {
!request.headers.contains_key("if-match")
}
}
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/42"))
.and(header("Content-Type", "application/json"))
.and(NoIfMatch)
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(42)))
.expect(1)
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let out = backend
.update_record("demo", RecordId(42), u, None)
.await
.expect("update");
assert_eq!(out.id, RecordId(42));
}
#[tokio::test]
async fn supports_reports_full_matrix_for_sim() {
let backend = SimBackend::new("http://127.0.0.1:7878".into()).expect("backend");
for f in [
BackendFeature::Delete,
BackendFeature::Transitions,
BackendFeature::StrongVersioning,
BackendFeature::BulkEdit,
BackendFeature::Workflows,
] {
assert!(backend.supports(f), "expected sim to support {f:?}");
}
assert_eq!(backend.name(), "simulator");
}
#[tokio::test]
async fn update_issue_sends_quoted_if_match() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/1"))
.and(header("If-Match", "\"3\""))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(1)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
let u = sanitize(
Tainted::new(Record {
id: RecordId(0),
title: "hello".into(),
status: RecordStatus::Open,
assignee: None,
labels: vec![],
created_at: t,
updated_at: t,
version: 0,
body: String::new(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
}),
ServerMetadata {
id: RecordId(1),
created_at: t,
updated_at: t,
version: 3,
},
);
let out = backend
.update_record("demo", RecordId(1), u, Some(3))
.await
.expect("update");
assert_eq!(out.id, RecordId(1));
}
#[tokio::test]
async fn update_issue_attaches_agent_header() {
use wiremock::Match;
struct HasAgentHeader;
impl Match for HasAgentHeader {
fn matches(&self, request: &wiremock::Request) -> bool {
request.headers.contains_key("x-reposix-agent")
}
}
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/1"))
.and(HasAgentHeader)
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(1)))
.expect(1)
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let _ = backend
.update_record("demo", RecordId(1), u, Some(1))
.await
.expect("update");
}
#[tokio::test]
async fn create_issue_omits_server_fields() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/projects/demo/issues"))
.respond_with(ResponseTemplate::new(201).set_body_json(sample_issue_json(4)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let got = backend.create_record("demo", u).await.expect("create");
assert_eq!(got.id, RecordId(4));
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body = String::from_utf8_lossy(&requests[0].body);
assert!(
body.contains("\"title\""),
"egress body lacks title: {body}"
);
assert!(
body.contains("\"labels\""),
"egress body lacks labels: {body}"
);
assert!(
!body.contains("\"version\""),
"egress body leaked version: {body}"
);
assert!(!body.contains("\"id\""), "egress body leaked id: {body}");
assert!(
!body.contains("\"created_at\""),
"egress body leaked created_at: {body}"
);
assert!(
!body.contains("\"updated_at\""),
"egress body leaked updated_at: {body}"
);
}
#[tokio::test]
async fn update_issue_respects_untainted_sanitization() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/projects/demo/issues/1"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_issue_json(1)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let t = Utc.with_ymd_and_hms(2026, 4, 13, 0, 0, 0).unwrap();
let meta = ServerMetadata {
id: RecordId(1),
created_at: t,
updated_at: t,
version: 1,
};
let hostile = Record {
id: RecordId(1),
title: "hello".into(),
status: RecordStatus::Open,
assignee: None,
labels: vec![],
created_at: t,
updated_at: t,
version: 999_999,
body: String::new(),
parent_id: None,
extensions: std::collections::BTreeMap::new(),
};
let u = sanitize(Tainted::new(hostile), meta);
backend
.update_record("demo", RecordId(1), u, Some(1))
.await
.expect("update");
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body = String::from_utf8_lossy(&requests[0].body);
assert!(
!body.contains("\"version\""),
"egress body leaked version: {body}"
);
assert!(!body.contains("\"id\""), "egress body leaked id: {body}");
assert!(
!body.contains("\"created_at\""),
"egress body leaked created_at: {body}"
);
assert!(
!body.contains("\"updated_at\""),
"egress body leaked updated_at: {body}"
);
assert!(body.contains("\"title\""));
assert!(body.contains("\"status\""));
}
#[tokio::test]
async fn create_issue_400_preserves_body_in_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/projects/demo/issues"))
.respond_with(ResponseTemplate::new(400).set_body_string("invalid title"))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let err = backend.create_record("demo", u).await.expect_err("400");
match err {
Error::Other(msg) => {
assert!(
msg.contains("invalid title"),
"expected body substring 'invalid title', got {msg}"
);
assert!(
msg.contains("400"),
"expected status substring '400', got {msg}"
);
}
other => panic!("expected Error::Other, got {other:?}"),
}
}
#[tokio::test]
async fn sim_backend_rejects_non_allowlisted_origin() {
let backend = SimBackend::new("http://evil.example".into()).expect("backend");
let err = backend.list_records("demo").await.expect_err("evil origin");
assert!(
matches!(err, Error::InvalidOrigin(_)),
"expected InvalidOrigin, got {err:?}"
);
}
#[tokio::test]
async fn get_issue_500_surfaces_error_other() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues/1"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let err = backend
.get_record("demo", RecordId(1))
.await
.expect_err("500");
match err {
Error::Other(msg) => {
assert!(
msg.contains("sim returned 500"),
"expected 'sim returned 500' substring, got {msg}"
);
}
other => panic!("expected Error::Other(500), got {other:?}"),
}
}
#[tokio::test]
async fn create_issue_returns_authoritative_issue() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/projects/demo/issues"))
.respond_with(ResponseTemplate::new(201).set_body_json(sample_issue_json(42)))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let u = sample_untainted();
let got = backend.create_record("demo", u).await.expect("create");
assert_eq!(got.id, RecordId(42));
assert_eq!(got.version, 1);
}
#[tokio::test]
async fn delete_or_close_succeeds_on_200() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/projects/demo/issues/1"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
backend
.delete_or_close("demo", RecordId(1), DeleteReason::Completed)
.await
.expect("delete");
}
#[tokio::test]
async fn list_changed_since_sends_since_query_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues"))
.and(query_param("since", "2026-04-24T00:00:00Z"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([sample_issue_json(42)])),
)
.expect(1)
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let t = Utc.with_ymd_and_hms(2026, 4, 24, 0, 0, 0).unwrap();
let ids = backend
.list_changed_since("demo", t)
.await
.expect("list_changed");
assert_eq!(ids, vec![RecordId(42)]);
}
#[tokio::test]
async fn list_changed_since_returns_ids_only() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/projects/demo/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
sample_issue_json(1),
sample_issue_json(2),
sample_issue_json(3),
])))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let t = chrono::Utc::now();
let ids = backend
.list_changed_since("demo", t)
.await
.expect("list_changed");
assert_eq!(ids, vec![RecordId(1), RecordId(2), RecordId(3)]);
}
#[tokio::test]
async fn delete_or_close_404_maps_to_not_found() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/projects/demo/issues/9999"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let backend = SimBackend::new(server.uri()).expect("backend");
let err = backend
.delete_or_close("demo", RecordId(9999), DeleteReason::Completed)
.await
.expect_err("404");
match err {
Error::NotFound { project, id } => {
assert_eq!(project, "demo");
assert_eq!(id, "9999");
}
other => panic!("expected Error::NotFound, got {other:?}"),
}
}
}