import uuid from decimal import Decimal from allauth.account.models import EmailAddress from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user, get_user_model from django.contrib.staticfiles import finders from django.core import mail from django.core.cache import cache from django.test import Client, TestCase, override_settings from django.utils import timezone from rest_framework.test import APIClient from apps.ai.models import AiModel, ModelAlias from apps.billing.models import ( CallRecord, ExchangeRate, PointsLedger, PricingRule, RechargeOrder, SignupBonusGrant, ) from apps.portal.models import DownloadRelease, ImportTemplate from apps.users.models import ApiKey, UserWallet @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") class PortalAccountFlowTests(TestCase): password = "Strong-pass-123" def setUp(self): cache.clear() def create_verified_user(self): suffix = uuid.uuid4().hex[:8] user = get_user_model().objects.create_user( username=f"portal-user-{suffix}", email=f"portal-user-{suffix}@example.com", password=self.password, ) EmailAddress.objects.create( user=user, email=user.email, verified=True, primary=True, ) return user def create_recharge_order( self, user, *, order_no: str, amount: str, points: int, status: str = RechargeOrder.Status.PAID, ): return RechargeOrder.objects.create( user=user, order_no=order_no, amount_money=Decimal(amount), currency="CNY", pay_method=RechargeOrder.PayMethod.WEIXIN, exchange_rate=Decimal("10.0000"), points_granted=points, status=status, payment_txn_no=f"txn-{order_no}" if status == RechargeOrder.Status.PAID else "", paid_at=timezone.now() if status == RechargeOrder.Status.PAID else None, ) def create_call_record( self, user, api_key, *, alias: str, points_cost: int, status: str = CallRecord.Status.SUCCESS, ): return CallRecord.objects.create( user=user, api_key=api_key, operation_type=CallRecord.OperationType.TITLE, alias=alias, model_used="gpt-5.5", points_cost=points_cost, status=status, ) def create_model_alias( self, *, alias: str, operation_type: str = ModelAlias.OperationType.TITLE, capabilities: list[str] | None = None, api_type: str = AiModel.ApiType.CHAT, url: str = "https://provider-secret.example/v1/chat/completions", model_sku: str = "secret-sku-gpt-5.5", ) -> ModelAlias: ai_model = AiModel.objects.create( name=f"{alias}-{uuid.uuid4().hex[:8]}", url=url, model=model_sku, api_type=api_type, api_key_encrypted="encrypted-provider-key", capabilities=capabilities if capabilities is not None else ["text"], extra_body={"internal": "provider-extra-secret"}, is_active=True, ) return ModelAlias.objects.create( alias=alias, operation_type=operation_type, ai_model=ai_model, ) def create_download_release( self, *, version: str = "1.0.0", platform: str = DownloadRelease.Platform.WINDOWS, is_current: bool = True, external_url: str = "https://download.example.com/cmhub-desktop.exe", sha256: str = "a" * 64, release_notes: str = "首版 Windows 客户端", ) -> DownloadRelease: return DownloadRelease.objects.create( platform=platform, version=version, is_current=is_current, external_url=external_url, sha256=sha256, release_notes=release_notes, ) def create_import_template( self, *, name: str = "导入模板", is_current: bool = True, external_url: str = "https://download.example.com/import-template.xlsx", file_name: str = "", sha256: str = "e" * 64, notes: str = "商品导入 Excel 模板", ) -> ImportTemplate: return ImportTemplate.objects.create( name=name, is_current=is_current, external_url=external_url, file=file_name, sha256=sha256, notes=notes, ) def assert_nav_link_active(self, response, *, href: str, label: str): html = response.content.decode("utf-8") self.assertIn( f'{label}', html, ) def assert_nav_link_inactive(self, response, *, href: str, label: str): html = response.content.decode("utf-8") self.assertIn( f'{label}', html, ) def assert_portal_branding(self, response): self.assertContains(response, "虾皮圈") self.assertNotContains(response, ">cmhub") self.assertNotContains(response, " - cmhub") self.assertNotContains(response, "cmhub AI 电商生成台") self.assertNotContains(response, "cmhub API Key") def test_signup_rate_limit_is_configured(self): self.assertEqual(settings.ACCOUNT_RATE_LIMITS["signup"], "20/m/ip") def test_signup_grants_10_points_writes_signup_bonus_ledger_and_can_login(self): suffix = uuid.uuid4().hex[:8] email = f"signup-{suffix}@example.com" response = self.client.post( "/signup", { "username": f"signup-{suffix}", "email": email, "password1": self.password, "password2": self.password, }, ) self.assertEqual(response.status_code, 302) user = get_user_model().objects.get(email=email) email_address = EmailAddress.objects.get(user=user, email=email) wallet = UserWallet.objects.get(user=user) self.assertFalse(email_address.verified) self.assertTrue(email_address.primary) self.assertEqual(wallet.points_balance, 10) self.assertEqual(SignupBonusGrant.objects.filter(user=user).count(), 1) ledger = PointsLedger.objects.get( user=user, change_type=PointsLedger.ChangeType.SIGNUP_BONUS, ) self.assertEqual(ledger.points_delta, 10) self.assertEqual(ledger.balance_after, 10) self.assertEqual(len(mail.outbox), 0) self.assertTrue(get_user(self.client).is_authenticated) dashboard_response = self.client.get("/dashboard") self.assertEqual(dashboard_response.status_code, 200) self.assertEqual(dashboard_response.context["balance"].points_balance, 10) self.assertEqual(dashboard_response.context["balance"].ledger_balance, 10) self.assertContains(dashboard_response, "10") def test_user_can_login_without_verified_email(self): suffix = uuid.uuid4().hex[:8] user = get_user_model().objects.create_user( username=f"unverified-{suffix}", email=f"unverified-{suffix}@example.com", password=self.password, ) EmailAddress.objects.create( user=user, email=user.email, verified=False, primary=True, ) login_response = self.client.post( "/login", {"login": user.email, "password": self.password}, ) self.assertEqual(login_response.status_code, 302) self.assertEqual(login_response["Location"], "/dashboard") self.assertTrue(get_user(self.client).is_authenticated) def test_user_can_login_view_dashboard_and_logout(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=42) login_response = self.client.post( "/login", {"login": user.email, "password": self.password}, ) self.assertEqual(login_response.status_code, 302) self.assertEqual(login_response["Location"], "/dashboard") self.assertTrue(get_user(self.client).is_authenticated) dashboard_response = self.client.get("/dashboard") self.assertEqual(dashboard_response.status_code, 200) self.assertContains(dashboard_response, user.email) self.assertContains(dashboard_response, "42") logout_response = self.client.post("/logout") self.assertEqual(logout_response.status_code, 302) self.assertEqual(logout_response["Location"], "/login") self.assertFalse(get_user(self.client).is_authenticated) def test_dashboard_requires_session_login(self): response = self.client.get("/dashboard") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("/login?next=")) def test_login_post_is_csrf_protected(self): csrf_client = Client(enforce_csrf_checks=True) response = csrf_client.post( "/login", {"login": "nobody@example.com", "password": "password"}, ) self.assertEqual(response.status_code, 403) def test_homepage_is_public_and_shows_anonymous_onboarding_without_release(self): response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertEqual(response.resolver_match.url_name, "portal-home") self.assertContains(response, "虾皮圈 AI 电商生成台") self.assert_portal_branding(response) self.assertContains(response, "注册领 10 点") self.assertContains(response, "注册送 10 点") self.assertContains(response, "登录") self.assertContains(response, "客户端暂未发布") self.assertContains(response, "暂未发布") self.assertNotContains(response, "下载导入模板") self.assertNotContains(response, "/login?next=") def test_homepage_shows_current_download_release(self): release = self.create_download_release( version="1.2.3", external_url="https://download.example.com/cmhub-1.2.3.exe", sha256="b" * 64, release_notes="修复下载入口并补充 SHA256", ) response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["current_release"], release) self.assertContains(response, "Windows 客户端 1.2.3") self.assertContains(response, "https://download.example.com/cmhub-1.2.3.exe") self.assertContains(response, "b" * 64) self.assertContains(response, "修复下载入口并补充 SHA256") self.assertContains(response, "未知发布者") self.assertContains(response, "填入虾皮圈 API Key 使用") self.assertNotContains(response, "填入 cmhub API Key 使用") def test_portal_pages_use_shopee_circle_branding(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=10) public_paths = ("/", "/signup", "/login") for path in public_paths: with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assert_portal_branding(response) self.client.force_login(user) authenticated_paths = ( "/dashboard", "/recharge", "/apikeys", "/models", "/records/recharge", "/records/usage", "/logout", ) for path in authenticated_paths: with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assert_portal_branding(response) def test_homepage_shows_current_import_template_download_link(self): self.create_download_release() import_template = self.create_import_template( name="蝦皮圈導入模板", external_url="https://download.example.com/shopee-import-template.xlsx", sha256="f" * 64, ) response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["current_import_template"], import_template) self.assertEqual( response.context["current_import_template_download_url"], "https://download.example.com/shopee-import-template.xlsx", ) self.assertContains(response, "下载客户端") self.assertContains(response, "下载导入模板") self.assertContains( response, "https://download.example.com/shopee-import-template.xlsx", ) def test_homepage_builds_absolute_import_template_file_url_without_local_path(self): self.create_import_template( external_url="", file_name="import_templates/cmhub-import-template.xlsx", ) response = self.client.get("/", secure=True) expected_url = "https://testserver/media/import_templates/cmhub-import-template.xlsx" response_body = response.content.decode("utf-8") self.assertEqual(response.status_code, 200) self.assertEqual( response.context["current_import_template_download_url"], expected_url, ) self.assertContains(response, expected_url) self.assertNotIn(str(settings.MEDIA_ROOT), response_body) self.assertNotIn("MEDIA_ROOT", response_body) def test_homepage_prefers_import_template_external_url_over_file(self): self.create_import_template( external_url="https://cdn.example.com/current-template.xlsx", file_name="import_templates/local-template.xlsx", ) response = self.client.get("/", secure=True) self.assertEqual( response.context["current_import_template_download_url"], "https://cdn.example.com/current-template.xlsx", ) self.assertContains(response, "https://cdn.example.com/current-template.xlsx") self.assertNotContains(response, "/media/import_templates/local-template.xlsx") def test_homepage_shows_dashboard_entry_for_authenticated_user(self): user = self.create_verified_user() self.client.force_login(user) response = self.client.get("/") self.assertEqual(response.status_code, 200) self.assertContains(response, "进入控制台") self.assertNotContains(response, "注册领 10 点") def test_download_release_only_keeps_one_current_per_platform(self): old_release = self.create_download_release(version="1.0.0", sha256="c" * 64) new_release = self.create_download_release(version="1.1.0", sha256="d" * 64) old_release.refresh_from_db() new_release.refresh_from_db() self.assertFalse(old_release.is_current) self.assertTrue(new_release.is_current) def test_download_release_admin_requires_sha256_and_size_bytes(self): form_class = admin.site._registry[DownloadRelease].form base_data = { "platform": DownloadRelease.Platform.WINDOWS, "version": "1.2.3", "external_url": "https://download.example.com/cmhub-1.2.3.zip", "release_notes": "发布说明", } missing_sha256_form = form_class(data={**base_data, "size_bytes": "123456"}) missing_size_form = form_class(data={**base_data, "sha256": "a" * 64}) self.assertFalse(missing_sha256_form.is_valid()) self.assertIn("sha256", missing_sha256_form.errors) self.assertFalse(missing_size_form.is_valid()) self.assertIn("size_bytes", missing_size_form.errors) def test_download_release_admin_saves_valid_release_metadata(self): form_class = admin.site._registry[DownloadRelease].form form = form_class( data={ "platform": DownloadRelease.Platform.WINDOWS, "version": "1.2.3", "external_url": "https://download.example.com/cmhub-1.2.3.zip", "sha256": "b" * 64, "size_bytes": "123456", "release_notes": "发布说明", } ) self.assertTrue(form.is_valid(), form.errors) release = form.save() self.assertEqual(release.sha256, "b" * 64) self.assertEqual(release.size_bytes, 123456) def test_import_template_only_keeps_one_current(self): old_template = self.create_import_template( name="旧模板", sha256="1" * 64, ) new_template = self.create_import_template( name="新模板", sha256="2" * 64, ) old_template.refresh_from_db() new_template.refresh_from_db() self.assertFalse(old_template.is_current) self.assertTrue(new_template.is_current) def test_import_template_admin_exposes_download_fields(self): registered_admin = admin.site._registry[ImportTemplate] self.assertIn("is_current", registered_admin.list_display) self.assertIn("download_source", registered_admin.list_display) self.assertIn("is_current", registered_admin.list_filter) template_fields = registered_admin.fieldsets[0][1]["fields"] download_fields = registered_admin.fieldsets[1][1]["fields"] self.assertIn("name", template_fields) self.assertIn("file", download_fields) self.assertIn("external_url", download_fields) self.assertIn("sha256", download_fields) def test_apikeys_requires_session_login(self): response = self.client.get("/apikeys") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("/login?next=")) def test_models_page_requires_session_login(self): response = self.client.get("/models") self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("/login?next=")) def test_models_page_lists_public_aliases_prices_and_unpriced_state(self): user = self.create_verified_user() title_alias = self.create_model_alias(alias="title-standard", capabilities=["text"]) image_alias = self.create_model_alias( alias="image-edit", operation_type=ModelAlias.OperationType.IMAGE, capabilities=["image", "vision"], api_type=AiModel.ApiType.IMAGES_EDITS, url="https://provider-secret.example/v1/images/edits", model_sku="secret-sku-image-2", ) vision_alias = self.create_model_alias( alias="vision-standard", operation_type=ModelAlias.OperationType.VISION, capabilities=["text", "vision"], model_sku="secret-sku-vision", ) PricingRule.objects.create( operation_type=title_alias.operation_type, alias=title_alias.alias, resolution="", points_cost=2, ) PricingRule.objects.create( operation_type=image_alias.operation_type, alias=image_alias.alias, resolution="1k", points_cost=12, is_active=False, ) PricingRule.objects.create( operation_type=vision_alias.operation_type, alias=vision_alias.alias, resolution="", points_cost=3, ) self.client.force_login(user) response = self.client.get("/models") self.assertEqual(response.status_code, 200) self.assertEqual(len(response.context["models"]), 3) self.assertContains(response, "可用模型") self.assertContains(response, "title-standard") self.assertContains(response, "生成标题") self.assertContains(response, "默认") self.assertContains(response, "2 点") self.assertContains(response, "image-edit") self.assertContains(response, "生成图片") self.assertContains(response, "vision-standard") self.assertContains(response, "图片理解") self.assertContains(response, "3 点") self.assertContains(response, "需要") self.assertContains(response, "暂未定价") self.assertNotContains(response, "secret-sku") self.assertNotContains(response, "provider-secret.example") self.assertNotContains(response, "encrypted-provider-key") self.assertNotContains(response, "provider-extra-secret") self.assertNotContains(response, "api_key") self.assertNotContains(response, "api_key_encrypted") self.assertNotContains(response, "extra_body") def test_authenticated_nav_highlights_current_page_only(self): user = self.create_verified_user() self.client.force_login(user) cases = ( ("/dashboard", "/dashboard", "控制台"), ("/recharge", "/recharge", "充值"), ("/apikeys", "/apikeys", "API Key"), ("/models", "/models", "可用模型"), ("/records/recharge", "/records/recharge", "充值记录"), ("/records/usage", "/records/usage", "点数记录"), ) for path, href, label in cases: with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assert_nav_link_active(response, href=href, label=label) if path != "/recharge": self.assert_nav_link_inactive( response, href="/recharge", label="充值", ) def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self): user = self.create_verified_user() self.client.force_login(user) response = self.client.post("/apikeys", {"name": "desktop"}, follow=True) self.assertEqual(response.status_code, 200) api_key = ApiKey.objects.get(user=user) new_api_key = response.context["new_api_key"] raw_key = new_api_key["raw_key"] self.assertTrue(raw_key.startswith("sk_cmhub_")) self.assertEqual(api_key.name, "desktop") self.assertEqual(api_key.key_prefix, raw_key[: ApiKey.KEY_PREFIX_LENGTH]) self.assertNotEqual(api_key.key_hash, raw_key) self.assertNotIn(raw_key, str(api_key.__dict__)) self.assertTrue(api_key.matches_key(raw_key)) self.assertContains(response, raw_key) self.assertContains(response, api_key.key_prefix) self.assertNotContains(response, api_key.key_hash) second_response = self.client.get("/apikeys") self.assertEqual(second_response.status_code, 200) self.assertNotContains(second_response, raw_key) self.assertContains(second_response, api_key.key_prefix) self.assertNotContains(second_response, api_key.key_hash) def test_apikey_list_only_shows_current_user_prefix(self): user = self.create_verified_user() other_user = self.create_verified_user() own_key, own_raw_key = ApiKey.create_for_user(user, name="desktop") other_key, other_raw_key = ApiKey.create_for_user(other_user, name="other") self.client.force_login(user) response = self.client.get("/apikeys") self.assertEqual(response.status_code, 200) self.assertContains(response, own_key.key_prefix) self.assertContains(response, "desktop") self.assertNotContains(response, own_raw_key) self.assertNotContains(response, own_key.key_hash) self.assertNotContains(response, other_key.key_prefix) self.assertNotContains(response, other_raw_key) self.assertNotContains(response, other_key.key_hash) def test_delete_api_key_revokes_it_and_external_api_returns_403(self): user = self.create_verified_user() api_key, raw_key = ApiKey.create_for_user(user, name="desktop") self.client.force_login(user) response = self.client.post(f"/apikeys/{api_key.pk}/delete") self.assertEqual(response.status_code, 302) self.assertEqual(response["Location"], "/apikeys") api_key.refresh_from_db() self.assertEqual(api_key.status, ApiKey.Status.REVOKED) api_client = APIClient() api_response = api_client.get( "/api/v1/balance", HTTP_AUTHORIZATION=f"Bearer {raw_key}", ) self.assertEqual(api_response.status_code, 403) self.assertEqual(api_response.data["error"]["code"], "account_disabled") def test_delete_api_key_does_not_allow_other_users_key(self): user = self.create_verified_user() other_user = self.create_verified_user() other_key, _raw_key = ApiKey.create_for_user(other_user, name="other") self.client.force_login(user) response = self.client.post(f"/apikeys/{other_key.pk}/delete") self.assertEqual(response.status_code, 404) other_key.refresh_from_db() self.assertEqual(other_key.status, ApiKey.Status.ACTIVE) def test_apikey_create_and_delete_posts_are_csrf_protected(self): user = self.create_verified_user() api_key, _raw_key = ApiKey.create_for_user(user, name="desktop") csrf_client = Client(enforce_csrf_checks=True) csrf_client.force_login(user) create_response = csrf_client.post("/apikeys", {"name": "new"}) delete_response = csrf_client.post(f"/apikeys/{api_key.pk}/delete") self.assertEqual(create_response.status_code, 403) self.assertEqual(delete_response.status_code, 403) api_key.refresh_from_db() self.assertEqual(api_key.status, ApiKey.Status.ACTIVE) def test_dashboard_shows_account_summary_recent_records_for_current_user_only(self): user = self.create_verified_user() other_user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=160) UserWallet.objects.create(user=other_user, points_balance=999) api_key, _raw_key = ApiKey.create_for_user(user, name="desktop") other_key, _other_raw_key = ApiKey.create_for_user(other_user, name="other") order = self.create_recharge_order( user, order_no="R-OWN-PAID-503", amount="20.00", points=200, ) self.create_recharge_order( other_user, order_no="R-OTHER-PAID-503", amount="99.00", points=990, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.RECHARGE, points_delta=200, balance_after=200, ref_order_id=order.id, ) call = self.create_call_record( user, api_key, alias="title-standard", points_cost=50, status=CallRecord.Status.FAILED, ) other_call = self.create_call_record( other_user, other_key, alias="other-alias", points_cost=70, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-50, balance_after=150, ref_call=call, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.REFUND, points_delta=10, balance_after=160, ref_call=call, ) PointsLedger.objects.create( user=other_user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-70, balance_after=929, ref_call=other_call, ) self.client.force_login(user) response = self.client.get("/dashboard") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["balance"].points_balance, 160) self.assertEqual(response.context["balance"].ledger_balance, 160) self.assertEqual(response.context["recharge_total_amount"], Decimal("20.00")) self.assertEqual(response.context["recharge_points_total"], 200) self.assertEqual(response.context["credited_points_total"], 200) self.assertEqual(response.context["consumed_points_total"], 50) self.assertEqual(response.context["refunded_points_total"], 10) self.assertEqual(response.context["net_used_points"], 40) self.assertContains(response, "R-OWN-PAID-503") self.assertContains(response, "title-standard") self.assertNotContains(response, "R-OTHER-PAID-503") self.assertNotContains(response, "other-alias") def test_recharge_records_require_login_and_only_show_current_user_orders(self): user = self.create_verified_user() other_user = self.create_verified_user() paid_order = self.create_recharge_order( user, order_no="R-OWN-RECHARGE-503", amount="30.00", points=300, ) self.create_recharge_order( user, order_no="R-OWN-PENDING-503", amount="88.00", points=880, status=RechargeOrder.Status.PENDING, ) self.create_recharge_order( other_user, order_no="R-OTHER-RECHARGE-503", amount="99.00", points=990, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.RECHARGE, points_delta=300, balance_after=300, ref_order_id=paid_order.id, ) anonymous_response = self.client.get("/records/recharge") self.assertEqual(anonymous_response.status_code, 302) self.assertTrue(anonymous_response["Location"].startswith("/login?next=")) self.client.force_login(user) response = self.client.get("/records/recharge") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["recharge_total_amount"], Decimal("30.00")) self.assertEqual(response.context["recharge_points_total"], 300) self.assertEqual(response.context["paid_recharge_count"], 1) self.assertContains(response, "R-OWN-RECHARGE-503") self.assertContains(response, "R-OWN-PENDING-503") self.assertNotContains(response, "R-OTHER-RECHARGE-503") def test_recharge_records_paginate_more_than_page_size(self): user = self.create_verified_user() other_user = self.create_verified_user() self.create_recharge_order( other_user, order_no="R-PAGE-OTHER-505", amount="99.00", points=990, ) for index in range(55): self.create_recharge_order( user, order_no=f"R-PAGE-{index:03d}", amount="1.00", points=10, ) self.client.force_login(user) first_page = self.client.get("/records/recharge") second_page = self.client.get("/records/recharge?page=2") self.assertEqual(first_page.status_code, 200) self.assertEqual(first_page.context["records_total_count"], 55) self.assertEqual(first_page.context["page_obj"].number, 1) self.assertEqual(len(first_page.context["recharge_orders"]), 50) self.assertContains(first_page, "共 55 条,每页 50 条") self.assertContains(first_page, "R-PAGE-054") self.assertNotContains(first_page, "R-PAGE-000") self.assertNotContains(first_page, "R-PAGE-OTHER-505") self.assertEqual(second_page.status_code, 200) self.assertEqual(second_page.context["page_obj"].number, 2) self.assertEqual(len(second_page.context["recharge_orders"]), 5) self.assertContains(second_page, "R-PAGE-000") self.assertNotContains(second_page, "R-PAGE-054") self.assertNotContains(second_page, "R-PAGE-OTHER-505") def test_usage_records_require_login_and_only_show_current_user_usage(self): user = self.create_verified_user() other_user = self.create_verified_user() api_key, _raw_key = ApiKey.create_for_user(user, name="desktop") other_key, _other_raw_key = ApiKey.create_for_user(other_user, name="other") call = self.create_call_record( user, api_key, alias="title-standard", points_cost=40, status=CallRecord.Status.FAILED, ) other_call = self.create_call_record( other_user, other_key, alias="other-alias", points_cost=90, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.SIGNUP_BONUS, points_delta=100, balance_after=100, reason="new_user_registration", ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-40, balance_after=60, ref_call=call, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.REFUND, points_delta=15, balance_after=75, ref_call=call, ) PointsLedger.objects.create( user=other_user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-90, balance_after=10, ref_call=other_call, ) anonymous_response = self.client.get("/records/usage") self.assertEqual(anonymous_response.status_code, 302) self.assertTrue(anonymous_response["Location"].startswith("/login?next=")) self.client.force_login(user) response = self.client.get("/records/usage") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["consumed_points_total"], 40) self.assertEqual(response.context["refunded_points_total"], 15) self.assertEqual(response.context["net_used_points"], 25) self.assertContains(response, "注册赠点") self.assertContains(response, "100") self.assertContains(response, "title-standard") self.assertContains(response, api_key.key_prefix) self.assertContains(response, "-40") self.assertContains(response, "15") self.assertNotContains(response, "other-alias") self.assertNotContains(response, other_key.key_prefix) def test_usage_records_paginate_more_than_page_size(self): user = self.create_verified_user() other_user = self.create_verified_user() api_key, _raw_key = ApiKey.create_for_user(user, name="desktop") other_key, _other_raw_key = ApiKey.create_for_user(other_user, name="other") other_call = self.create_call_record( other_user, other_key, alias="other-page-alias", points_cost=99, ) PointsLedger.objects.create( user=other_user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-99, balance_after=0, ref_call=other_call, ) for index in range(55): call = self.create_call_record( user, api_key, alias=f"title-page-{index:03d}", points_cost=1, ) PointsLedger.objects.create( user=user, change_type=PointsLedger.ChangeType.CONSUME, points_delta=-1, balance_after=100 - index, ref_call=call, ) self.client.force_login(user) first_page = self.client.get("/records/usage") second_page = self.client.get("/records/usage?page=2") self.assertEqual(first_page.status_code, 200) self.assertEqual(first_page.context["records_total_count"], 55) self.assertEqual(first_page.context["page_obj"].number, 1) self.assertEqual(len(first_page.context["usage_entries"]), 50) self.assertContains(first_page, "共 55 条,每页 50 条") self.assertContains(first_page, "title-page-054") self.assertNotContains(first_page, "title-page-000") self.assertNotContains(first_page, "other-page-alias") self.assertEqual(second_page.status_code, 200) self.assertEqual(second_page.context["page_obj"].number, 2) self.assertEqual(len(second_page.context["usage_entries"]), 5) self.assertContains(second_page, "title-page-000") self.assertNotContains(second_page, "title-page-054") self.assertNotContains(second_page, "other-page-alias") def test_recharge_page_requires_login_and_shows_form(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=25) anonymous_response = self.client.get("/recharge") self.assertEqual(anonymous_response.status_code, 302) self.assertTrue(anonymous_response["Location"].startswith("/login?next=")) self.client.force_login(user) response = self.client.get("/recharge") self.assertEqual(response.status_code, 200) self.assertEqual(response.context["balance"].points_balance, 25) self.assertContains(response, "充值金额") self.assertContains(response, "创建订单") self.assertContains(response, "微信") self.assertNotContains(response, "支付宝") self.assertContains(response, "portal/vendor/bootstrap/bootstrap.min.css") self.assertContains(response, "portal/vendor/qrcode/qrcode.js") self.assertContains(response, "QRCode.toCanvas") self.assertNotContains(response, "cdn.jsdelivr.net") self.assertIsNotNone( finders.find("portal/vendor/bootstrap/bootstrap.min.css") ) self.assertIsNotNone(finders.find("portal/vendor/qrcode/qrcode.js")) @override_settings(PAYMENT_CALLBACK_MODE="mock") def test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=5) ExchangeRate.objects.create( currency="CNY", points_per_unit=Decimal("10.0000"), effective_from=timezone.now(), ) self.client.force_login(user) response = self.client.post( "/recharge", {"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN}, ) self.assertEqual(response.status_code, 302) self.assertTrue(response["Location"].startswith("/recharge?order_no=")) order = RechargeOrder.objects.get(user=user) self.assertEqual(order.amount_money, Decimal("20.00")) self.assertEqual(order.exchange_rate, Decimal("10.0000")) self.assertEqual(order.points_granted, 200) self.assertEqual(order.status, RechargeOrder.Status.PENDING) self.assertTrue(order.code_url.startswith("weixin://wxpay/cmhub-mock")) self.assertIsNotNone(order.expires_at) wallet = UserWallet.objects.get(user=user) self.assertEqual(wallet.points_balance, 5) self.assertFalse( PointsLedger.objects.filter( user=user, ref_order_id=order.id, ).exists() ) follow_response = self.client.get(response["Location"]) self.assertEqual(follow_response.status_code, 200) self.assertEqual(follow_response.context["current_order"], order) self.assertContains(follow_response, order.order_no) self.assertContains(follow_response, "weixin://wxpay/cmhub-mock") self.assertTrue(follow_response.context["is_mock_payment_mode"]) self.assertContains(follow_response, "当前为支付测试模式") self.assertContains(follow_response, "不能使用微信或支付宝真实付款") self.assertContains(follow_response, 'data-status-url="/api/v1/recharge/status"') self.assertContains(follow_response, "data-recharge-order") @override_settings(PAYMENT_CALLBACK_MODE="sdk") def test_recharge_page_does_not_show_mock_notice_in_sdk_mode(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=0) order = self.create_recharge_order( user, order_no="R-SDK-RECHARGE-PAGE-504", amount="20.00", points=200, status=RechargeOrder.Status.PENDING, ) order.code_url = "weixin://wxpay/bizpayurl?pr=real-native-ticket" order.save(update_fields=("code_url", "updated_at")) self.client.force_login(user) response = self.client.get(f"/recharge?order_no={order.order_no}") self.assertEqual(response.status_code, 200) self.assertFalse(response.context["is_mock_payment_mode"]) self.assertContains(response, "weixin://wxpay/bizpayurl") self.assertNotContains(response, "当前为支付测试模式") self.assertNotContains(response, "不能使用微信或支付宝真实付款") def test_recharge_page_current_order_only_shows_current_user_order(self): user = self.create_verified_user() other_user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=0) other_order = self.create_recharge_order( other_user, order_no="R-OTHER-RECHARGE-PAGE-504", amount="50.00", points=500, status=RechargeOrder.Status.PENDING, ) self.client.force_login(user) response = self.client.get(f"/recharge?order_no={other_order.order_no}") self.assertEqual(response.status_code, 200) self.assertIsNone(response.context["current_order"]) self.assertNotContains(response, other_order.order_no) @override_settings(RECHARGE_MAX_AMOUNT_CNY="100.00") def test_recharge_page_rejects_amount_above_configured_maximum(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=0) ExchangeRate.objects.create( currency="CNY", points_per_unit=Decimal("10.0000"), effective_from=timezone.now(), ) self.client.force_login(user) response = self.client.post( "/recharge", {"amount": "100.01", "pay_method": RechargeOrder.PayMethod.WEIXIN}, ) self.assertEqual(response.status_code, 200) self.assertContains(response, "单笔充值金额不能超过 100.00 CNY") self.assertFalse(RechargeOrder.objects.filter(user=user).exists()) def test_recharge_page_rejects_hidden_alipay_submit(self): user = self.create_verified_user() UserWallet.objects.create(user=user, points_balance=0) ExchangeRate.objects.create( currency="CNY", points_per_unit=Decimal("10.0000"), effective_from=timezone.now(), ) self.client.force_login(user) response = self.client.post( "/recharge", {"amount": "20.00", "pay_method": RechargeOrder.PayMethod.ALIPAY}, ) self.assertEqual(response.status_code, 200) self.assertContains(response, "选择一个有效的选项") self.assertFalse(RechargeOrder.objects.filter(user=user).exists()) def test_recharge_page_post_is_csrf_protected(self): user = self.create_verified_user() csrf_client = Client(enforce_csrf_checks=True) csrf_client.force_login(user) response = csrf_client.post( "/recharge", {"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN}, ) self.assertEqual(response.status_code, 403) self.assertFalse(RechargeOrder.objects.filter(user=user).exists())