using EntitlementBLL.Common; using EntitlementBLL.Implementations; using EntitlementDAL.DTOs; using EntitlementDAL.Interfaces; using GB5Shared.DTO.Framework.Login; using Moq; using Xunit; namespace EntitlementTests; /// /// Covers every layer of IEntitlementService.IsFeatureEnabledAsync's 9-layer resolution /// algorithm, in the exact order specified by the plan — all DAL dependencies are mocked, no /// real DB/Redis involved. /// public class EntitlementResolutionTests { private const int ClientId = 42; private const int UserId = 7; private const string FeatureCode = "MOD.INVENTORY"; private static (EntitlementService svc, Mock feature, Mock flag, Mock grant, Mock beta, Mock license, Mock sub) BuildService() { var feature = new Mock(); var flag = new Mock(); var grant = new Mock(); var beta = new Mock(); var license = new Mock(); var sub = new Mock(); var loginFactory = new Mock(); loginFactory.Setup(f => f.Create(It.IsAny(), It.IsAny())) .Returns((int clientId, int userId) => new LoginDTO { ClientId = clientId, UserId = userId }); var svc = new EntitlementService( feature.Object, flag.Object, grant.Object, beta.Object, license.Object, sub.Object, loginFactory.Object); return (svc, feature, flag, grant, beta, license, sub); } private static FeatureFlagDTO Flag(byte status, byte rolloutType = 0, byte rolloutPercent = 100, string flagName = "TestFlag") => new() { FeatureFlagId = 1, FeatureId = 1, FlagName = flagName, FlagStatus = status, RolloutType = rolloutType, RolloutPercent = rolloutPercent }; private static EntitlementDTO Grant(byte isEnabled = 1) => new() { EntitlementId = 1, SubscriptionId = 1, FeatureId = 1, IsEnabled = isEnabled, ValidFrom = DateTime.UtcNow.AddDays(-1), ValidUntil = DateTime.UtcNow.AddDays(365) }; // ── Layer 1 — Kill Switch ──────────────────────────────────────────────────── [Fact] public async Task Test_Layer1_KillSwitch_AlwaysDenies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 2)); // KillSwitch grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } [Fact] public async Task Test_Layer1_KillSwitch_CannotBeBypassedByAdminRole() { var (svc, _, flag, grant, _, _, _) = BuildService(); const int adminUserId = 1; // conventionally a platform-admin user id in this codebase flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 2)); // KillSwitch grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); // Even an explicit per-user "enabled" override for the admin user must NOT be consulted — // the kill switch must short-circuit before Layer 4 is ever reached. flag.Setup(f => f.GetUserTargetAsync(1, adminUserId, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureFlagTargetDTO { FeatureFlagId = 1, TargetType = 1, TargetUserId = adminUserId, IsEnabled = 1 }); var result = await svc.IsFeatureEnabledAsync(ClientId, adminUserId, FeatureCode, CancellationToken.None); Assert.False(result); flag.Verify(f => f.GetUserTargetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } // ── Layer 2 — Not Licensed ─────────────────────────────────────────────────── [Fact] public async Task Test_Layer2_DisabledEntitlement_Denies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagDTO?)null); // no gating flag — pure licence check grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 0)); // not licensed var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } // ── Layer 3 — Globally Off ─────────────────────────────────────────────────── [Fact] public async Task Test_Layer3_FlagOff_Denies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 0)); // Off grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); // licensed — Layer2 passes var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } // ── Layer 4 — Per-User Target Override ─────────────────────────────────────── [Fact] public async Task Test_Layer4_PerUserTarget_Enabled_Allows() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 3)); // On, SelectedUsers grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureFlagTargetDTO { FeatureFlagId = 1, TargetType = 1, TargetUserId = UserId, IsEnabled = 1 }); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.True(result); } [Fact] public async Task Test_Layer4_PerUserTarget_Disabled_Denies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 3)); grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureFlagTargetDTO { FeatureFlagId = 1, TargetType = 1, TargetUserId = UserId, IsEnabled = 0 }); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } // ── Layer 5 — Per-Client Target / Beta Programme ──────────────────────────── [Fact] public async Task Test_Layer5_BetaClient_InProgram_Allows() { var (svc, _, flag, grant, beta, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 1, flagName: "EarlyAccessProgram")); // On, BetaClients grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); beta.Setup(b => b.IsClientInProgramAsync(ClientId, "EarlyAccessProgram", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.True(result); } [Fact] public async Task Test_Layer5_SelectedClient_Explicit_Denies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 2)); // On, SelectedClients grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureFlagTargetDTO { FeatureFlagId = 1, TargetType = 0, TargetClientId = ClientId, IsEnabled = 0 }); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } // ── Layer 6 — Percentage Rollout ───────────────────────────────────────────── [Fact] public async Task Test_Layer6_Percentage_BelowThreshold_Allows() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 4, rolloutPercent: 100)); // pct (0-99) always < 100 grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.True(result); } [Fact] public async Task Test_Layer6_Percentage_AboveThreshold_Denies() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 4, rolloutPercent: 0)); // pct (0-99) never < 0 grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } [Fact] public async Task Test_Layer6_Percentage_Deterministic_SameUserSameResult() { var (svc, _, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 4, rolloutPercent: 50)); grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); var result1 = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); var result2 = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); var result3 = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.Equal(result1, result2); Assert.Equal(result2, result3); } // ── Layer 7 — Global On ────────────────────────────────────────────────────── [Fact] public async Task Test_Layer7_GlobalOn_Allows() { var (svc, feature, flag, grant, _, _, _) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 0)); // On, Global grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); feature.Setup(f => f.GetByCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureType = 0 }); // Boolean — Layer8 no-ops var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.True(result); } // ── Layer 8 — Quota Enforcement ────────────────────────────────────────────── [Fact] public async Task Test_Layer8_QuotaExceeded_Denies() { var (svc, feature, flag, grant, _, license, subDal) = BuildService(); flag.Setup(f => f.GetByFeatureCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 0)); // On, Global grant.Setup(g => g.GetByClientAndFeatureCodeAsync(ClientId, FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(Grant(isEnabled: 1)); flag.Setup(f => f.GetUserTargetAsync(1, UserId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); flag.Setup(f => f.GetClientTargetAsync(1, ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagTargetDTO?)null); feature.Setup(f => f.GetByCodeAsync(FeatureCode, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureType = 1 }); // Quota subDal.Setup(s => s.GetByClientAsync(ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 1, ClientId = ClientId }); license.Setup(l => l.GetBySubscriptionAndTypeAsync(1, 0, It.IsAny(), It.IsAny())) // LicenseTypeEnum.Web = 0 .ReturnsAsync(new LicenseDTO { LicenseId = 5, SubscriptionId = 1, LicenseType = 0, Seats = 5 }); license.Setup(l => l.GetActiveAssignmentCountAsync(5, It.IsAny(), It.IsAny())) .ReturnsAsync(5); // used == seats -> exceeded var result = await svc.IsFeatureEnabledAsync(ClientId, UserId, FeatureCode, CancellationToken.None); Assert.False(result); } // ── GetResolvedBundleAsync — bundle-schema enhancement (FeatureGroupId + FlagState) ──────── // Covers EntitlementDAL.Enums.BundleFlagStateEnum classification, mirroring (not duplicating) // Layers 1/2/3/5 of the resolution algorithm above, for the client-level (no-user) bundle // payload consumed by gb-ent-client. [Fact] public async Task Test_Bundle_NoGatingFlag_PopulatesFeatureGroupIdAndNormalState() { var (svc, feature, flag, grant, beta, license, sub) = BuildService(); sub.Setup(s => s.GetByClientAsync(ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 1, ClientId = ClientId, LicenseValidTill = DateTime.UtcNow.AddYears(1) }); grant.Setup(g => g.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { Grant(isEnabled: 1) }); license.Setup(l => l.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); beta.Setup(b => b.GetOpenProgramsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); feature.Setup(f => f.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureGroupId = 9 }); flag.Setup(f => f.GetByFeatureIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync((FeatureFlagDTO?)null); // no gating flag at all var bundle = await svc.GetResolvedBundleAsync(ClientId, CancellationToken.None); var item = Assert.Single(bundle.Entitlements); Assert.Equal(9, item.FeatureGroupId); Assert.Equal(EntitlementDAL.Enums.BundleFlagStateEnum.Normal, item.FlagState); } [Fact] public async Task Test_Bundle_KillSwitch_MapsToMaintenanceState() { var (svc, feature, flag, grant, beta, license, sub) = BuildService(); sub.Setup(s => s.GetByClientAsync(ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 1, ClientId = ClientId, LicenseValidTill = DateTime.UtcNow.AddYears(1) }); grant.Setup(g => g.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { Grant(isEnabled: 1) }); license.Setup(l => l.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); beta.Setup(b => b.GetOpenProgramsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); feature.Setup(f => f.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureGroupId = 9 }); flag.Setup(f => f.GetByFeatureIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 2)); // KillSwitch var bundle = await svc.GetResolvedBundleAsync(ClientId, CancellationToken.None); var item = Assert.Single(bundle.Entitlements); // Client-facing wording is "Undergoing maintenance", never "Kill Switch" — the bundle // must never leak the raw kill-switch concept to a bundle consumer. Assert.Equal(EntitlementDAL.Enums.BundleFlagStateEnum.Maintenance, item.FlagState); } [Fact] public async Task Test_Bundle_NotLicensed_MapsToHiddenState() { var (svc, feature, flag, grant, beta, license, sub) = BuildService(); sub.Setup(s => s.GetByClientAsync(ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 1, ClientId = ClientId, LicenseValidTill = DateTime.UtcNow.AddYears(1) }); grant.Setup(g => g.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { Grant(isEnabled: 0) }); // not licensed license.Setup(l => l.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); beta.Setup(b => b.GetOpenProgramsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); feature.Setup(f => f.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureGroupId = 9 }); // Layer-2-equivalent short-circuit happens before the flag is even consulted for state. flag.Setup(f => f.GetByFeatureIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 0)); var bundle = await svc.GetResolvedBundleAsync(ClientId, CancellationToken.None); var item = Assert.Single(bundle.Entitlements); Assert.Equal(EntitlementDAL.Enums.BundleFlagStateEnum.Hidden, item.FlagState); } [Fact] public async Task Test_Bundle_BetaRollout_ClientEnrolled_MapsToBetaPreviewState() { var (svc, feature, flag, grant, beta, license, sub) = BuildService(); sub.Setup(s => s.GetByClientAsync(ClientId, It.IsAny(), It.IsAny())) .ReturnsAsync(new SubscriptionDTO { SubscriptionId = 1, ClientId = ClientId, LicenseValidTill = DateTime.UtcNow.AddYears(1) }); grant.Setup(g => g.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new List { Grant(isEnabled: 1) }); license.Setup(l => l.GetBySubscriptionAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); beta.Setup(b => b.GetOpenProgramsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Enumerable.Empty()); feature.Setup(f => f.GetByIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(new FeatureDTO { FeatureId = 1, FeatureCode = FeatureCode, FeatureGroupId = 9 }); flag.Setup(f => f.GetByFeatureIdAsync(1, It.IsAny(), It.IsAny())) .ReturnsAsync(Flag(status: 1, rolloutType: 1, flagName: "EarlyAccessProgram")); // On, BetaClients beta.Setup(b => b.IsClientInProgramAsync(ClientId, "EarlyAccessProgram", It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(true); var bundle = await svc.GetResolvedBundleAsync(ClientId, CancellationToken.None); var item = Assert.Single(bundle.Entitlements); Assert.Equal(EntitlementDAL.Enums.BundleFlagStateEnum.BetaPreview, item.FlagState); } }