feat(onboarding): add first-run membership activation

This commit is contained in:
chengma
2026-07-23 17:29:16 +08:00
parent a0862dacbd
commit 7dabeb9971
15 changed files with 855 additions and 36 deletions
+45 -1
View File
@@ -438,7 +438,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
ai = appconfig.ai_config(config)
self.assertEqual("cmhub", ai["backend"])
self.assertEqual("cmhub", appconfig.ai_backend(config))
self.assertEqual("", appconfig.cmhub_config(config)["base_url"])
self.assertEqual(
appconfig.DEFAULT_CMHUB_BASE_URL,
appconfig.cmhub_config(config)["base_url"],
)
self.assertEqual(
appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT,
appconfig.cmhub_config(config)["connect_timeout"],
@@ -480,6 +483,47 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_default_gateway_url_and_first_use_state_preserve_existing_config(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
with open(config_path, "w", encoding="utf-8") as fh:
json.dump({"ai": {"cmhub": {"base_url": ""}}}, fh)
migrated = appconfig.load_config(config_path)
self.assertEqual(
appconfig.DEFAULT_CMHUB_BASE_URL,
appconfig.cmhub_config(migrated)["base_url"],
)
self.assertEqual("", appconfig.first_use_guide_state(migrated))
migrated["ai"]["cmhub"]["base_url"] = "https://gateway.example.com/api/v1"
saved = appconfig.save_config(migrated, path=config_path)
self.assertEqual(
"https://gateway.example.com",
appconfig.cmhub_config(saved)["base_url"],
)
pending = appconfig.save_first_use_guide_state(
"pending",
path=config_path,
)
self.assertEqual("pending", appconfig.first_use_guide_state(pending))
self.assertEqual(
"https://gateway.example.com",
appconfig.cmhub_config(pending)["base_url"],
)
dismissed = appconfig.save_first_use_guide_state(
"dismissed",
path=config_path,
)
self.assertEqual("dismissed", appconfig.first_use_guide_state(dismissed))
with self.assertRaises(appconfig.ConfigError):
appconfig.save_first_use_guide_state("unknown", path=config_path)
self.assert_removed(temp_dir)
def test_subscription_notice_id_persists_without_credentials(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
+262
View File
@@ -64,6 +64,7 @@ from app.gui import (
ImageStudioPreviewDialog,
ImageStudioTab,
MainWindow,
MembershipActivationDialog,
ProductSuitePreviewDialog,
ProductSuiteTab,
SettingsTab,
@@ -344,6 +345,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
DestructiveRole = object()
RejectRole = object()
Warning = object()
Information = object()
def __init__(self, parent=None):
self.parent = parent
@@ -407,6 +409,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
DestructiveRole = object()
RejectRole = object()
Warning = object()
Information = object()
def __init__(self, parent=None):
self.parent = parent
@@ -11194,6 +11197,265 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_membership_activation_dialog_collects_only_key_and_validates_locally(self):
dialog = MembershipActivationDialog()
self.addCleanup(dialog.close)
submitted = []
dialog.validationRequested.connect(submitted.append)
dialog.validate_button.click()
self.assertEqual([], submitted)
self.assertIn("请输入会员 API Key", dialog.status_label.text())
self.assertIsNone(dialog.findChild(QLineEdit, "cmhubBaseUrlEdit"))
self.assertEqual(QLineEdit.Password, dialog.api_key_edit.echoMode())
dialog.api_key_edit.setText("temporary-test-key")
dialog.show_key_checkbox.setChecked(True)
dialog.validate_button.click()
self.assertEqual(["temporary-test-key"], submitted)
self.assertEqual(QLineEdit.Normal, dialog.api_key_edit.echoMode())
self.assertEqual("验证并开始使用", dialog.validate_button.text())
def test_missing_membership_key_opens_activation_instead_of_raw_settings(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
status = subscription.SubscriptionStatus(
subscription.STATUS_NOT_CONFIGURED
)
with mock.patch.object(window, "_show_membership_activation") as activation:
window._apply_subscription_status(status)
activation.assert_called_once_with()
self.assertEqual("请先绑定会员账号", window.statusBar().currentMessage())
for index, title in enumerate(TAB_TITLES):
self.assertEqual(title == "设置", window.tabs.isTabEnabled(index))
def test_activation_validation_uses_in_memory_key_worker(self):
class _Worker:
def __init__(self):
self.finished = DummySignal()
self.cancelled = DummySignal()
self.cancel_requested = False
def cancel(self):
self.cancel_requested = True
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
cfg = appconfig.load_config(config_path)
window = MainWindow(config=cfg, config_path=config_path)
self.addCleanup(window.close)
window._show_membership_activation()
worker = _Worker()
thread = FakeThread()
with mock.patch.object(
main_window,
"SubscriptionCheckWorker",
return_value=worker,
) as worker_factory, mock.patch.object(
main_window,
"run_worker",
return_value=thread,
):
window._activation_dialog.api_key_edit.setText("temporary-test-key")
window._activation_dialog.validate_button.click()
self.assertTrue(thread.started)
self.assertFalse(window._activation_dialog.validate_button.isEnabled())
kwargs = worker_factory.call_args.kwargs
self.assertEqual("temporary-test-key", kwargs["api_key"])
self.assertEqual(appconfig.DEFAULT_CMHUB_BASE_URL, kwargs["base_url"])
self.assertEqual(
"",
appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]),
)
def test_activation_invalid_key_is_not_persisted(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
cfg = appconfig.load_config(config_path)
window = MainWindow(config=cfg, config_path=config_path)
self.addCleanup(window.close)
window._show_membership_activation()
window._subscription_request_token = 7
window._on_activation_checked(
7,
"invalid-test-key",
{
"subscription": subscription.SubscriptionStatus(
subscription.STATUS_KEY_INVALID
)
},
)
self.assertEqual(
"",
appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]),
)
self.assertIn("会员 API Key 无效", window._activation_dialog.status_label.text())
self.assertFalse(
window.tabs.isTabEnabled(TAB_TITLES.index("① 导入采集"))
)
def test_activation_valid_key_without_plan_is_saved_but_stays_restricted(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
cfg = appconfig.load_config(config_path)
window = MainWindow(config=cfg, config_path=config_path)
self.addCleanup(window.close)
window._show_membership_activation()
window._subscription_request_token = 8
window._on_activation_checked(
8,
"valid-without-plan-key",
{
"subscription": subscription.SubscriptionStatus(
subscription.STATUS_REQUIRED,
manage_url="https://cm.833729.com",
)
},
)
self.assertEqual(
"valid-without-plan-key",
appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]),
)
self.assertIsNotNone(window._activation_dialog)
self.assertIn(
"尚未开通",
window._activation_dialog.status_label.text(),
)
for index, title in enumerate(TAB_TITLES):
self.assertEqual(title == "设置", window.tabs.isTabEnabled(index))
def test_closing_activation_dialog_exits_restricted_app(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
window._subscription_status = subscription.SubscriptionStatus(
subscription.STATUS_NOT_CONFIGURED
)
window._show_membership_activation()
with mock.patch.object(window, "close") as close:
window._activation_dialog.reject()
close.assert_called_once_with()
self.assertIsNone(window._activation_dialog)
def test_activation_success_saves_key_and_marks_guide_pending(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
cfg = appconfig.load_config(config_path)
window = MainWindow(config=cfg, config_path=config_path)
self.addCleanup(window.close)
window._show_membership_activation()
window._subscription_request_token = 9
active = subscription.SubscriptionStatus(
subscription.STATUS_ACTIVE,
account_name="新账号",
plan_name="测试套餐",
expires_at="2026-08-20T23:59:59+08:00",
)
with mock.patch.object(main_window.QTimer, "singleShot"):
window._on_activation_checked(
9,
"valid-test-key",
{"subscription": active},
)
self.assertIsNone(window._activation_dialog)
self.assertEqual(
"valid-test-key",
appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]),
)
self.assertEqual(
"pending",
appconfig.first_use_guide_state(
appconfig.load_config(config_path)
),
)
self.assertEqual(
"valid-test-key",
window._settings_tab().cmhub_api_key_edit.text(),
)
self.assertTrue(
all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count()))
)
def test_first_use_guide_can_start_account_setup_or_remind_later(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
cfg = appconfig.load_config(config_path)
appconfig.save_first_use_guide_state("pending", path=config_path)
cfg = appconfig.load_config(config_path)
window = MainWindow(config=cfg, config_path=config_path)
self.addCleanup(window.close)
window._subscription_status = subscription.SubscriptionStatus(
subscription.STATUS_ACTIVE,
account_name="新账号",
plan_name="测试套餐",
expires_at="2026-08-20T23:59:59+08:00",
)
fake_box, boxes = self.make_fake_message_box("开始配置店铺")
with mock.patch.object(main_window, "QMessageBox", fake_box):
window._show_first_use_guide_if_pending()
self.assertEqual(1, len(boxes))
self.assertIn("1. 添加蝦皮店铺账号", boxes[0].text)
self.assertEqual(TAB_TITLES.index("账号管理"), window.tabs.currentIndex())
self.assertEqual(
"completed",
appconfig.first_use_guide_state(appconfig.load_config(config_path)),
)
appconfig.save_first_use_guide_state("pending", path=config_path)
window.config.clear()
window.config.update(appconfig.load_config(config_path))
window._first_use_guide_shown = False
fake_box, boxes = self.make_fake_message_box("稍后提醒")
with mock.patch.object(main_window, "QMessageBox", fake_box):
window._show_first_use_guide_if_pending()
self.assertEqual(1, len(boxes))
self.assertEqual(
"pending",
appconfig.first_use_guide_state(appconfig.load_config(config_path)),
)
self.assertIn("稍后", window.statusBar().currentMessage())
def test_activation_member_center_uses_fixed_official_https_url(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
window._show_membership_activation()
with mock.patch.object(
main_window.QDesktopServices,
"openUrl",
return_value=False,
) as open_url:
window._open_activation_member_center()
open_url.assert_called_once()
self.assertEqual(
appconfig.DEFAULT_MEMBER_CENTER_URL,
open_url.call_args.args[0].toString(),
)
self.assertIn(
"无法打开会员中心",
window._activation_dialog.status_label.text(),
)
def test_main_window_displays_active_subscription_in_header(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
-1
View File
@@ -322,7 +322,6 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
tab._load_state(state)
cases = (
("base_url", "", "尚未配置默认网关地址", "test-key"),
("vision_alias", "", "尚未选择图片理解模型", "test-key"),
("vision_alias", "vision-standard", "尚未配置默认网关 API Key", ""),
)
+31
View File
@@ -64,6 +64,35 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
)
self.assertEqual("GET", calls[0][0][0])
self.assertNotIn("test-key", repr(result))
self.assertTrue(result.credentials_accepted)
def test_in_memory_key_override_is_used_without_persisting_it(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
appconfig.save_cmhub_config({"api_key": ""}, path=config["cmhub_config_path"])
calls = []
def request_json(*args, **kwargs):
calls.append((args, kwargs))
return {
"product_code": "cmshopee",
"account": {"display_name": "新账号"},
"plan": {"display_name": "测试套餐"},
"status": "active",
"expires_at": "2026-08-20T23:59:59+08:00",
}
result = subscription.check_status(
config,
base_url="https://activation.example.com/api/v1",
api_key="temporary-test-key",
request_json=request_json,
)
self.assertEqual(subscription.STATUS_ACTIVE, result.state)
self.assertEqual("https://activation.example.com", calls[0][0][1])
self.assertEqual("temporary-test-key", calls[0][0][3])
self.assertEqual("", appconfig.get_cmhub_api_key(config["cmhub_config_path"]))
def test_grace_and_expired_states_are_distinct(self):
with self.make_temp_dir() as temp_dir:
@@ -125,6 +154,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
result = subscription.check_status(config, request_json=invalid_key)
self.assertEqual(subscription.STATUS_KEY_INVALID, result.state)
self.assertNotIn("不应展示", result.user_message)
self.assertFalse(result.credentials_accepted)
def network_failure(*args, **kwargs):
raise ai.CMHubError("network_error", "不应展示")
@@ -132,6 +162,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase):
result = subscription.check_status(config, request_json=network_failure)
self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state)
self.assertNotIn("不应展示", result.user_message)
self.assertFalse(result.credentials_accepted)
def test_invalid_product_or_external_manage_url_is_not_trusted(self):
with self.make_temp_dir() as temp_dir: