package service import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" "github.com/wangjia/jiu/backend/testutil" ) // createCode 在码池写入一张 unused 兑换码(默认 type=annual / tier=standard)。 func createCode(t *testing.T, db *gorm.DB, code string, durationDays, maxDevices int) { t.Helper() require.NoError(t, db.Create(&model.LicenseCode{ Code: util.NormalizeCode(code), Type: "annual", Tier: "standard", DurationDays: durationDays, MaxDevices: maxDevices, Status: "unused", }).Error) } func daysFromNow(t *time.Time) float64 { if t == nil { return 0 } return time.Until(*t).Hours() / 24 } // 首次兑换(门店尚无授权行):新建一行,到期 = 今天 + 时长,码标记已用并绑定设备。 func TestLicenseService_Redeem_NewShop(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM001") createCode(t, db, "JIUKU-AAAA-BBBB", 365, 3) svc := NewLicenseService(db) // 传小写 + 连字符,验证归一化 lic, err := svc.Redeem(shop.ID, "jiuku-aaaa-bbbb", "dev-1", "Test PC", "windows") require.NoError(t, err) require.NotNil(t, lic) assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1) assert.Equal(t, 3, lic.MaxDevices) assert.Equal(t, "standard", lic.Tier) assert.Equal(t, "annual", lic.Type) var lc model.LicenseCode require.NoError(t, db.Where("code = ?", "JIUKUAAAABBBB").First(&lc).Error) assert.Equal(t, "redeemed", lc.Status) require.NotNil(t, lc.RedeemedShopID) assert.Equal(t, shop.ID, *lc.RedeemedShopID) var dev model.LicenseDevice require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "dev-1").First(&dev).Error) assert.Equal(t, "Test PC", dev.DeviceName) } // 在既有未过期授权上兑换:到期时间叠加在原到期之后。 func TestLicenseService_Redeem_ExtendsExisting(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM002") expiry := time.Now().Add(10 * 24 * time.Hour) require.NoError(t, db.Create(&model.License{ ShopID: shop.ID, LicenseKey: "TRIAL-RDM002", Type: "trial", Tier: "standard", ExpiresAt: &expiry, IsActive: true, MaxDevices: 3, }).Error) createCode(t, db, "JIUKU-CCCC-DDDD", 365, 0) svc := NewLicenseService(db) lic, err := svc.Redeem(shop.ID, "JIUKU-CCCC-DDDD", "dev-1", "PC", "windows") require.NoError(t, err) // 原剩 10 天 + 365 ≈ 375 assert.InDelta(t, 375, daysFromNow(lic.ExpiresAt), 1) } // 叠加:连兑两张码,时长累加。 func TestLicenseService_Redeem_Stacks(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM003") createCode(t, db, "JIUKU-1111-1111", 365, 1) createCode(t, db, "JIUKU-2222-2222", 365, 1) svc := NewLicenseService(db) _, err := svc.Redeem(shop.ID, "JIUKU-1111-1111", "dev-1", "PC", "windows") require.NoError(t, err) lic, err := svc.Redeem(shop.ID, "JIUKU-2222-2222", "dev-1", "PC", "windows") require.NoError(t, err) assert.InDelta(t, 730, daysFromNow(lic.ExpiresAt), 1) } // 已过期门店兑换:从今天起算,不在过去叠加。 func TestLicenseService_Redeem_ExpiredBase(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM004") past := time.Now().Add(-5 * 24 * time.Hour) require.NoError(t, db.Create(&model.License{ ShopID: shop.ID, LicenseKey: "TRIAL-RDM004", Type: "trial", Tier: "standard", ExpiresAt: &past, IsActive: true, MaxDevices: 1, }).Error) createCode(t, db, "JIUKU-EEEE-FFFF", 30, 0) svc := NewLicenseService(db) lic, err := svc.Redeem(shop.ID, "JIUKU-EEEE-FFFF", "dev-1", "PC", "windows") require.NoError(t, err) assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1) } // 永久码(duration=0):到期置 NULL。 func TestLicenseService_Redeem_Lifetime(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM005") createCode(t, db, "JIUKU-LIFE-TIME", 0, 1) svc := NewLicenseService(db) lic, err := svc.Redeem(shop.ID, "JIUKU-LIFE-TIME", "dev-1", "PC", "windows") require.NoError(t, err) assert.Nil(t, lic.ExpiresAt) } // 一码一次:同码兑换两次,第二次失败。 func TestLicenseService_Redeem_AlreadyUsed(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM006") createCode(t, db, "JIUKU-USED-ONCE", 365, 1) svc := NewLicenseService(db) _, err := svc.Redeem(shop.ID, "JIUKU-USED-ONCE", "dev-1", "PC", "windows") require.NoError(t, err) _, err = svc.Redeem(shop.ID, "JIUKU-USED-ONCE", "dev-1", "PC", "windows") assert.Equal(t, ErrCodeUsed, err) } func TestLicenseService_Redeem_NotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM007") svc := NewLicenseService(db) _, err := svc.Redeem(shop.ID, "JIUKU-NONE-XXXX", "dev-1", "", "") assert.Equal(t, ErrCodeNotFound, err) } func TestLicenseService_Redeem_Void(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM008") require.NoError(t, db.Create(&model.LicenseCode{ Code: "JIUKUVOIDCODE0", Type: "annual", Tier: "standard", DurationDays: 365, Status: "void", }).Error) svc := NewLicenseService(db) _, err := svc.Redeem(shop.ID, "JIUKU-VOID-CODE0", "dev-1", "", "") assert.Equal(t, ErrCodeVoid, err) } // 设备超上限:兑换整体回滚——码保持 unused、到期不变。 func TestLicenseService_Redeem_DeviceLimitRollsBack(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "RDM009") expiry := time.Now().Add(10 * 24 * time.Hour) lic := &model.License{ ShopID: shop.ID, LicenseKey: "TRIAL-RDM009", Type: "trial", Tier: "standard", ExpiresAt: &expiry, IsActive: true, MaxDevices: 1, } require.NoError(t, db.Create(lic).Error) // 已占满 1 个设备名额 require.NoError(t, db.Create(&model.LicenseDevice{ LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "old-dev", }).Error) createCode(t, db, "JIUKU-DEVL-IMIT", 365, 0) // 不提升设备上限 svc := NewLicenseService(db) _, err := svc.Redeem(shop.ID, "JIUKU-DEVL-IMIT", "new-dev", "New PC", "windows") assert.Equal(t, ErrDeviceLimitExceed, err) // 码仍未使用(回滚) var lc model.LicenseCode require.NoError(t, db.Where("code = ?", "JIUKUDEVLIMIT").First(&lc).Error) assert.Equal(t, "unused", lc.Status) // 到期不变 var after model.License require.NoError(t, db.First(&after, lic.ID).Error) assert.InDelta(t, 10, daysFromNow(after.ExpiresAt), 1) } func TestLicenseService_Verify_Success(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "LIC007") expiry := time.Now().Add(30 * 24 * time.Hour) lic := &model.License{ ShopID: shop.ID, LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX", IsActive: true, ExpiresAt: &expiry, } require.NoError(t, db.Create(lic).Error) require.NoError(t, db.Create(&model.LicenseDevice{ LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "my-device", }).Error) svc := NewLicenseService(db) result, err := svc.Verify(shop.ID, "my-device") require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, lic.ID, result.ID) } func TestLicenseService_Verify_Expired(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "LIC008") expiry := time.Now().Add(-1 * time.Hour) lic := &model.License{ ShopID: shop.ID, LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB", IsActive: true, ExpiresAt: &expiry, } require.NoError(t, db.Create(lic).Error) require.NoError(t, db.Create(&model.LicenseDevice{ LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "expired-device", }).Error) svc := NewLicenseService(db) result, err := svc.Verify(shop.ID, "expired-device") assert.Error(t, err) assert.Equal(t, ErrLicenseExpired, err) assert.Nil(t, result) } func TestLicenseService_Verify_NotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "LIC009") svc := NewLicenseService(db) result, err := svc.Verify(shop.ID, "nonexistent-device") assert.Error(t, err) assert.Equal(t, ErrLicenseNotFound, err) assert.Nil(t, result) } func TestLicenseService_Verify_NoExpiry(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "LIC010") lic := &model.License{ ShopID: shop.ID, LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF", IsActive: true, ExpiresAt: nil, } require.NoError(t, db.Create(lic).Error) require.NoError(t, db.Create(&model.LicenseDevice{ LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "lifetime-device", }).Error) svc := NewLicenseService(db) result, err := svc.Verify(shop.ID, "lifetime-device") require.NoError(t, err) require.NotNil(t, result) }