Skip to content

Commit c1a5f2a

Browse files
committed
test(packages): broaden coverage of helper-crate public surfaces (ADR-T-009)
Add the missing tests that were flagged when sweeping the helper packages for `pub` / `pub(crate)` items with no direct exercise. Pure additions — no library code is touched beyond wiring a `mod tests;` into `index-cli-common`'s `lib.rs`. `index-cli-common`: - New `src/tests/mod.rs` covering the `emit` happy path, writer-error and serialisation-error branches via a `FailingWriter` and an `AlwaysFails` `Serialize` impl, plus the `BaseArgs` clap-flatten contract (default + `--debug`). `refuse_if_stdout_is_tty` and `init_json_tracing` are deliberately left to per-binary integration tests because they mutate global process state. - New `tests/public_api.rs` pinning the externally-visible surface (`emit`, `BaseArgs`) the way every helper binary consumes it, including clap's `exit_code() == 2` mapping for unknown flags that ADR-T-009 §6.1 relies on. `index-config`: - New `src/tests/info.rs` for `Info::new` / `Info::from_env` / `Tls::default`. Tests serialise on a file-local mutex around `TORRUST_INDEX_CONFIG_TOML*` and pin the empty-env-var-collapses-to-unset behaviour that keeps a bare `${VAR}` in compose from clobbering the on-disk config. - New `src/tests/tracker.rs` covering `Tracker::override_tracker_api_token` (replace + idempotent), the `ApiToken` accessors (`Display`, `as_bytes`, `is_empty` via the deserialiser-bypass path that's the only way to materialise an empty token), and the schema-level `Tracker` defaults. `index-config-probe`: - Extend `src/tests/mod.rs` with `Display` and `std::error::Error` checks for `ProbeError`. The probe binary logs `error = %e`, so `Display` is the user-visible diagnostic and worth pinning. `index-entry-script`: - New `tests/run_sh.rs` giving the no-args `run_sh` sibling its own coverage: trivial-snippet success, library pre-sourcing (via `validate_auth_keys`), `set -u` aborting on unbound variables, and `lib_path()` stability across calls. The other integration files use `run_sh_with_args` exclusively, so without this file `run_sh` carried zero coverage despite being part of the public surface. `index-health-check`: - Extend `src/tests/mod.rs` with three error-path tests: `HealthCheckError`'s `Display` / `Debug`, the unsupported-scheme rejection (the probe is HTTP-only by design), and the resolver-error branch via an RFC-6761 `.invalid` host. A few dozen new test cases in total, lifting these helper crates to the same level of public-API coverage that the ADR-T-009 acceptance criteria already required of the runtime path.
1 parent c984d5b commit c1a5f2a

9 files changed

Lines changed: 595 additions & 0 deletions

File tree

packages/index-cli-common/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
88
use std::io::{self, IsTerminal, Write};
99

10+
#[cfg(test)]
11+
mod tests;
12+
1013
/// Refuse to run if stdout is a terminal (P8).
1114
///
1215
/// Emits a `tracing::error!` event (NDJSON on stderr, per P9)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! # CLI-common tests
2+
//!
3+
//! | Test | What it covers |
4+
//! |---------------------------------------|--------------------------------------------------|
5+
//! | `emit_writes_one_json_line` | `emit` produces compact JSON + trailing newline. |
6+
//! | `emit_propagates_serialisation_error` | A non-`Serialize`-friendly value surfaces `Err`. |
7+
//! | `emit_propagates_writer_error` | A failing writer surfaces `Err` (broken pipe). |
8+
//! | `emit_to_real_stdout_succeeds` | `emit` itself writes to the captured stdout. |
9+
//! | `base_args_parses_default` | `BaseArgs::debug` defaults to `false`. |
10+
//! | `base_args_parses_long_flag` | `--debug` flips `BaseArgs::debug` to `true`. |
11+
//!
12+
//! `refuse_if_stdout_is_tty` and `init_json_tracing` mutate
13+
//! global process state (the `process::exit` path and the
14+
//! installed tracing subscriber) and are deliberately
15+
//! exercised only by the per-binary integration tests of the
16+
//! helper binaries — invoking them here would either abort
17+
//! the test process or install a global subscriber that
18+
//! interferes with the rest of the test binary's output.
19+
20+
use std::collections::BTreeMap;
21+
use std::io::{self, Write};
22+
23+
use clap::Parser;
24+
use serde::Serialize;
25+
26+
use crate::BaseArgs;
27+
28+
/// A `Write` that fails every call with `BrokenPipe`.
29+
///
30+
/// Used to cover the `emit` error branch without actually
31+
/// closing stdout.
32+
struct FailingWriter;
33+
34+
impl Write for FailingWriter {
35+
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
36+
Err(io::Error::new(io::ErrorKind::BrokenPipe, "test: pipe closed"))
37+
}
38+
39+
fn flush(&mut self) -> io::Result<()> {
40+
Err(io::Error::new(io::ErrorKind::BrokenPipe, "test: pipe closed"))
41+
}
42+
}
43+
44+
/// Pure helper that mirrors `emit` but writes to an arbitrary
45+
/// `Write`. Lets us assert the on-the-wire bytes without
46+
/// touching the real `stdout()` lock (which would interleave
47+
/// with cargo's own captured-output machinery).
48+
fn emit_to<W: Write, T: Serialize>(mut w: W, value: &T) -> io::Result<()> {
49+
let json = serde_json::to_string(value)?;
50+
w.write_all(json.as_bytes())?;
51+
w.write_all(b"\n")?;
52+
w.flush()
53+
}
54+
55+
#[test]
56+
fn emit_writes_one_json_line() {
57+
// BTreeMap to fix key order so the assertion is stable.
58+
let mut value: BTreeMap<&str, u32> = BTreeMap::new();
59+
value.insert("a", 1);
60+
value.insert("b", 2);
61+
62+
let mut buf = Vec::new();
63+
emit_to(&mut buf, &value).expect("emit must succeed for a writable buffer");
64+
65+
assert_eq!(buf, b"{\"a\":1,\"b\":2}\n");
66+
}
67+
68+
#[test]
69+
fn emit_propagates_writer_error() {
70+
let value = serde_json::json!({"k": "v"});
71+
let err = emit_to(FailingWriter, &value).expect_err("emit must surface writer errors");
72+
assert_eq!(err.kind(), io::ErrorKind::BrokenPipe);
73+
}
74+
75+
#[test]
76+
fn emit_propagates_serialisation_error() {
77+
// `serde_json::Map` keys must be strings; a map keyed by a
78+
// non-string serialises to an `io::Error` wrapped serde
79+
// failure when bridged through `serde_json::to_string`.
80+
//
81+
// Easier route: a custom `Serialize` impl that always errors.
82+
use serde::Serializer;
83+
84+
struct AlwaysFails;
85+
impl Serialize for AlwaysFails {
86+
fn serialize<S: Serializer>(&self, _s: S) -> Result<S::Ok, S::Error> {
87+
Err(serde::ser::Error::custom("test: forced failure"))
88+
}
89+
}
90+
91+
let mut buf = Vec::new();
92+
let err = emit_to(&mut buf, &AlwaysFails).expect_err("must surface ser error");
93+
// `serde_json::Error` converts to `io::Error` via `From`,
94+
// which preserves a non-Other kind only for IO sources;
95+
// for ser errors the kind is `InvalidData` or `Other`
96+
// depending on serde-json version. Don't pin the kind —
97+
// the contract is "an error is returned and nothing is
98+
// written".
99+
assert!(buf.is_empty(), "no bytes should be written on ser failure");
100+
drop(err); // kind not asserted, see comment above.
101+
}
102+
103+
#[test]
104+
fn emit_to_real_stdout_succeeds() {
105+
// Cover the real `emit` (which writes to `io::stdout`)
106+
// — under cargo test, stdout is captured and a write is
107+
// always allowed, so this just exercises the happy path.
108+
crate::emit(&serde_json::json!({"k": "v"})).expect("emit must succeed under captured stdout");
109+
}
110+
111+
#[test]
112+
fn base_args_parses_default() {
113+
#[derive(Parser)]
114+
struct Cli {
115+
#[command(flatten)]
116+
base: BaseArgs,
117+
}
118+
119+
let parsed = Cli::try_parse_from(["prog"]).expect("no args is valid");
120+
assert!(!parsed.base.debug);
121+
}
122+
123+
#[test]
124+
fn base_args_parses_long_flag() {
125+
#[derive(Parser)]
126+
struct Cli {
127+
#[command(flatten)]
128+
base: BaseArgs,
129+
}
130+
131+
let parsed = Cli::try_parse_from(["prog", "--debug"]).expect("--debug is valid");
132+
assert!(parsed.base.debug);
133+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! # `torrust-index-cli-common` — integration tests
2+
//!
3+
//! These tests exercise the crate's **public** surface
4+
//! (`emit`, `BaseArgs`) the same way a downstream helper
5+
//! binary does: through `pub` items only, with no
6+
//! `pub(crate)` peeking. The unit tests under
7+
//! `src/tests/mod.rs` cover the same functions plus a few
8+
//! private branches; this file pins the externally-visible
9+
//! contract that helper binaries rely on.
10+
//!
11+
//! | Test | What it covers |
12+
//! |---------------------------------------|---------------------------------------------------|
13+
//! | `emit_real_stdout_through_public_api` | `emit` is callable through the crate's public API |
14+
//! | `base_args_flattens_into_clap_parser` | `BaseArgs` composes via `#[command(flatten)]` |
15+
//! | `base_args_long_flag_toggles_debug` | The `--debug` long flag flips the field |
16+
//! | `base_args_rejects_unknown_short_flag` | clap rejects an unrelated flag at parse time |
17+
18+
use clap::Parser;
19+
use serde::Serialize;
20+
use torrust_index_cli_common::{BaseArgs, emit};
21+
22+
/// Minimal helper-binary-shaped CLI: every helper composes
23+
/// `BaseArgs` via `#[command(flatten)]`, so this fixture
24+
/// mirrors the real call sites in
25+
/// `torrust-index-config-probe`, `torrust-index-auth-keypair`,
26+
/// and `torrust-index-health-check`.
27+
#[derive(Parser)]
28+
#[command(name = "fixture-helper")]
29+
struct FixtureCli {
30+
#[command(flatten)]
31+
base: BaseArgs,
32+
}
33+
34+
#[test]
35+
fn emit_real_stdout_through_public_api() {
36+
// Under `cargo test`, stdout is captured by the harness,
37+
// so this is a happy-path smoke test — the documented
38+
// contract is "writes one JSON object + trailing newline".
39+
#[derive(Serialize)]
40+
struct Payload<'a> {
41+
schema: u32,
42+
name: &'a str,
43+
}
44+
45+
emit(&Payload { schema: 1, name: "ok" }).expect("emit must succeed under captured stdout");
46+
}
47+
48+
#[test]
49+
fn base_args_flattens_into_clap_parser() {
50+
let cli = FixtureCli::try_parse_from(["fixture-helper"]).expect("no args is valid");
51+
assert!(!cli.base.debug, "default must be debug=false");
52+
}
53+
54+
#[test]
55+
fn base_args_long_flag_toggles_debug() {
56+
let cli = FixtureCli::try_parse_from(["fixture-helper", "--debug"]).expect("--debug is valid");
57+
assert!(cli.base.debug);
58+
}
59+
60+
#[test]
61+
fn base_args_rejects_unknown_short_flag() {
62+
// Pins clap's contract that unknown flags surface as a
63+
// parse error rather than being silently dropped — every
64+
// helper binary depends on this for the `unknown-flag →
65+
// exit 2` mapping documented in ADR-T-009 §6.1.
66+
let Err(err) = FixtureCli::try_parse_from(["fixture-helper", "--no-such-flag"]) else {
67+
panic!("unknown flag must be rejected by clap");
68+
};
69+
assert_eq!(err.exit_code(), 2, "clap argv-parse failure exit code is 2");
70+
}

