"""`peach.onboarding`:首次运行问答的题目、校验与落盘,纯逻辑、可注入。 CLI 与将来的托盘设置页共用这一层,所以这里只用脚本化答案驱动,不碰 stdin;平台由 `windows=` 注入。例外是真实目录:媒体目录题要收一个真实存在的目录,写回设置文件的 真实目录要配上账本口径的声明根,而那个目录是不是盘符路径由测试机决定,所以这些用例 按 `NATIVE_WINDOWS` 用本机形态。 """ from __future__ import annotations import importlib.util import io import ntpath import os import sqlite3 import tempfile import unittest from contextlib import contextmanager from pathlib import Path from unittest import mock from peach import distribution, onboarding, settings_file NATIVE_WINDOWS = os.name == "nt" HAS_HTTP_DEPS = all(importlib.util.find_spec(name) for name in ("fastapi", "httpx")) def local_media(*directories: Path) -> tuple[dict, dict]: """本机真实目录对应的 `[media.locations]` 与 `[media.mounts]`。 声明根在所有系统上都是账本口径的 Windows 路径:Windows 直接用这些目录; 其他系统按 `peach init` 的写法用 `R:\\media` 起的声明根,真实目录按顺序进 `[media.mounts]`。 """ actual = tuple(str(directory) for directory in directories) if NATIVE_WINDOWS: return {"local": actual}, {} return {"local": onboarding.posix_declared_roots(len(actual))}, {"local": actual} def scripted(*answers: str): """按顺序吐答案,并把每一题的题目与默认值记下来。""" queue = list(answers) seen: list[tuple[str, str]] = [] def ask(prompt: str, default: str) -> str: seen.append((prompt, default)) return queue.pop(0) ask.seen = seen # type: ignore[attr-defined] return ask class _Case(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name).resolve() self.home = self.root / "home" self.home.mkdir() self.media = self.root / "media" self.media.mkdir() self.config = settings_file.load_config( project_root=self.root / "app", environ={"PEACH_DATA_ROOT": str(self.root / "peach-data")}) class QuestionTests(_Case): def test_questions_come_in_a_fixed_order_with_defaults_from_the_config(self): asked = onboarding.questions(self.config, windows=True, home=self.home) self.assertEqual([q.key for q in asked], ["data_root", "media_dir", "host", "port", "mdns_name"]) by_key = {q.key: q for q in asked} self.assertEqual(by_key["data_root"].default, str(self.root / "peach-data")) self.assertEqual(by_key["host"].default, "2") self.assertEqual(by_key["port"].default, "8900") self.assertEqual(by_key["mdns_name"].default, "peach") def test_media_default_is_the_system_video_folder_only_when_it_exists(self): self.assertIsNone(onboarding.default_media_dir(self.home, windows=True)) (self.home / "Videos").mkdir() self.assertEqual(onboarding.default_media_dir(self.home, windows=True), self.home / "Videos") self.assertIsNone(onboarding.default_media_dir(self.home, windows=False)) (self.home / "Movies").mkdir() self.assertEqual(onboarding.default_media_dir(self.home, windows=False), self.home / "Movies") asked = {q.key: q for q in onboarding.questions(self.config, windows=False, home=self.home)} self.assertEqual(asked["media_dir"].default, str(self.home / "Movies")) class ValidatorTests(_Case): def test_windows_mapped_directory_keeps_its_drive_path(self): mapped = r"Z:\CloudDrive2\115open\云下载" redirected = Path(r"\\NAS\CloudDrive2\115open\云下载") windows_abspath = ntpath.abspath with mock.patch.object(Path, "is_dir", return_value=True), \ mock.patch.object(Path, "resolve", return_value=redirected), \ mock.patch("peach.onboarding.os.path.abspath", side_effect=windows_abspath): validate = onboarding.media_dir_validator(windows=True) for raw in (mapped, mapped + r"\子目录\.."): with self.subTest(raw=raw): self.assertEqual(validate(raw), Path(mapped)) def test_windows_unc_directory_requires_a_drive_mapping(self): shared = r"\\NAS\媒体库" windows_abspath = ntpath.abspath with mock.patch.object(Path, "is_dir", return_value=True), \ mock.patch.object(Path, "resolve", return_value=Path(shared)), \ mock.patch("peach.onboarding.os.path.abspath", side_effect=windows_abspath): with self.assertRaisesRegex(ValueError, "不是盘符路径"): onboarding.media_dir_validator(windows=True)(shared) def test_posix_media_directory_uses_its_filesystem_target(self): with mock.patch.object(Path, "resolve", return_value=self.media): self.assertEqual(onboarding.media_dir_validator(windows=False)(str(self.home)), self.media) def test_media_dir_must_already_exist_and_is_never_created(self): validate = onboarding.media_dir_validator(windows=False) missing = self.root / "nope" with self.assertRaises(ValueError) as caught: validate(str(missing)) self.assertIn("不存在", str(caught.exception)) self.assertFalse(missing.exists()) with self.assertRaises(ValueError): validate("") self.assertEqual(validate(str(self.media)), self.media) def test_host_accepts_the_two_choices_in_several_spellings(self): for raw in ("1", "本机", "127.0.0.1"): self.assertEqual(onboarding.validate_host(raw), "127.0.0.1") for raw in ("2", "局域网", "0.0.0.0"): self.assertEqual(onboarding.validate_host(raw), "0.0.0.0") with self.assertRaises(ValueError): onboarding.validate_host("3") def test_port_is_an_integer_in_range(self): self.assertEqual(onboarding.validate_port(" 8900 "), 8900) for raw in ("0", "65536", "abc", ""): with self.assertRaises(ValueError): onboarding.validate_port(raw) def test_mdns_name_is_a_dns_label(self): self.assertEqual(onboarding.validate_mdns_name("peach-two"), "peach-two") for raw in ("", "-peach", "peach.local", "pea ch"): with self.assertRaises(ValueError): onboarding.validate_mdns_name(raw) def test_yes_no_defaults_to_yes(self): for raw in ("", "y", "Yes", "是"): self.assertTrue(onboarding.validate_yes_no(raw)) for raw in ("n", "NO", "否"): self.assertFalse(onboarding.validate_yes_no(raw)) with self.assertRaises(ValueError): onboarding.validate_yes_no("maybe") def test_data_root_may_not_exist_yet_but_may_not_be_a_file(self): self.assertEqual(onboarding.validate_data_root(str(self.root / "new")), self.root / "new") (self.root / "file").write_text("x", encoding="utf-8") with self.assertRaises(ValueError): onboarding.validate_data_root(str(self.root / "file")) class AskUntilValidTests(_Case): def test_empty_input_takes_the_default(self): question = onboarding.Question("port", "服务端口", "8900", onboarding.validate_port) self.assertEqual(onboarding.ask_until_valid(question, scripted(""), report=lambda _: None), 8900) def test_invalid_answers_are_reported_and_reasked_up_to_the_limit(self): question = onboarding.Question("port", "服务端口", "8900", onboarding.validate_port) reported: list[str] = [] value = onboarding.ask_until_valid(question, scripted("x", "y", "9000"), report=reported.append) self.assertEqual(value, 9000) self.assertEqual(len(reported), 2) self.assertIn("还可以再试 2 次", reported[0]) with self.assertRaises(onboarding.OnboardingAborted) as caught: onboarding.ask_until_valid(question, scripted("x", "y", "z"), report=lambda _: None) self.assertIn("服务端口", str(caught.exception)) self.assertIn("3 次", str(caught.exception)) class ConfigureTests(_Case): def _answers(self, **overrides): base = dict(data_root=self.root / "peach-data", media_dirs=(self.media,), host="127.0.0.1", port=8900, mdns_name="peach") base.update(overrides) return onboarding.Answers(**base) def test_windows_declares_the_directory_itself_and_leaves_mounts_empty(self): prepared = onboarding.configure(self.config, self._answers(), windows=True) self.assertEqual(prepared.locations, {"local": (str(self.media),)}) self.assertEqual(prepared.mounts, {}) self.assertEqual(onboarding.scan_roots(prepared), (str(self.media),)) def test_posix_keeps_the_ledger_shape_and_mounts_the_directory(self): prepared = onboarding.configure(self.config, self._answers(), windows=False) self.assertEqual(prepared.locations, {"local": (r"R:\media",)}) self.assertEqual(prepared.mounts, {"local": (str(self.media),)}) self.assertEqual(onboarding.scan_roots(prepared), (r"R:\media",)) self.assertIn(str(self.media), onboarding.mounts_explanation(self.media)) def test_several_directories_all_belong_to_the_local_source(self): """终端设置的两个本地目录:Windows 直接使用目录路径,macOS 按序号生成数据库路径前缀。""" second = self.root / "more" second.mkdir() answers = self._answers(media_dirs=(self.media, second)) windows = onboarding.configure(self.config, answers, windows=True) self.assertEqual(windows.locations, {"local": (str(self.media), str(second))}) self.assertEqual(windows.mounts, {}) posix = onboarding.configure(self.config, answers, windows=False) self.assertEqual(posix.locations, {"local": (r"R:\media", r"R:\media2")}) self.assertEqual(posix.mounts, {"local": (str(self.media), str(second))}) self.assertEqual(onboarding.scan_roots(posix), (r"R:\media", r"R:\media2")) explanation = onboarding.mounts_explanation((self.media, second)) self.assertIn(r"R:\media2", explanation) self.assertIn(str(second), explanation) def test_directories_must_not_repeat_or_nest(self): nested = self.media / "inner" nested.mkdir() other = self.root / "other" other.mkdir() self.assertEqual(onboarding.check_media_dirs((self.media, other)), {}) self.assertEqual(list(onboarding.check_media_dirs((self.media, self.media))), [1]) self.assertIn("重复", onboarding.check_media_dirs((self.media, self.media))[1]) problems = onboarding.check_media_dirs((self.media, nested)) self.assertEqual(list(problems), [1]) self.assertIn(str(self.media), problems[1]) # 父目录排在后面时,多余的仍是子目录那一行:用户后来给的父目录已经把它包住了。 self.assertEqual(list(onboarding.check_media_dirs((nested, self.media))), [0]) def test_form_rows_are_read_line_by_line_with_errors_kept_in_place(self): validate = onboarding.media_dir_validator(windows=NATIVE_WINDOWS) other = self.root / "other" other.mkdir() # 第一行留空取默认值,中间的空行当没填。 paths, problems = onboarding.read_media_dirs( ["", "", str(other)], validate=validate, default=str(self.media)) self.assertEqual(paths, [self.media, other]) self.assertEqual(problems, []) # 错误与提交的行一一对应:第三行不存在,第二行是空行也占一个位置。 _paths, problems = onboarding.read_media_dirs( [str(self.media), "", str(self.root / "nope")], validate=validate) self.assertEqual(problems[:2], ["", ""]) self.assertIn("目录不存在", problems[2]) (self.media / "inner").mkdir() _paths, problems = onboarding.read_media_dirs( [str(self.media), str(self.media / "inner")], validate=validate) self.assertEqual(problems[0], "") self.assertIn("已经在", problems[1]) def test_only_the_asked_settings_change_and_replication_stays_off(self): prepared = onboarding.configure( self.config, self._answers(host="0.0.0.0", port=9443, mdns_name="peach-two"), windows=True) self.assertEqual((prepared.server.host, prepared.server.port, prepared.server.mdns_name), ("0.0.0.0", 9443, "peach-two")) self.assertFalse(prepared.replication.enabled) self.assertEqual(prepared.server.review_writer_origin, "") self.assertEqual(prepared.replication.smb_host, "") self.assertEqual(set(prepared.directories), set(settings_file.DIRECTORY_KEYS)) def test_the_written_file_reads_back_with_only_the_declared_source(self): """`[media.locations]` 里只有 local:内建默认的示例盘符不得从旁边混进来。""" prepared = onboarding.configure(self.config, self._answers(), windows=NATIVE_WINDOWS) settings_file.write(prepared) loaded = settings_file.load_config( project_root=self.root / "app", environ={"PEACH_DATA_ROOT": str(self.root / "peach-data")}) self.assertTrue(loaded.present) self.assertEqual((loaded.locations, loaded.mounts), local_media(self.media)) self.assertFalse(loaded.replication.enabled) class InterviewTests(_Case): def test_scripted_answers_drive_the_whole_interview(self): second = self.root / "more" second.mkdir() ask = scripted("", str(self.media), str(second), "", "2", "", "peach-two") answers = onboarding.interview(self.config, ask, windows=NATIVE_WINDOWS, home=self.home, report=lambda _: None) self.assertEqual(answers, onboarding.Answers( data_root=self.root / "peach-data", media_dirs=(self.media, second), host="0.0.0.0", port=8900, mdns_name="peach-two")) # 媒体目录问完一个就问「再加一个」,回车结束;追加的目录和第一个走同一套校验。 self.assertEqual([prompt for prompt, _ in ask.seen], [ "数据目录(Peach 数据库、缓存和设置文件都放在这里)", "媒体目录(必须已经存在,可以在外置硬盘上)", "再加一个媒体目录(不加就直接回车)", "再加一个媒体目录(不加就直接回车)", "谁可以访问:1 = 只有这台电脑,2 = 同一局域网的设备", "端口", "局域网访问地址(<名字>.local,只在允许局域网访问时发布)", ]) def test_an_extra_directory_inside_the_first_is_asked_again(self): nested = self.media / "inner" nested.mkdir() reports: list[str] = [] ask = scripted("", str(self.media), str(nested), "", "1", "", "") answers = onboarding.interview(self.config, ask, windows=NATIVE_WINDOWS, home=self.home, report=reports.append) self.assertEqual(answers.media_dirs, (self.media,)) self.assertTrue(any("已经在" in line for line in reports), reports) def test_console_ask_shows_the_default_and_treats_eof_as_abort(self): with mock.patch("builtins.input", return_value="x") as prompt: self.assertEqual(onboarding.console_ask("服务端口", "8900"), "x") prompt.assert_called_once_with("服务端口 [8900]: ") with mock.patch("builtins.input", return_value="") as prompt: onboarding.console_ask("本地媒体目录", "") prompt.assert_called_once_with("本地媒体目录(必填): ") with mock.patch("builtins.input", side_effect=EOFError): with self.assertRaises(onboarding.OnboardingAborted): onboarding.console_ask("服务端口", "8900") def test_only_a_real_terminal_counts_as_interactive(self): self.assertFalse(onboarding.is_interactive(io.StringIO())) self.assertFalse(onboarding.is_interactive(None)) self.assertTrue(onboarding.is_interactive(mock.Mock(isatty=lambda: True))) class ApplyTests(_Case): """`apply()` 是 CLI 问答与设置页共用的落盘入口。""" def setUp(self): super().setUp() (self.media / "sub").mkdir() (self.media / "sub" / "a.mp4").write_bytes(b"0" * 10) patcher = mock.patch.object(onboarding.certs, "bootstrap_certificates") self.certs = patcher.start() self.certs.return_value.ca_cert = self.root / "peach-data" / "secrets" / "tls" / "ca.crt" self.addCleanup(patcher.stop) def _answers(self, **overrides): base = dict(data_root=self.root / "peach-data", media_dirs=(self.media,), host="127.0.0.1", port=8900, mdns_name="peach") base.update(overrides) return onboarding.Answers(**base) def test_apply_builds_the_whole_tree_and_writes_the_settings_file(self): applied = onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) data_root = self.root / "peach-data" for key in settings_file.DIRECTORY_KEYS: self.assertTrue((data_root / key).is_dir(), key) self.assertTrue((data_root / "secrets" / "tls").is_dir()) self.assertEqual(applied.settings_path, data_root / "config.toml") self.assertTrue(applied.settings_path.is_file()) self.assertFalse(applied.tree.ledger_existed) self.assertGreater(applied.tree.migrations, 0) self.assertTrue(applied.tree.database.is_file()) self.assertTrue(applied.tree.token_path.is_file()) self.assertTrue(applied.tree.token_created) self.certs.assert_called_once() # 账本真的被迁到最新:`asset` 表在,且是空的(apply 不扫描)。 connection = sqlite3.connect(applied.tree.database) try: self.assertEqual(connection.execute("SELECT COUNT(*) FROM asset").fetchone()[0], 0) finally: connection.close() def test_an_existing_ledger_is_never_migrated_or_overwritten(self): first = onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) again = onboarding.create_data_tree(first.config) self.assertTrue(again.ledger_existed) self.assertEqual(again.migrations, 0) self.assertFalse(again.token_created, "口令沿用已有的那份,不重新生成") def test_a_missing_openssl_is_reported_instead_of_aborting_the_setup(self): self.certs.side_effect = RuntimeError("openssl 不在 PATH 上") applied = onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) self.assertIsNone(applied.tree.ca_cert) self.assertIn("openssl", applied.tree.ca_error) self.assertTrue(applied.settings_path.is_file(), "没有 CA 也要把设置文件写出来") def test_writing_over_an_existing_settings_file_needs_force(self): onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) with self.assertRaises(settings_file.SettingsFileError): onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) onboarding.apply(self.config, self._answers(port=9100), windows=NATIVE_WINDOWS, force=True) loaded, _ = onboarding.resolve_config(self.root / "peach-data", environ={}) self.assertEqual(loaded.server.port, 9100) def test_required_tls_checks_the_signer_before_creating_the_data_tree(self): with mock.patch.object(onboarding.certs, "require_openssl", side_effect=RuntimeError("OpenSSL 未取得")): with self.assertRaisesRegex(RuntimeError, "OpenSSL"): onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS, require_tls=True) self.assertFalse((self.root / "peach-data" / "database" / "ledger.db").exists()) self.assertFalse((self.root / "peach-data" / "config.toml").exists()) self.certs.assert_not_called() def test_required_tls_does_not_save_settings_after_certificate_generation_fails(self): self.certs.side_effect = RuntimeError("证书签发失败") with mock.patch.object(onboarding.certs, "require_openssl"): with self.assertRaisesRegex(RuntimeError, "证书签发失败"): onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS, require_tls=True) self.assertFalse((self.root / "peach-data" / "config.toml").exists()) def test_the_first_scan_marker_is_consumed_exactly_once(self): applied = onboarding.apply(self.config, self._answers(), windows=NATIVE_WINDOWS) config = applied.config self.assertIsNone(onboarding.take_first_scan_request(config)) path = onboarding.request_first_scan(config) self.assertEqual(path, config.directory("state") / onboarding.SCAN_REQUEST_NAME) self.assertEqual(onboarding.take_first_scan_request(config), "local") self.assertFalse(path.exists()) self.assertIsNone(onboarding.take_first_scan_request(config)) def test_resolve_config_follows_the_given_data_root(self): elsewhere = self.root / "somewhere-else" config, broken = onboarding.resolve_config(elsewhere, environ={}) self.assertIsNone(broken) self.assertEqual(config.data_root, elsewhere) self.assertEqual(config.path, elsewhere / "config.toml") def test_a_broken_settings_file_falls_back_and_hands_over_the_error(self): data_root = self.root / "peach-data" data_root.mkdir() (data_root / "config.toml").write_text("[server\nport = ", encoding="utf-8") config, broken = onboarding.resolve_config(data_root, environ={}) self.assertIsNotNone(broken) self.assertEqual(config.server.port, 8900, "坏文件退回内建默认") class _SetupHttpCase(_Case): """首启服务的 HTTP 夹具:临时 Peach 数据目录、证书生成打桩、没有口令的应用。""" def setUp(self): super().setUp() (self.media / "a.mp4").write_bytes(b"0" * 10) patcher = mock.patch.object(onboarding.certs, "bootstrap_certificates") self.certs = patcher.start() self.addCleanup(patcher.stop) active = mock.patch.object(settings_file, "active", lambda: self.config) active.start() self.addCleanup(active.stop) self.data_root = self.root / "peach-data" def _client(self, *, configured: bool = False, client=("127.0.0.1", 12345), base_url="http://test"): import httpx from peach.api import create_app from peach.config import PeachSettings app = create_app(PeachSettings( db_path=self.data_root / "database" / "ledger.db", configured=configured, token="", )) return httpx.AsyncClient( transport=httpx.ASGITransport(app=app, client=client), base_url=base_url) def _send(self, method, path, *, headers=None, data=None, json=None, **kwargs): import asyncio async def run(): async with self._client(**kwargs) as client: return await client.request(method, path, headers=headers, data=data, json=json) return asyncio.run(run()) def _get(self, path, **kwargs): import asyncio async def run(): async with self._client(**kwargs) as client: return await client.get(path) return asyncio.run(run()) def _loaded(self): return settings_file.load_config(environ={"PEACH_DATA_ROOT": str(self.data_root)}) @unittest.skipUnless(HAS_HTTP_DEPS, "需要 fastapi 与 httpx") class SetupJsonContractTests(_SetupHttpCase): """首启的 JSON 契约:`GET /api/setup/questions` 与 `POST /api/setup`。 首次运行页的题目、默认值与文案全部来自读题端点,逐字段校验与落盘全部在提交端点; 这一层和 `peach init` 的问答共用 `peach.onboarding`,所以断言的是「端点给出的字段和 `questions()` 同名同序」,而不是另抄一份字段清单去比对。页面怎么画见 vitest 与 e2e。 """ QUESTIONS = "/api/setup/questions" SUBMIT = "/api/setup" @unittest.skipUnless(NATIVE_WINDOWS, 'Windows 源码首设需要 TLS') def test_windows_source_setup_reports_missing_openssl_and_accepts_a_retry(self): with mock.patch.object(distribution, 'standalone', return_value=False), \ mock.patch.object(onboarding.certs, 'require_openssl', side_effect=RuntimeError('未找到 OpenSSL')) as require: response = self._send('POST', self.SUBMIT, json=self._body(scan_now=False)) self.assertEqual(response.status_code, 400, response.text) self.assertIn('OpenSSL', response.json()['errors']['data_root']) self.assertFalse((self.data_root / 'config.toml').exists()) require.side_effect = None response = self._send('POST', self.SUBMIT, json=self._body(scan_now=False)) self.assertEqual(response.status_code, 200, response.text) def _rows(self, *paths, location="local"): return [{"path": str(path), "location": location, "root": "" if NATIVE_WINDOWS else "R:\\media" + (str(index + 1) if index else "")} for index, path in enumerate(paths)] def _body(self, **overrides): body = {"data_root": str(self.data_root), "media_dir": self._rows(self.media), "host": "1", "port": "8900", "mdns_name": "peach", "scan_now": True} body.update(overrides) return {key: value for key, value in body.items() if value is not None} def _errors(self, body, **kwargs): """提交一份填错的答卷,返回按题目 key 收集的 errors。""" response = self._send("POST", self.SUBMIT, json=body, **kwargs) self.assertEqual(response.status_code, 400, response.text) self.assertEqual(response.json()["error"], "有几项需要修改") self.assertFalse((self.data_root / "config.toml").exists(), "校验失败不写设置") return response.json()["errors"] def _submit(self, **overrides): response = self._send("POST", self.SUBMIT, json=self._body(**overrides)) self.assertEqual(response.status_code, 200, response.text) return response.json() def test_remote_reporting_defaults_on_and_opt_out_is_saved_in_selected_data_root(self): from peach import remote_reporting response = self._send("GET", self.QUESTIONS) self.assertTrue(response.json()["remote_reporting"]["usage"]) self.assertTrue(response.json()["remote_reporting"]["errors"]) self._submit(remote_reporting={"usage": False, "errors": False}) config = settings_file.load_config(environ={"PEACH_DATA_ROOT": str(self.data_root)}) self.assertFalse(remote_reporting.consent(config)["usage"]) self.assertFalse(remote_reporting.consent(config)["errors"]) def test_remote_reporting_rejects_non_boolean_consent_before_creating_settings(self): for value in ([], {"usage": "true"}, {"errors": 1}, {"usage": False, "errors": True}): response = self._send("POST", self.SUBMIT, json=self._body(remote_reporting=value)) self.assertEqual(response.status_code, 400) self.assertFalse((self.data_root / "config.toml").exists()) def test_proxy_questions_expose_status_without_credentials_and_detect_docker(self): from peach import peach_proxy peach_proxy.save(self.config.directory("secrets"), { "mode": "proxy", "proxy": "http://user:private-password@127.0.0.1:7890"}) with mock.patch.dict(os.environ, {"PEACH_DISTRIBUTION": "docker"}): response = self._send("GET", self.QUESTIONS) self.assertEqual(response.json()["peach_proxy"], { "mode": "proxy", "proxy_saved": True, "needs_selection": False}) self.assertTrue(response.json()["docker"]) self.assertNotIn("private-password", response.text) with mock.patch.dict(os.environ, {"PEACH_DISTRIBUTION": "source"}): self.assertFalse(self._send("GET", self.QUESTIONS).json()["docker"]) def test_proxy_validation_collects_field_errors_without_creating_data(self): for proxy in ([], {"mode": []}, {"mode": "unknown"}, {"mode": "proxy", "proxy": "http://user:secret@host"}, {"mode": "proxy", "proxy": "ftp://host:1234"}): with self.subTest(proxy=proxy): errors = self._errors(self._body(peach_proxy=proxy, port="0")) self.assertEqual(set(errors), {"peach_proxy", "port"}) self.assertNotIn("secret", str(errors)) self.assertFalse(self.data_root.exists()) def test_proxy_is_saved_in_the_selected_data_root_before_settings_and_first_scan(self): from peach import peach_proxy selected = self.root / "selected-data" address = "socks5h://user:private-password@127.0.0.1:7890" original_write = settings_file.write original_scan = onboarding.request_first_scan seen = [] def write(config, **kwargs): self.assertEqual(peach_proxy.client_options(config.directory("secrets")), { "trust_env": False, "proxy": address}) seen.append("settings") return original_write(config, **kwargs) def scan(config, location): self.assertEqual(peach_proxy.client_options(config.directory("secrets")), { "trust_env": False, "proxy": address}) seen.append("scan") return original_scan(config, location) with mock.patch.object(settings_file, "write", side_effect=write), \ mock.patch.object(onboarding, "request_first_scan", side_effect=scan): result = self._submit(data_root=str(selected), peach_proxy={"mode": "proxy", "proxy": address}) self.assertEqual(seen, ["settings", "scan"]) self.assertNotIn("private-password", str(result)) self.assertFalse((self.config.directory("secrets") / "follow/peach-proxy.json").exists()) def test_proxy_modes_and_omitted_choice_are_saved_without_an_address(self): from peach import peach_proxy for mode in ("environment", "direct", None): with self.subTest(mode=mode): selected = self.root / str(mode) self._submit(data_root=str(selected), scan_now=False, peach_proxy={"mode": mode, "proxy": "http://user:unused@127.0.0.1:7890"} if mode else None) self.assertEqual(peach_proxy.values(selected / "secrets"), {"mode": mode or "environment", "proxy": ""}) def test_proxy_save_failure_does_not_publish_settings_or_scan_request(self): with mock.patch("peach.peach_proxy.save", side_effect=OSError("代理配置写入失败")): self.assertEqual(self._errors(self._body(peach_proxy={"mode": "direct"})), { "data_root": "代理配置写入失败"}) self.assertFalse((self.data_root / "state" / onboarding.SCAN_REQUEST_NAME).exists()) def test_the_first_run_page_is_a_shell_around_the_standalone_page_bundle(self): """首次运行页由独立页面包画:薄壳只带主题预读、样式、脚本、借来的字形与挂载点。""" response = self._get("/") self.assertEqual(response.status_code, 200) body = response.text self.assertIn("Peach · 首次运行", body) self.assertIn('', body) self.assertLess(body.index('localStorage.getItem("peach.settings.v1")'), body.index("/dist/peach-pages.css")) for symbol in ("folder-search", "hard-drive", "database"): self.assertIn(f'', page.text) self.assertIn('', page.text) self.assertNotIn("/dist/peach-entry.js", page.text) self.assertNotIn("