Skip to content

Commit 486ca80

Browse files
committed
test(cli): pin ListArgs flag surface + run() error/empty/populated paths
Adds tests/cli_parse_list.rs covering the public contract for `socket-patch list`: - clap parser tests pin each ListArgs flag (long + short forms), assert the defaults from CLI_CONTRACT.md, and verify unknown flags surface ErrorKind::UnknownArgument. - async run() tests exercise the no-network branches against tempdirs: missing manifest (exit 1, plain + json), empty manifest (exit 0, plain + json), populated manifest (exit 0, plain + json), and an absolute --manifest-path overriding --cwd. - one subprocess test invokes the compiled binary against a missing-manifest tempdir with --json and parses stdout to lock the { status: "error", error: "Manifest not found", path: ... } shape, since run() writes to the real stdout and cannot be captured in-process. Assisted-by: Claude Code:claude-opus-4-7
1 parent 0bd4f50 commit 486ca80

1 file changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
//! Parser + `run()` contract tests for `socket-patch list`.
2+
//!
3+
//! These tests pin the public CLI surface of the `list` subcommand:
4+
//! - clap parser tests assert flag long/short forms, defaults, and unknown-flag rejection
5+
//! - async `run()` tests cover the no-network execution paths (missing manifest -> 1,
6+
//! empty manifest -> 0, populated manifest -> 0, absolute manifest path wins)
7+
//! - one subprocess test against the compiled binary locks the JSON `status` shape for
8+
//! the missing-manifest error path, since `run()` writes directly to stdout/stderr
9+
//! and cannot be intercepted in-process.
10+
//!
11+
//! See `crates/socket-patch-cli/CLI_CONTRACT.md` for the surface these tests pin.
12+
13+
use std::collections::HashMap;
14+
use std::path::PathBuf;
15+
use std::process::Command;
16+
17+
use clap::Parser;
18+
use socket_patch_cli::commands::list::{ListArgs, run};
19+
use socket_patch_cli::{Cli, Commands};
20+
use socket_patch_core::manifest::schema::{
21+
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
22+
};
23+
24+
// ---------------------------------------------------------------------------
25+
// Parser helpers
26+
// ---------------------------------------------------------------------------
27+
28+
fn parse_list(extra: &[&str]) -> ListArgs {
29+
let mut argv = vec!["socket-patch", "list"];
30+
argv.extend_from_slice(extra);
31+
let cli = Cli::try_parse_from(&argv).expect("parse");
32+
match cli.command {
33+
Commands::List(a) => a,
34+
_ => panic!("expected List"),
35+
}
36+
}
37+
38+
// ---------------------------------------------------------------------------
39+
// Parser tests
40+
// ---------------------------------------------------------------------------
41+
42+
#[test]
43+
fn defaults_match_contract() {
44+
let args = parse_list(&[]);
45+
assert_eq!(args.cwd, PathBuf::from("."));
46+
assert_eq!(args.manifest_path, ".socket/manifest.json");
47+
assert!(!args.json);
48+
}
49+
50+
#[test]
51+
fn manifest_path_short_form() {
52+
let args = parse_list(&["-m", "custom.json"]);
53+
assert_eq!(args.manifest_path, "custom.json");
54+
}
55+
56+
#[test]
57+
fn manifest_path_long_form() {
58+
let args = parse_list(&["--manifest-path", "custom.json"]);
59+
assert_eq!(args.manifest_path, "custom.json");
60+
}
61+
62+
#[test]
63+
fn cwd_long_form() {
64+
let args = parse_list(&["--cwd", "/tmp/x"]);
65+
assert_eq!(args.cwd, PathBuf::from("/tmp/x"));
66+
}
67+
68+
#[test]
69+
fn json_flag_sets_true() {
70+
let args = parse_list(&["--json"]);
71+
assert!(args.json);
72+
}
73+
74+
#[test]
75+
fn unknown_flag_is_rejected() {
76+
let err = match Cli::try_parse_from(["socket-patch", "list", "--nope"]) {
77+
Ok(_) => panic!("unknown flag must fail"),
78+
Err(e) => e,
79+
};
80+
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
81+
}
82+
83+
// ---------------------------------------------------------------------------
84+
// run() integration tests — no-network paths
85+
// ---------------------------------------------------------------------------
86+
87+
fn populated_manifest() -> PatchManifest {
88+
let mut files = HashMap::new();
89+
files.insert(
90+
"package/index.js".to_string(),
91+
PatchFileInfo {
92+
before_hash:
93+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1111"
94+
.to_string(),
95+
after_hash:
96+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1111"
97+
.to_string(),
98+
},
99+
);
100+
101+
let mut vulnerabilities = HashMap::new();
102+
vulnerabilities.insert(
103+
"GHSA-test-test-test".to_string(),
104+
VulnerabilityInfo {
105+
cves: vec!["CVE-2024-0001".to_string()],
106+
summary: "test vuln".to_string(),
107+
severity: "high".to_string(),
108+
description: "test description".to_string(),
109+
},
110+
);
111+
112+
let mut patches = HashMap::new();
113+
patches.insert(
114+
"pkg:npm/test-pkg@1.0.0".to_string(),
115+
PatchRecord {
116+
uuid: "11111111-1111-4111-8111-111111111111".to_string(),
117+
exported_at: "2024-01-01T00:00:00Z".to_string(),
118+
files,
119+
vulnerabilities,
120+
description: "Test patch".to_string(),
121+
license: "MIT".to_string(),
122+
tier: "free".to_string(),
123+
},
124+
);
125+
126+
PatchManifest { patches }
127+
}
128+
129+
#[tokio::test]
130+
async fn missing_manifest_returns_1_plain() {
131+
let tmp = tempfile::tempdir().unwrap();
132+
let args = ListArgs {
133+
cwd: tmp.path().to_path_buf(),
134+
manifest_path: ".socket/manifest.json".into(),
135+
json: false,
136+
};
137+
assert_eq!(run(args).await, 1);
138+
}
139+
140+
#[tokio::test]
141+
async fn missing_manifest_returns_1_json() {
142+
let tmp = tempfile::tempdir().unwrap();
143+
let args = ListArgs {
144+
cwd: tmp.path().to_path_buf(),
145+
manifest_path: ".socket/manifest.json".into(),
146+
json: true,
147+
};
148+
assert_eq!(run(args).await, 1);
149+
}
150+
151+
#[tokio::test]
152+
async fn empty_manifest_returns_0_plain() {
153+
let tmp = tempfile::tempdir().unwrap();
154+
let socket_dir = tmp.path().join(".socket");
155+
tokio::fs::create_dir_all(&socket_dir).await.unwrap();
156+
let manifest = PatchManifest::new();
157+
let path = socket_dir.join("manifest.json");
158+
tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap())
159+
.await
160+
.unwrap();
161+
162+
let args = ListArgs {
163+
cwd: tmp.path().to_path_buf(),
164+
manifest_path: ".socket/manifest.json".into(),
165+
json: false,
166+
};
167+
assert_eq!(run(args).await, 0);
168+
}
169+
170+
#[tokio::test]
171+
async fn empty_manifest_returns_0_json() {
172+
let tmp = tempfile::tempdir().unwrap();
173+
let socket_dir = tmp.path().join(".socket");
174+
tokio::fs::create_dir_all(&socket_dir).await.unwrap();
175+
let manifest = PatchManifest::new();
176+
let path = socket_dir.join("manifest.json");
177+
tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap())
178+
.await
179+
.unwrap();
180+
181+
let args = ListArgs {
182+
cwd: tmp.path().to_path_buf(),
183+
manifest_path: ".socket/manifest.json".into(),
184+
json: true,
185+
};
186+
assert_eq!(run(args).await, 0);
187+
}
188+
189+
#[tokio::test]
190+
async fn populated_manifest_returns_0_plain() {
191+
let tmp = tempfile::tempdir().unwrap();
192+
let socket_dir = tmp.path().join(".socket");
193+
tokio::fs::create_dir_all(&socket_dir).await.unwrap();
194+
let manifest = populated_manifest();
195+
let path = socket_dir.join("manifest.json");
196+
tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap())
197+
.await
198+
.unwrap();
199+
200+
let args = ListArgs {
201+
cwd: tmp.path().to_path_buf(),
202+
manifest_path: ".socket/manifest.json".into(),
203+
json: false,
204+
};
205+
assert_eq!(run(args).await, 0);
206+
}
207+
208+
#[tokio::test]
209+
async fn populated_manifest_returns_0_json() {
210+
let tmp = tempfile::tempdir().unwrap();
211+
let socket_dir = tmp.path().join(".socket");
212+
tokio::fs::create_dir_all(&socket_dir).await.unwrap();
213+
let manifest = populated_manifest();
214+
let path = socket_dir.join("manifest.json");
215+
tokio::fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap())
216+
.await
217+
.unwrap();
218+
219+
let args = ListArgs {
220+
cwd: tmp.path().to_path_buf(),
221+
manifest_path: ".socket/manifest.json".into(),
222+
json: true,
223+
};
224+
assert_eq!(run(args).await, 0);
225+
}
226+
227+
#[tokio::test]
228+
async fn absolute_manifest_path_wins_over_cwd() {
229+
// Manifest lives in tmp_manifest_dir, cwd points elsewhere.
230+
// resolve_manifest_path() must prefer the absolute path.
231+
let tmp_manifest_dir = tempfile::tempdir().unwrap();
232+
let tmp_cwd = tempfile::tempdir().unwrap();
233+
234+
let manifest = PatchManifest::new();
235+
let abs_path = tmp_manifest_dir.path().join("abs.json");
236+
tokio::fs::write(&abs_path, serde_json::to_string_pretty(&manifest).unwrap())
237+
.await
238+
.unwrap();
239+
240+
let args = ListArgs {
241+
cwd: tmp_cwd.path().to_path_buf(),
242+
manifest_path: abs_path.to_string_lossy().into_owned(),
243+
json: false,
244+
};
245+
assert_eq!(run(args).await, 0);
246+
}
247+
248+
// ---------------------------------------------------------------------------
249+
// Subprocess test — locks the JSON `status` shape for missing-manifest error
250+
// ---------------------------------------------------------------------------
251+
252+
#[test]
253+
fn missing_manifest_json_status_is_error_via_binary() {
254+
let tmp = tempfile::tempdir().unwrap();
255+
let out = Command::new(env!("CARGO_BIN_EXE_socket-patch"))
256+
.args([
257+
"list",
258+
"--cwd",
259+
tmp.path().to_str().unwrap(),
260+
"--json",
261+
])
262+
.output()
263+
.expect("failed to execute socket-patch binary");
264+
265+
assert_eq!(
266+
out.status.code(),
267+
Some(1),
268+
"missing manifest must exit 1, stderr={}",
269+
String::from_utf8_lossy(&out.stderr)
270+
);
271+
272+
let stdout = String::from_utf8_lossy(&out.stdout);
273+
let parsed: serde_json::Value =
274+
serde_json::from_str(stdout.trim()).expect("stdout must be valid JSON");
275+
assert_eq!(
276+
parsed.get("status").and_then(|v| v.as_str()),
277+
Some("error"),
278+
"status must be \"error\", got {parsed}"
279+
);
280+
assert_eq!(
281+
parsed.get("error").and_then(|v| v.as_str()),
282+
Some("Manifest not found"),
283+
"error message must be exact, got {parsed}"
284+
);
285+
assert!(
286+
parsed.get("path").and_then(|v| v.as_str()).is_some(),
287+
"missing-manifest JSON must include `path` key, got {parsed}"
288+
);
289+
}

0 commit comments

Comments
 (0)