From 8394c79e5a90f1b9d41eb2c588fcb645034183f2 Mon Sep 17 00:00:00 2001 From: erishforG Date: Fri, 4 Sep 2026 10:12:34 +0900 Subject: [PATCH] =?UTF-8?q?test(smartlog):=20Phase=204=20#310=20=E2=80=94?= =?UTF-8?q?=20PARSEC=5FGITHUB=5FAPI=5FBASE=20+=20mockito=20overlay=20integ?= =?UTF-8?q?ration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PARSEC_GITHUB_API_BASE env var override so tests (and GitHub Enterprise users) can route GitHubClient calls to a custom API endpoint without changing the git remote URL. Also adds two CLI-level integration tests that close the 'mock 으로 통합 테스트' gap in issues #309 and #310. ## Changes ### src/env.rs - PARSEC_GITHUB_API_BASE constant + github_api_base() function (mirrors existing PARSEC_BITBUCKET_API_BASE pattern) ### src/github/mod.rs - GitHubClient::new() uses github_api_base() override when set (single-line additive change; falls back to remote.api_base() when unset) ### tests/cli_tests.rs - test_smartlog_no_overlay_skips_github: verifies --no-overlay exits 0 without any GitHub token and emits no PR/CI badges - test_smartlog_overlay_with_mock_github: spins up a mockito server, points PARSEC_GITHUB_API_BASE at it, runs parsec smartlog, and asserts that [PR #99] and [CI: ✓ passed] badges appear in ASCII output Refs #309 (통합 테스트 acceptance criterion) Refs #310 (mock 으로 통합 테스트 acceptance criterion) Co-Authored-By: Claude Opus 4.7 --- src/env.rs | 22 ++++++ src/github/mod.rs | 3 +- tests/cli_tests.rs | 184 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) diff --git a/src/env.rs b/src/env.rs index 494df4f..128ab61 100644 --- a/src/env.rs +++ b/src/env.rs @@ -163,6 +163,28 @@ pub fn bitbucket_api_base() -> Option { .map(|v| v.trim_end_matches('/').to_string()) } +// --------------------------------------------------------------------------- +// GitHub API base URL override +// --------------------------------------------------------------------------- + +/// Override the GitHub API base URL (no trailing slash). +/// +/// Primarily used in tests to route API calls to a mock server without +/// changing the git remote URL. Also useful for GitHub Enterprise instances +/// whose API resides at a custom path. +/// +/// When unset, [`GitHubRemote::api_base`] derives the URL from the remote host: +/// `github.com` → `https://api.github.com`, GHE → `https://{host}/api/v3`. +pub const PARSEC_GITHUB_API_BASE: &str = "PARSEC_GITHUB_API_BASE"; + +/// Return the GitHub API base URL override when [`PARSEC_GITHUB_API_BASE`] is set. +pub fn github_api_base() -> Option { + std::env::var(PARSEC_GITHUB_API_BASE) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v.trim_end_matches('/').to_string()) +} + // --------------------------------------------------------------------------- // Offline mode // --------------------------------------------------------------------------- diff --git a/src/github/mod.rs b/src/github/mod.rs index 15f7ff9..4b6e1ea 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -359,7 +359,8 @@ impl GitHubClient { None => return Ok(None), }; - let api_base = remote.api_base(); + // Allow test overrides (and GHE custom endpoints) via env var. + let api_base = crate::env::github_api_base().unwrap_or_else(|| remote.api_base()); let client = http_client()?; Ok(Some(Self { diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 82507a6..38db627 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,4 +1,5 @@ use assert_cmd::Command; +use mockito::Matcher; use predicates::prelude::*; use std::process::Command as StdCommand; use tempfile::TempDir; @@ -2437,3 +2438,186 @@ fn test_dashboard_quiet_rejected() { combined ); } + +// --------------------------------------------------------------------------- +// parsec smartlog — overlay integration tests (#309 Phase 6, #310 Phase 4) +// --------------------------------------------------------------------------- + +/// `parsec smartlog --no-overlay` must succeed without any GitHub token and +/// must not emit PR or CI badges in its output. +/// +/// Validates issue #309 acceptance: "—no-overlay 시 placeholder 로 fallback". +#[test] +fn test_smartlog_no_overlay_skips_github() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "OV-1", "--repo", repo_path]) + .assert() + .success(); + + let output = parsec() + .args(["smartlog", "--no-overlay", "--repo", repo_path]) + // Strip any inherited GitHub token so the test is hermetic. + .env_remove("PARSEC_GITHUB_TOKEN") + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN") + .output() + .unwrap(); + + assert!( + output.status.success(), + "--no-overlay must succeed without a GitHub token; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("OV-1"), + "worktree ticket must appear; got:\n{stdout}" + ); + assert!( + !stdout.contains("[PR #"), + "--no-overlay must not emit PR badges; got:\n{stdout}" + ); + assert!( + !stdout.contains("[CI:"), + "--no-overlay must not emit CI badges; got:\n{stdout}" + ); +} + +/// `parsec smartlog` with `PARSEC_GITHUB_API_BASE` pointing at a mockito +/// server must render PR and CI badges in the ASCII tree when the mock returns +/// successful check-run data. +/// +/// Validates: +/// - issue #309: PR overlay (state, review) surfaced in output +/// - issue #310: CI check-run aggregate badge surfaced in output +/// - `PARSEC_GITHUB_API_BASE` env var routes GitHub API calls to the mock +#[test] +fn test_smartlog_overlay_with_mock_github() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path().to_str().unwrap(); + + parsec() + .args(["start", "OV-2", "--repo", repo_path]) + .assert() + .success(); + + // After the worktree is created with the local bare remote, swap `origin` + // to a GitHub-style URL so `GitHubClient` can parse owner/repo. The + // `parsec smartlog` command reads this URL but never git-fetches from it. + StdCommand::new("git") + .args([ + "remote", + "set-url", + "origin", + "https://github.com/testowner/testrepo.git", + ]) + .current_dir(repo.path()) + .output() + .unwrap(); + + let branch = "feature/OV-2"; + let pr_number: u64 = 99; + let fake_sha = "aaabbbccc111222333444555666777888999000ab"; + + let mut server = mockito::Server::new(); + + // 1. find_pr_by_branch → GET /repos/.../pulls?head=testowner:{branch}&state=open + let _m_pr_list = server + .mock("GET", "/repos/testowner/testrepo/pulls") + .match_query(Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"[{{"number": {}}}]"#, pr_number)) + .expect_at_least(1) + .create(); + + // 2. GET /repos/.../pulls/{n} — called by get_pr_status AND get_check_runs. + let _m_pr_detail = server + .mock( + "GET", + format!("/repos/testowner/testrepo/pulls/{}", pr_number).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"number":{n},"title":"feat: mock PR","state":"open","mergeable":true,"html_url":"https://github.com/testowner/testrepo/pull/{n}","head":{{"sha":"{sha}","ref":"{branch}"}}}}"#, + n = pr_number, + sha = fake_sha, + branch = branch, + )) + .expect_at_least(1) + .create(); + + // 3. GET /repos/.../commits/{sha}/status (get_pr_status inner call) + let _m_status = server + .mock( + "GET", + format!("/repos/testowner/testrepo/commits/{}/status", fake_sha).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"state":"success"}"#) + .expect_at_least(1) + .create(); + + // 4. GET /repos/.../pulls/{n}/reviews (get_pr_status inner call) + let _m_reviews = server + .mock( + "GET", + format!("/repos/testowner/testrepo/pulls/{}/reviews", pr_number).as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"state":"APPROVED"}]"#) + .expect_at_least(1) + .create(); + + // 5. GET /repos/.../commits/{sha}/check-runs (get_check_runs inner call) + let _m_checks = server + .mock( + "GET", + format!( + "/repos/testowner/testrepo/commits/{}/check-runs", + fake_sha + ) + .as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"check_runs":[{"name":"CI","status":"completed","conclusion":"success","started_at":"2026-09-04T00:00:00Z","completed_at":"2026-09-04T00:05:00Z","html_url":"https://github.com/actions/runs/1"}]}"#, + ) + .expect_at_least(1) + .create(); + + let output = parsec() + .args(["smartlog", "--repo", repo_path]) + .env("PARSEC_GITHUB_TOKEN", "fake-token-for-test") + .env("PARSEC_GITHUB_API_BASE", server.url()) + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN") + .output() + .unwrap(); + + assert!( + output.status.success(), + "smartlog with mock GitHub must exit 0; stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("OV-2"), + "ticket must appear; got:\n{stdout}" + ); + assert!( + stdout.contains("[PR #99"), + "PR badge must appear; got:\n{stdout}" + ); + assert!( + stdout.contains("[CI: ✓ passed"), + "CI badge must show passed; got:\n{stdout}" + ); +}