diff --git a/.trellis/tasks/archive/2026-05/05-12-improve-tests/check.jsonl b/.trellis/tasks/archive/2026-05/05-12-improve-tests/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-improve-tests/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-12-improve-tests/implement.jsonl b/.trellis/tasks/archive/2026-05/05-12-improve-tests/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-improve-tests/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/archive/2026-05/05-12-improve-tests/prd.md b/.trellis/tasks/archive/2026-05/05-12-improve-tests/prd.md new file mode 100644 index 0000000..3b466cc --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-improve-tests/prd.md @@ -0,0 +1,50 @@ +# 补充完善测试用例 + +## Goal + +补充 `sing-cli` 现有 CLI、state、profile、service 模块的测试覆盖,锁定当前命令契约和错误处理行为,降低后续重构或修复时引入回归的风险。 + +## Requirements + +* 只新增或调整测试,不改变当前业务行为。 +* 覆盖主要 CLI 命令的成功路径:`install`、`add`、`remove`、`update`、`list`。 +* 覆盖关键错误路径:已存在 profile、删除 active profile、未安装 bin 启动、下载 HTTP 错误、无效 state 字段、非 Windows 服务操作、缺失 `sing-box.exe`。 +* 测试继续使用现有 pytest、Typer `CliRunner`、monkeypatch 和临时目录模式。 +* 不添加 mock 成功路径到生产代码;外部依赖通过测试替身注入或 monkeypatch 隔离。 + +## Acceptance Criteria + +* [ ] 新增测试能在本地稳定运行,不依赖真实 Windows、真实 NSSM、真实网络或真实用户 app data。 +* [ ] `uv run pytest` 通过。 +* [ ] `uv run ruff check src tests` 通过。 +* [ ] `uv run ty check src tests` 或项目可用的类型检查命令通过;若工具不支持测试路径,使用项目约定命令并记录结果。 + +## Definition of Done + +* Tests added or updated. +* Lint, type-check, and test suite pass. +* No production behavior changed unless tests expose an existing bug that must be fixed explicitly. +* No docs update unless behavior or documented command contract changes. + +## Technical Approach + +以现有测试风格为基准扩展覆盖:CLI 测试通过 `CliRunner` 调用命令并 monkeypatch `app_dir`、service/profile 函数;模块测试使用轻量 stub 验证状态解析、下载错误和 service 参数/平台边界。 + +## Decision (ADR-lite) + +**Context**: 当前测试已经覆盖部分核心路径,但 CLI 命令集合和错误处理矩阵尚未完整锁定。 +**Decision**: 本任务优先补充行为测试,不引入新测试框架、不修改生产代码。 +**Consequences**: 覆盖面提升且变更风险低;如果测试暴露真实行为缺陷,再以最小生产代码修改修复。 + +## Out of Scope + +* 不新增功能。 +* 不重构生产代码。 +* 不引入覆盖率工具或强制覆盖率阈值。 +* 不测试真实 Windows service 或真实网络下载。 + +## Technical Notes + +* 项目使用 Python `>=3.13`,测试框架为 pytest。 +* 现有测试文件:`tests/test_cli.py`、`tests/test_profile.py`、`tests/test_state.py`、`tests/test_service.py`。 +* 相关规范:`.trellis/spec/backend/index.md`、`.trellis/spec/backend/quality-guidelines.md`、`.trellis/spec/backend/error-handling.md`。 diff --git a/.trellis/tasks/archive/2026-05/05-12-improve-tests/task.json b/.trellis/tasks/archive/2026-05/05-12-improve-tests/task.json new file mode 100644 index 0000000..58fe339 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-improve-tests/task.json @@ -0,0 +1,26 @@ +{ + "id": "improve-tests", + "name": "improve-tests", + "title": "补充完善测试用例", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pixelcola", + "assignee": "pixelcola", + "createdAt": "2026-05-12", + "completedAt": "2026-05-12", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workspace/pixelcola/index.md b/.trellis/workspace/pixelcola/index.md index 97fb16d..bf8e510 100644 --- a/.trellis/workspace/pixelcola/index.md +++ b/.trellis/workspace/pixelcola/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 6 +- **Total Sessions**: 7 - **Last Active**: 2026-05-12 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~205 | Active | +| `journal-1.md` | ~238 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 7 | 2026-05-12 | Expand test coverage | `764205a` | `test/improve-coverage` | | 6 | 2026-05-12 | Update README installation instructions | `49680f6` | `docs/readme-install-uv-tool` | | 5 | 2026-05-12 | Fix restart active profile | `010e59e` | `fix/restart-active-config` | | 4 | 2026-05-12 | Fix Windows subprocess output decoding | `e90a676` | `fix/windows-subprocess-output-decoding` | diff --git a/.trellis/workspace/pixelcola/journal-1.md b/.trellis/workspace/pixelcola/journal-1.md index ad58b68..abe7693 100644 --- a/.trellis/workspace/pixelcola/journal-1.md +++ b/.trellis/workspace/pixelcola/journal-1.md @@ -203,3 +203,36 @@ Documented Scoop runtime dependency installation and uv tool install, upgrade, a ### Next Steps - None - task complete + + +## Session 7: Expand test coverage + +**Date**: 2026-05-12 +**Task**: Expand test coverage +**Branch**: `test/improve-coverage` + +### Summary + +Added CLI, profile, state, and service tests covering command success paths, failure propagation, and state preservation. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `764205a` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/tests/test_cli.py b/tests/test_cli.py index c9bdb54..c1f937a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,6 +4,7 @@ from typer.testing import CliRunner from sing_cli import cli +from sing_cli.errors import SingCliError from sing_cli.state import ProfileEntry, State, load_state, save_state @@ -11,6 +12,205 @@ def write_state(tmp_path: Path, state: State) -> None: save_state(tmp_path / "state.json", state) +def test_install_registers_service_and_saves_bin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + runner = CliRunner() + bin_path = tmp_path / "sing-box.exe" + service_calls: list[Path] = [] + bin_path.write_text("exe", encoding="utf-8") + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "resolve_bin", lambda value: bin_path) + monkeypatch.setattr(cli, "install_service", lambda value: service_calls.append(value)) + + result = runner.invoke(cli.app, ["install", "--bin", str(bin_path)]) + + assert result.exit_code == 0 + assert result.stdout == f"Installed {bin_path}\n" + assert service_calls == [bin_path] + assert load_state(tmp_path / "state.json").bin == str(bin_path) + + +def test_install_failure_does_not_save_bin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + bin_path = tmp_path / "sing-box.exe" + bin_path.write_text("exe", encoding="utf-8") + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "resolve_bin", lambda value: bin_path) + + def install_service(value: Path) -> None: + raise SingCliError("install failed") + + monkeypatch.setattr(cli, "install_service", install_service) + + result = CliRunner().invoke(cli.app, ["install", "--bin", str(bin_path)]) + + assert result.exit_code == 1 + assert result.stderr == "Error: install failed\n" + assert not (tmp_path / "state.json").exists() + + +def test_uninstall_reports_success(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(cli, "uninstall_service", lambda: calls.append("uninstall")) + + result = CliRunner().invoke(cli.app, ["uninstall"]) + + assert result.exit_code == 0 + assert result.stdout == "Uninstalled sing-box service\n" + assert calls == ["uninstall"] + + +def test_uninstall_reports_service_error(monkeypatch: pytest.MonkeyPatch) -> None: + def uninstall_service() -> None: + raise SingCliError("remove failed") + + monkeypatch.setattr(cli, "uninstall_service", uninstall_service) + + result = CliRunner().invoke(cli.app, ["uninstall"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: remove failed\n" + + +def test_start_configures_service_and_saves_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, str | None, str | None]] = [] + write_state( + tmp_path, + State( + bin="C:/tools/sing-box.exe", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "service_is_running", lambda: False) + monkeypatch.setattr( + cli, + "configure_service", + lambda bin_path, profile_path: calls.append(("configure", bin_path, profile_path)), + ) + monkeypatch.setattr(cli, "start_service", lambda: calls.append(("start", None, None))) + + result = CliRunner().invoke(cli.app, ["start", "home"]) + + assert result.exit_code == 0 + assert result.stdout == "Started home\n" + assert calls == [ + ("configure", "C:/tools/sing-box.exe", "C:/profiles/home.json"), + ("start", None, None), + ] + assert load_state(tmp_path / "state.json").active == "home" + + +def test_start_requires_installed_bin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "service_is_running", lambda: False) + + result = CliRunner().invoke(cli.app, ["start", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: sing-box.exe path is not installed. Run 'sing install' first.\n" + + +def test_start_configure_failure_does_not_start_or_save_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[str] = [] + state = State( + bin="C:/tools/sing-box.exe", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ) + write_state(tmp_path, state) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "service_is_running", lambda: False) + + def configure_service(bin_path: str, profile_path: str) -> None: + calls.append("configure") + raise SingCliError("configure failed") + + monkeypatch.setattr(cli, "configure_service", configure_service) + monkeypatch.setattr(cli, "start_service", lambda: calls.append("start")) + + result = CliRunner().invoke(cli.app, ["start", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: configure failed\n" + assert calls == ["configure"] + assert load_state(tmp_path / "state.json") == state + + +def test_start_service_failure_does_not_save_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + state = State( + bin="C:/tools/sing-box.exe", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ) + write_state(tmp_path, state) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "service_is_running", lambda: False) + monkeypatch.setattr(cli, "configure_service", lambda bin_path, profile_path: None) + + def start_service() -> None: + raise SingCliError("start failed") + + monkeypatch.setattr(cli, "start_service", start_service) + + result = CliRunner().invoke(cli.app, ["start", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: start failed\n" + assert load_state(tmp_path / "state.json") == state + + +def test_stop_reports_success(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(cli, "stop_service", lambda: calls.append("stop")) + + result = CliRunner().invoke(cli.app, ["stop"]) + + assert result.exit_code == 0 + assert result.stdout == "Stopped sing-box service\n" + assert calls == ["stop"] + + +def test_stop_reports_service_error(monkeypatch: pytest.MonkeyPatch) -> None: + def stop_service() -> None: + raise SingCliError("stop failed") + + monkeypatch.setattr(cli, "stop_service", stop_service) + + result = CliRunner().invoke(cli.app, ["stop"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: stop failed\n" + + def test_restart_uses_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: runner = CliRunner() calls: list[tuple[str, str | None, str | None]] = [] @@ -47,6 +247,41 @@ def test_restart_uses_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyP assert load_state(tmp_path / "state.json") == state +def test_restart_stop_failure_does_not_reconfigure_or_start( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[str] = [] + write_state( + tmp_path, + State( + bin="C:/tools/sing-box.exe", + active="home", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + def stop_service() -> None: + calls.append("stop") + raise SingCliError("stop failed") + + monkeypatch.setattr(cli, "stop_service", stop_service) + monkeypatch.setattr(cli, "configure_service", lambda bin_path, profile_path: calls.append("configure")) + monkeypatch.setattr(cli, "start_service", lambda: calls.append("start")) + + result = CliRunner().invoke(cli.app, ["restart"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: stop failed\n" + assert calls == ["stop"] + + def test_restart_rejects_profile_argument() -> None: result = CliRunner().invoke(cli.app, ["restart", "home"]) @@ -95,3 +330,231 @@ def test_start_running_service_mentions_restart_without_name(tmp_path: Path, mon assert result.exit_code == 1 assert result.stderr == "Error: sing-box service is already running. Use 'sing restart'.\n" + + +def test_add_downloads_profile_and_saves_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + downloads: list[tuple[str, Path]] = [] + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "now_utc", lambda: "2026-05-12T00:00:00Z") + + def download_profile(url: str, destination: Path) -> None: + downloads.append((url, destination)) + destination.parent.mkdir(parents=True) + destination.write_text('{"log":{"level":"info"}}', encoding="utf-8") + + monkeypatch.setattr(cli, "download_profile", download_profile) + + result = CliRunner().invoke(cli.app, ["add", "home", "https://example.com/home.json"]) + + assert result.exit_code == 0 + assert result.stdout == "Added home\n" + assert downloads == [("https://example.com/home.json", tmp_path / "profiles" / "home")] + assert load_state(tmp_path / "state.json") == State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path=str(tmp_path / "profiles" / "home"), + updated_at="2026-05-12T00:00:00Z", + ) + }, + ) + + +def test_add_rejects_existing_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home.json", + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["add", "home", "https://example.com/other.json"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: Profile already exists: home\n" + + +def test_add_rejects_invalid_profile_name_without_downloading( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + downloads: list[str] = [] + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "download_profile", lambda url, destination: downloads.append(url)) + + result = CliRunner().invoke(cli.app, ["add", "../home", "https://example.com/home.json"]) + + assert result.exit_code == 1 + assert "Profile name may only contain" in result.stderr + assert downloads == [] + assert not (tmp_path / "state.json").exists() + + +def test_add_download_failure_does_not_save_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + def download_profile(url: str, destination: Path) -> None: + raise SingCliError("download failed") + + monkeypatch.setattr(cli, "download_profile", download_profile) + + result = CliRunner().invoke(cli.app, ["add", "home", "https://example.com/home.json"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: download failed\n" + assert not (tmp_path / "state.json").exists() + + +def test_remove_deletes_non_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + profile_file = tmp_path / "profiles" / "home" + profile_file.parent.mkdir() + profile_file.write_text("{}", encoding="utf-8") + write_state( + tmp_path, + State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path=str(profile_file), + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["remove", "home"]) + + assert result.exit_code == 0 + assert result.stdout == "Removed home\n" + assert not profile_file.exists() + assert load_state(tmp_path / "state.json") == State() + + +def test_remove_rejects_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + active="home", + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path=str(tmp_path / "profiles" / "home"), + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["remove", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: Cannot remove active profile: home\n" + + +def test_update_redownloads_profile_and_updates_timestamp(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + profile_file = tmp_path / "profiles" / "home" + downloads: list[tuple[str, Path]] = [] + write_state( + tmp_path, + State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path=str(profile_file), + updated_at="2026-05-12T00:00:00Z", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "now_utc", lambda: "2026-05-12T01:00:00Z") + monkeypatch.setattr(cli, "download_profile", lambda url, destination: downloads.append((url, destination))) + + result = CliRunner().invoke(cli.app, ["update", "home"]) + + assert result.exit_code == 0 + assert result.stdout == "Updated home\n" + assert downloads == [("https://example.com/home.json", profile_file)] + assert load_state(tmp_path / "state.json").profiles["home"].updated_at == "2026-05-12T01:00:00Z" + + +def test_update_download_failure_preserves_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + state = State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path=str(tmp_path / "profiles" / "home"), + updated_at="2026-05-12T00:00:00Z", + ) + }, + ) + write_state(tmp_path, state) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + def download_profile(url: str, destination: Path) -> None: + raise SingCliError("download failed") + + monkeypatch.setattr(cli, "download_profile", download_profile) + + result = CliRunner().invoke(cli.app, ["update", "home"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: download failed\n" + assert load_state(tmp_path / "state.json") == state + + +def test_update_requires_existing_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state(tmp_path, State()) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["update", "missing"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: Profile does not exist: missing\n" + + +def test_list_profiles_marks_active_profile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + active="work", + profiles={ + "work": ProfileEntry( + url="https://example.com/work.json", + path="C:/profiles/work", + updated_at="2026-05-12T01:00:00Z", + ), + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home", + updated_at="2026-05-12T00:00:00Z", + ), + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["list"]) + + assert result.exit_code == 0 + assert result.stdout == ( + " home\thttps://example.com/home.json\t2026-05-12T00:00:00Z\n" + "* work\thttps://example.com/work.json\t2026-05-12T01:00:00Z\n" + ) + + +def test_list_profiles_reports_empty_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["list"]) + + assert result.exit_code == 0 + assert result.stdout == "No profiles\n" diff --git a/tests/test_profile.py b/tests/test_profile.py index 4cee582..fc777fa 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -20,6 +20,11 @@ def response_with_text(text: str) -> httpx.Response: return httpx.Response(200, text=text, request=request) +def response_with_status(status_code: int) -> httpx.Response: + request = httpx.Request("GET", "https://example.com/profile.json") + return httpx.Response(status_code, text="error", request=request) + + def test_download_profile_writes_valid_json(tmp_path: Path) -> None: destination = tmp_path / "profile.json" @@ -43,3 +48,16 @@ def test_download_profile_rejects_invalid_json(tmp_path: Path) -> None: ) assert not destination.exists() + + +def test_download_profile_reports_http_error(tmp_path: Path) -> None: + destination = tmp_path / "profile.json" + + with pytest.raises(SingCliError, match="Unable to download profile"): + download_profile( + "https://example.com/profile.json", + destination, + StubHttpClient(response_with_status(500)), + ) + + assert not destination.exists() diff --git a/tests/test_service.py b/tests/test_service.py index b2015b8..d5270d2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -4,8 +4,8 @@ import pytest -from sing_cli.errors import ExternalCommandError from sing_cli import service +from sing_cli.errors import ExternalCommandError def completed_process( @@ -75,6 +75,45 @@ def runner(command: list[str]) -> subprocess.CompletedProcess[str]: ] +def test_uninstall_service_removes_service_with_confirm(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + commands: list[list[str]] = [] + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + commands.append(command) + return completed_process(command) + + service.uninstall_service(runner) + + assert commands == [["C:/tools/nssm.exe", "remove", "sing-box", "confirm"]] + + +def test_start_service_starts_service(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + commands: list[list[str]] = [] + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + commands.append(command) + return completed_process(command) + + service.start_service(runner) + + assert commands == [["C:/tools/nssm.exe", "start", "sing-box"]] + + +def test_stop_service_stops_service(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + commands: list[list[str]] = [] + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + commands.append(command) + return completed_process(command) + + service.stop_service(runner) + + assert commands == [["C:/tools/nssm.exe", "stop", "sing-box"]] + + def test_run_nssm_surfaces_external_command_failure(monkeypatch: pytest.MonkeyPatch) -> None: enable_windows(monkeypatch) @@ -85,6 +124,26 @@ def runner(command: list[str]) -> subprocess.CompletedProcess[str]: service.run_nssm(["start", "sing-box"], runner) +def test_run_nssm_uses_stdout_when_stderr_is_empty(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + return completed_process(command, stdout="The service has not been started.", returncode=3) + + with pytest.raises(ExternalCommandError, match="The service has not been started"): + service.run_nssm(["stop", "sing-box"], runner) + + +def test_run_nssm_reports_exit_code_when_output_is_empty(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + return completed_process(command, returncode=7) + + with pytest.raises(ExternalCommandError, match="exit code 7"): + service.run_nssm(["start", "sing-box"], runner) + + def test_resolve_nssm_reports_missing_executable(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(service.shutil, "which", lambda executable: None) @@ -92,6 +151,33 @@ def test_resolve_nssm_reports_missing_executable(monkeypatch: pytest.MonkeyPatch service.resolve_nssm() +def test_run_nssm_rejects_non_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service.sys, "platform", "linux") + + with pytest.raises(service.SingCliError, match="only supported on Windows"): + service.run_nssm(["start", "sing-box"]) + + +def test_resolve_bin_uses_path_lookup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + bin_path = tmp_path / "sing-box.exe" + bin_path.write_text("exe", encoding="utf-8") + monkeypatch.setattr(service.shutil, "which", lambda executable: str(bin_path)) + + assert service.resolve_bin(None) == bin_path + + +def test_resolve_bin_reports_missing_path_lookup(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(service.shutil, "which", lambda executable: None) + + with pytest.raises(service.SingCliError, match="sing-box.exe was not found"): + service.resolve_bin(None) + + +def test_resolve_bin_reports_non_file_path(tmp_path: Path) -> None: + with pytest.raises(service.SingCliError, match="does not exist or is not a file"): + service.resolve_bin(tmp_path / "missing.exe") + + def test_service_is_running_reads_nssm_status(monkeypatch: pytest.MonkeyPatch) -> None: enable_windows(monkeypatch) @@ -99,3 +185,12 @@ def runner(command: list[str]) -> subprocess.CompletedProcess[str]: return completed_process(command, stdout="SERVICE_RUNNING") assert service.service_is_running(runner) + + +def test_service_is_running_returns_false_for_stopped_status(monkeypatch: pytest.MonkeyPatch) -> None: + enable_windows(monkeypatch) + + def runner(command: list[str]) -> subprocess.CompletedProcess[str]: + return completed_process(command, stdout="SERVICE_STOPPED") + + assert not service.service_is_running(runner) diff --git a/tests/test_state.py b/tests/test_state.py index 81c3c6b..474dbb7 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -37,3 +37,35 @@ def test_state_round_trip(tmp_path: Path) -> None: def test_require_profile_reports_missing_name() -> None: with pytest.raises(SingCliError, match="Profile does not exist"): require_profile(State(), "missing") + + +def test_load_state_rejects_non_object_root(tmp_path: Path) -> None: + path = tmp_path / "state.json" + path.write_text("[]", encoding="utf-8") + + with pytest.raises(SingCliError, match="must contain a JSON object"): + load_state(path) + + +def test_load_state_rejects_non_object_profiles(tmp_path: Path) -> None: + path = tmp_path / "state.json" + path.write_text('{"profiles": []}', encoding="utf-8") + + with pytest.raises(SingCliError, match="profiles"): + load_state(path) + + +def test_load_state_rejects_invalid_profile_entry(tmp_path: Path) -> None: + path = tmp_path / "state.json" + path.write_text('{"profiles": {"home": {"url": 1, "path": "p", "updated_at": "t"}}}', encoding="utf-8") + + with pytest.raises(SingCliError, match="invalid profile entry"): + load_state(path) + + +def test_load_state_rejects_non_string_optional_fields(tmp_path: Path) -> None: + path = tmp_path / "state.json" + path.write_text('{"bin": 1, "active": null, "profiles": {}}', encoding="utf-8") + + with pytest.raises(SingCliError, match="State field 'bin'"): + load_state(path)