Skip to content

Commit 6fef741

Browse files
tikazyqclaude
andauthored
feat(core): add adapter compliance test harness (#288)
* feat(core): add adapter compliance test harness Introduce a reusable behavioural-contract test suite that every `Adapter` must satisfy: schema consistency, CRUD round-trip, list / filter, search, optional links, and standard error cases. The harness is exposed via the `test-utils` feature for out-of-tree adapters and is always available inside the crate's own tests. The `adapter_compliance_tests!` macro emits one `#[test]` per category so each case shows up individually in `cargo test` output; `run_compliance_suite` runs every case sequentially against a single adapter instance. `MarkdownAdapter` wires the full suite via a fresh-tempdir factory; all six categories pass. `GitHubAdapter` wires `check_schema_consistency` into its mock unit tests and `run_compliance_suite` into its (feature-gated, `#[ignore]`d) real-API integration tests. Two markdown adapter bugs surfaced and were fixed in the process: `update()` and `delete()` on a nonexistent id now return `AdapterError::NotFound` instead of `AdapterError::IoError`, matching the contract every adapter is now held to. Closes #260 https://claude.ai/code/session_01CZJP9d8gzWqQHgz2UJKyhu * refactor(core): tighten compliance harness from review feedback - `check_schema_consistency` now cross-checks `ComplianceOptions` against the schema (status_key, content_key, and link_type when `supports_links`) so a misconfigured adapter fails here with a clear diagnostic instead of deep inside CRUD. - `check_crud_roundtrip` now requires the content field to be present on `get()` after a `create()` that set it — silent absence no longer passes. - `check_list_filter`, `check_search`, and `check_links` always delete the items they create so persistent backends (real GitHub API in particular) don't accumulate fixtures across runs. Archive-specific post-delete assertions remain gated on `delete_is_archive`. - The `adapter_compliance_tests!` macro binds `let opts = $opts;` once per generated test so a `$opts` expression like a function call is evaluated once and the reference is to a named local, not a temporary. https://claude.ai/code/session_01CZJP9d8gzWqQHgz2UJKyhu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 918a032 commit 6fef741

5 files changed

Lines changed: 592 additions & 2 deletions

File tree

rust/leanspec-core/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ github = ["dep:reqwest", "reqwest/blocking", "reqwest/json", "reqwest/default-tl
4646
# Requires `GITHUB_TOKEN`, `TEST_GITHUB_OWNER`, `TEST_GITHUB_REPO` env vars.
4747
github-integration-tests = ["github"]
4848
storage = ["tokio", "dirs", "uuid"]
49+
# Expose the adapter compliance test harness for use by out-of-tree
50+
# adapter implementations. Always available within this crate's own tests.
51+
test-utils = []
4952

5053
[dev-dependencies]
5154
mockito = "1.5"

rust/leanspec-core/src/adapters/github/mod.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,21 @@ mod tests {
11021102
);
11031103
}
11041104

1105+
#[test]
1106+
fn passes_schema_compliance_check() {
1107+
use crate::adapters::test_harness::{check_schema_consistency, ComplianceOptions};
1108+
let s = mockito::Server::new();
1109+
let a = adapter(&s);
1110+
let opts = ComplianceOptions {
1111+
status_active: "open".into(),
1112+
status_alt: "closed".into(),
1113+
delete_is_archive: true,
1114+
supports_links: false,
1115+
..ComplianceOptions::default()
1116+
};
1117+
check_schema_consistency(&a, &opts);
1118+
}
1119+
11051120
#[test]
11061121
fn capabilities_match_spec() {
11071122
let s = mockito::Server::new();
@@ -1831,13 +1846,33 @@ mod tests {
18311846
#[cfg(all(test, feature = "github-integration-tests"))]
18321847
mod integration {
18331848
use super::*;
1849+
use crate::adapters::test_harness::{run_compliance_suite, ComplianceOptions};
18341850

18351851
fn live_adapter() -> Option<GitHubAdapter> {
18361852
let owner = std::env::var("TEST_GITHUB_OWNER").ok()?;
18371853
let repo = std::env::var("TEST_GITHUB_REPO").ok()?;
18381854
GitHubAdapter::new(owner, repo, "GITHUB_TOKEN").ok()
18391855
}
18401856

1857+
fn github_compliance_options() -> ComplianceOptions {
1858+
ComplianceOptions {
1859+
status_active: "open".into(),
1860+
status_alt: "closed".into(),
1861+
delete_is_archive: true,
1862+
supports_links: false,
1863+
..ComplianceOptions::default()
1864+
}
1865+
}
1866+
1867+
#[test]
1868+
#[ignore = "hits real GitHub API; requires GITHUB_TOKEN + TEST_GITHUB_OWNER + TEST_GITHUB_REPO"]
1869+
fn integration_compliance_suite() {
1870+
let Some(adapter) = live_adapter() else {
1871+
return;
1872+
};
1873+
run_compliance_suite(&adapter, &github_compliance_options());
1874+
}
1875+
18411876
#[test]
18421877
#[ignore = "hits real GitHub API; requires GITHUB_TOKEN + TEST_GITHUB_OWNER + TEST_GITHUB_REPO"]
18431878
fn integration_create_get_update_close_roundtrip() {

rust/leanspec-core/src/adapters/markdown/mod.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,10 @@ impl Adapter for MarkdownAdapter {
670670

671671
writer
672672
.update_metadata(id, meta_update)
673-
.map_err(|e| AdapterError::IoError(std::io::Error::other(e.to_string())))?;
673+
.map_err(|e| match e {
674+
writer::WriteError::NotFound(p) => AdapterError::NotFound(p),
675+
other => AdapterError::IoError(std::io::Error::other(other.to_string())),
676+
})?;
674677

675678
// Second pass: title, content body, and extended frontmatter fields
676679
// (reviewer, issue, pr, epic, breaking, due) rewrite the file directly.
@@ -769,7 +772,10 @@ impl Adapter for MarkdownAdapter {
769772
fn delete(&self, id: &str) -> Result<(), AdapterError> {
770773
SpecArchiver::new(&self.specs_dir)
771774
.archive(id)
772-
.map_err(|e| AdapterError::IoError(std::io::Error::other(e.to_string())))
775+
.map_err(|e| match e {
776+
archiver::ArchiveError::NotFound(p) => AdapterError::NotFound(p),
777+
other => AdapterError::IoError(std::io::Error::other(other.to_string())),
778+
})
773779
}
774780

775781
fn search(&self, query: &str, opts: &SearchOptions) -> Result<Vec<SearchHit>, AdapterError> {
@@ -1211,3 +1217,33 @@ mod tests {
12111217
assert!(hits.iter().any(|h| h.id == "001-auth"));
12121218
}
12131219
}
1220+
1221+
#[cfg(test)]
1222+
mod compliance {
1223+
use super::*;
1224+
use crate::adapter_compliance_tests;
1225+
use crate::adapters::test_harness::ComplianceOptions;
1226+
use tempfile::TempDir;
1227+
1228+
fn make_markdown_adapter() -> (MarkdownAdapter, TempDir) {
1229+
let dir = TempDir::new().expect("tempdir");
1230+
let specs = dir.path().join("specs");
1231+
std::fs::create_dir_all(&specs).expect("create specs dir");
1232+
let adapter = MarkdownAdapter::new(specs);
1233+
(adapter, dir)
1234+
}
1235+
1236+
fn markdown_options() -> ComplianceOptions {
1237+
ComplianceOptions {
1238+
status_active: "planned".into(),
1239+
status_alt: "in-progress".into(),
1240+
delete_is_archive: true,
1241+
supports_links: true,
1242+
link_type: link::DEPENDS_ON.into(),
1243+
link_target: "001-foundation".into(),
1244+
..ComplianceOptions::default()
1245+
}
1246+
}
1247+
1248+
adapter_compliance_tests!(make_markdown_adapter, markdown_options());
1249+
}

rust/leanspec-core/src/adapters/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ pub mod jira;
2020
pub mod markdown;
2121
pub mod registry;
2222

23+
#[cfg(any(test, feature = "test-utils"))]
24+
pub mod test_harness;
25+
2326
use chrono::{DateTime, Utc};
2427
use serde::{Deserialize, Serialize};
2528
use std::collections::HashMap;

0 commit comments

Comments
 (0)