feat(onboarding): add first-run membership activation
This commit is contained in:
+45
-1
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user