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);
}
}