feat: add software entitlement foundations
This commit is contained in:
+289
-3
@@ -1,12 +1,31 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.db import close_old_connections
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
|
||||
from apps.licensing.services import record_device_heartbeat
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
assign_license_seat,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
)
|
||||
from apps.users.models import ApiKey, User, UserWallet
|
||||
|
||||
|
||||
@@ -160,3 +179,270 @@ class DeviceRegistrationApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
device.refresh_from_db()
|
||||
self.assertGreater(device.last_seen_at, stale_time)
|
||||
|
||||
|
||||
class SoftwareEntitlementServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="entitlement-user",
|
||||
email="entitlement@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.operator = User.objects.create_user(
|
||||
username="entitlement-operator",
|
||||
email="operator@example.com",
|
||||
password="test-password",
|
||||
is_staff=True,
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="月度套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("19.90"),
|
||||
device_limit=1,
|
||||
grace_days=3,
|
||||
)
|
||||
|
||||
def create_device(self, suffix):
|
||||
device_id = f"v1:entitlement-device-{suffix}"
|
||||
public_key = f"entitlement-public-key-{suffix}"
|
||||
return ClientDevice.objects.create(
|
||||
user=self.user,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_fingerprint=ClientDevice.fingerprint_device_id("v1", device_id),
|
||||
public_key_fingerprint=ClientDevice.fingerprint_public_key(public_key),
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.1.0",
|
||||
)
|
||||
|
||||
def grant_entitlement(self, **kwargs):
|
||||
return grant_software_entitlement(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="客服人工授予",
|
||||
actor=self.operator,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_grant_snapshots_plan_creates_fixed_seats_and_audit_event(self):
|
||||
entitlement = self.grant_entitlement()
|
||||
self.plan.name = "已调整套餐"
|
||||
self.plan.price = Decimal("29.90")
|
||||
self.plan.device_limit = 3
|
||||
self.plan.save()
|
||||
entitlement.refresh_from_db()
|
||||
|
||||
self.assertEqual(entitlement.plan_name, "月度套餐")
|
||||
self.assertEqual(entitlement.plan_price, Decimal("19.90"))
|
||||
self.assertEqual(entitlement.plan_device_limit, 1)
|
||||
self.assertEqual(LicenseSeat.objects.filter(entitlement=entitlement).count(), 1)
|
||||
event = LicenseEvent.objects.get(entitlement=entitlement)
|
||||
self.assertEqual(event.action, LicenseEvent.Action.GRANTED)
|
||||
self.assertEqual(event.reason, "客服人工授予")
|
||||
self.assertEqual(event.actor, self.operator)
|
||||
|
||||
def test_renew_extends_from_later_of_now_or_existing_expiry(self):
|
||||
now = timezone.now()
|
||||
entitlement = self.grant_entitlement(starts_at=now - timedelta(days=10))
|
||||
previous_expiry = entitlement.expires_at
|
||||
|
||||
renewed = renew_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="用户续订",
|
||||
actor=self.operator,
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(renewed.expires_at, previous_expiry + timedelta(days=30))
|
||||
self.assertEqual(renewed.grace_expires_at, renewed.expires_at + timedelta(days=3))
|
||||
self.assertEqual(renewed.status, SoftwareEntitlement.Status.ACTIVE)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.RENEWED,
|
||||
reason="用户续订",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_assign_release_and_revoke_are_audited_and_never_exceed_seat_limit(self):
|
||||
entitlement = self.grant_entitlement()
|
||||
first_device = self.create_device("one")
|
||||
second_device = self.create_device("two")
|
||||
|
||||
seat = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="首次绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
repeated = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="重复绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertEqual(seat.pk, repeated.pk)
|
||||
with self.assertRaisesRegex(LicensingError, "设备席位已用完"):
|
||||
assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=second_device,
|
||||
reason="超额绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
|
||||
released = release_license_seat(
|
||||
seat=seat,
|
||||
reason="客服解绑",
|
||||
actor=self.operator,
|
||||
)
|
||||
assigned_again = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=second_device,
|
||||
reason="重新绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertIsNone(released.device_id)
|
||||
self.assertEqual(assigned_again.device_id, second_device.id)
|
||||
self.assertEqual(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.SEAT_ASSIGNED,
|
||||
).count(),
|
||||
2,
|
||||
)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.SEAT_RELEASED,
|
||||
).exists()
|
||||
)
|
||||
|
||||
revoked = revoke_software_entitlement(
|
||||
entitlement=entitlement,
|
||||
reason="退款撤销",
|
||||
actor=self.operator,
|
||||
)
|
||||
self.assertEqual(revoked.status, SoftwareEntitlement.Status.REVOKED)
|
||||
with self.assertRaisesRegex(LicensingError, "软件权益当前不可用"):
|
||||
assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=first_device,
|
||||
reason="撤销后绑定",
|
||||
actor=self.operator,
|
||||
)
|
||||
|
||||
def test_manual_operations_require_reason(self):
|
||||
with self.assertRaisesRegex(LicensingError, "必须填写操作原因"):
|
||||
grant_software_entitlement(user=self.user, plan=self.plan, reason="")
|
||||
|
||||
|
||||
class SoftwareEntitlementAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.operator = User.objects.create_user(
|
||||
username="licensing-admin",
|
||||
email="licensing-admin@example.com",
|
||||
password="test-password",
|
||||
is_staff=True,
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="licensing-target",
|
||||
email="licensing-target@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="后台套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("9.90"),
|
||||
device_limit=2,
|
||||
)
|
||||
self.grant_url = reverse("admin:licensing_softwareentitlement_grant")
|
||||
|
||||
def test_admin_grant_requires_staff_and_reason_then_writes_event(self):
|
||||
anonymous = self.client.get(self.grant_url)
|
||||
self.assertEqual(anonymous.status_code, 302)
|
||||
|
||||
self.client.force_login(self.operator)
|
||||
missing_reason = self.client.post(
|
||||
self.grant_url,
|
||||
{"user": self.user.pk, "plan": self.plan.pk, "reason": ""},
|
||||
)
|
||||
self.assertEqual(missing_reason.status_code, 200)
|
||||
self.assertFalse(SoftwareEntitlement.objects.exists())
|
||||
|
||||
response = self.client.post(
|
||||
self.grant_url,
|
||||
{"user": self.user.pk, "plan": self.plan.pk, "reason": "后台补偿"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
entitlement = SoftwareEntitlement.objects.get(user=self.user)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.GRANTED,
|
||||
reason="后台补偿",
|
||||
actor=self.operator,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class LicenseSeatConcurrencyTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="seat-concurrency-user",
|
||||
email="seat-concurrency@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="单席位套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("9.90"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.entitlement = grant_software_entitlement(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="并发测试授予",
|
||||
)
|
||||
self.first_device = self.create_device("first")
|
||||
self.second_device = self.create_device("second")
|
||||
|
||||
def create_device(self, suffix):
|
||||
return ClientDevice.objects.create(
|
||||
user=self.user,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_fingerprint=ClientDevice.fingerprint_device_id("v1", f"concurrent-{suffix}"),
|
||||
public_key_fingerprint=ClientDevice.fingerprint_public_key(f"key-{suffix}"),
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.1.0",
|
||||
)
|
||||
|
||||
def test_concurrent_assignments_do_not_exceed_fixed_seat_limit(self):
|
||||
def assign(device_id):
|
||||
close_old_connections()
|
||||
try:
|
||||
entitlement = SoftwareEntitlement.objects.get(pk=self.entitlement.pk)
|
||||
device = ClientDevice.objects.get(pk=device_id)
|
||||
seat = assign_license_seat(
|
||||
entitlement=entitlement,
|
||||
device=device,
|
||||
reason="并发绑定",
|
||||
)
|
||||
return ("assigned", seat.device_id)
|
||||
except LicensingError as exc:
|
||||
return (exc.code, None)
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(
|
||||
executor.map(assign, (self.first_device.pk, self.second_device.pk))
|
||||
)
|
||||
|
||||
self.assertEqual(sum(outcome[0] == "assigned" for outcome in outcomes), 1)
|
||||
self.assertEqual(sum(outcome[0] == "seat_limit_reached" for outcome in outcomes), 1)
|
||||
seat = LicenseSeat.objects.get(entitlement=self.entitlement)
|
||||
self.assertIn(seat.device_id, {self.first_device.pk, self.second_device.pk})
|
||||
|
||||
Reference in New Issue
Block a user