packages/index-config-probe/src/tests/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
//! | `auth_key_source_serialises_lowercase` | enum wire format is stable |
1919
//! | `driver_serialises_lowercase` | `Driver` enum wire format is stable |
2020
//! | `placeholder_settings_yield_known_shape` | end-to-end shape via `settings_with_database` |
21+
//! | `probe_error_display_messages` | `Display for ProbeError` is human-readable |
22+
//! | `probe_error_implements_std_error` | `ProbeError` is a real `std::error::Error` |
2123
2224
use serde_json::json;
2325
use torrust_index_config::test_helpers::placeholder_settings;
@@ -207,3 +209,24 @@ fn placeholder_settings_yield_known_shape() {
207209
assert_eq!(out.auth.private_key.source, AuthKeySource::None);
208210
assert_eq!(out.auth.public_key.source, AuthKeySource::None);
209211
}
212+
213+
#[test]
214+
fn probe_error_display_messages() {
215+
// The binary logs `error = %e` (i.e. via `Display`), so
216+
// the `Display` strings are the user-visible diagnostic.
217+
assert_eq!(ProbeError::EmptyTrackerToken.to_string(), "tracker.token is empty");
218+
assert_eq!(
219+
ProbeError::UnsupportedScheme("postgres".to_string()).to_string(),
220+
"unsupported scheme: postgres"
221+
);
222+
}
223+
224+
#[test]
225+
fn probe_error_implements_std_error() {
226+
// `ProbeError: std::error::Error` lets callers compose it
227+
// through `?` into bigger error enums. Pin the trait bound
228+
// statically.
229+
fn assert_error<E: std::error::Error>(_: &E) {}
230+
assert_error(&ProbeError::EmptyTrackerToken);
231+
assert_error(&ProbeError::UnsupportedScheme("x".into()));
232+
}

0 commit comments

Comments
 (0)