Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using SelfService.Domain.Models;
using SelfService.Domain.Queries;
using SelfService.Domain.Services;
using SelfService.Infrastructure.Api.Metrics;
using SelfService.Infrastructure.Persistence;
using SelfService.Infrastructure.Persistence.Models;
using SelfService.Tests.Builders;
Expand Down Expand Up @@ -321,7 +322,8 @@ private static EmailCampaignApplicationService BuildService(
IUserFilterService? userFilter = null,
IRbacApplicationService? rbac = null,
ITemplateRenderingService? templateRendering = null,
IServiceScopeFactory? scopeFactory = null
IServiceScopeFactory? scopeFactory = null,
AllCapabilitiesCostsCache? costsCache = null
)
{
return new EmailCampaignApplicationService(
Expand All @@ -335,7 +337,8 @@ private static EmailCampaignApplicationService BuildService(
userFilter ?? Mock.Of<IUserFilterService>(),
templateRendering ?? Mock.Of<ITemplateRenderingService>(),
rbac ?? Mock.Of<IRbacApplicationService>(),
scopeFactory ?? Mock.Of<IServiceScopeFactory>()
scopeFactory ?? Mock.Of<IServiceScopeFactory>(),
costsCache ?? new AllCapabilitiesCostsCache()
);
}

Expand Down
43 changes: 43 additions & 0 deletions src/SelfService.Tests/Domain/Models/TestCapabilityCosts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using SelfService.Domain.Models;

namespace SelfService.Tests.Domain.Models;

public class TestCapabilityCosts
{
private static CapabilityCosts Costs(params TimeSeries[] points) =>
new(CapabilityId.Parse("test-capability-abc12"), points);

[Fact]
public void SumForLastDays_NoData_ReturnsNull()
{
Assert.Null(Costs().SumForLastDays(7));
}

[Fact]
public void SumForLastDays_FewerDaysThanWindow_SumsAll()
{
var costs = Costs(
new TimeSeries(10.00f, new DateTime(2026, 6, 27)),
new TimeSeries(5.50f, new DateTime(2026, 6, 28))
);

Assert.Equal(15.50f, costs.SumForLastDays(7));
}

[Fact]
public void SumForLastDays_MoreDaysThanWindow_SumsMostRecentUnordered()
{
// Deliberately unordered input; the 7-day window must pick the 7 most recent timestamps.
var costs = Costs(
new TimeSeries(1f, new DateTime(2026, 6, 20)), // older — excluded by a 3-day window
new TimeSeries(1f, new DateTime(2026, 6, 21)), // older — excluded
new TimeSeries(2f, new DateTime(2026, 6, 28)), // newest
new TimeSeries(4f, new DateTime(2026, 6, 26)),
new TimeSeries(8f, new DateTime(2026, 6, 27))
);

// 3 most recent: 6/28 (2) + 6/27 (8) + 6/26 (4) = 14
Assert.Equal(14f, costs.SumForLastDays(3));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using SelfService.Domain.Models;
using SelfService.Infrastructure.Api.Metrics;
using SelfService.Infrastructure.Persistence;
using SelfService.Tests.Builders;

namespace SelfService.Tests.Infrastructure.Persistence;

public class TestCapabilityFilterService
{
private static string CostFilterAudience(string op, string value) =>
JsonSerializer.Serialize(
new
{
mode = "filter",
filters = new[]
{
new
{
field = "cost",
@operator = op,
value = value,
},
},
}
);

private static CapabilityCosts CostsFor(Capability capability, params float[] dailyValues)
{
// SumForLastDays takes the most recent N timestamps; the absolute dates only need to be ordered.
var baseDate = new DateTime(2026, 6, 1);
var series = dailyValues.Select((v, i) => new TimeSeries(v, baseDate.AddDays(i))).ToArray();
return new CapabilityCosts(capability.Id, series);
}

private static async Task<AllCapabilitiesCostsCache> CacheWith(params CapabilityCosts[] costs)
{
var cache = new AllCapabilitiesCostsCache();
await cache.UpdateCache(new AllCapabilitiesCosts(costs.ToList()));
return cache;
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task cost_filter_greater_than_excludes_cheaper_and_costless_capabilities()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive")).Build();
var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap")).Build();
var noCost = A.Capability.WithId(CapabilityId.CreateFrom("nocost")).Build();
dbContext.Capabilities.AddRange(expensive, cheap, noCost);
await dbContext.SaveChangesAsync();

// noCost is intentionally absent from the cache.
var cache = await CacheWith(CostsFor(expensive, 6f, 4f), CostsFor(cheap, 1f));
var sut = new CapabilityFilterService(dbContext, cache);

var result = await sut.ResolveCapabilities(CostFilterAudience("gt", "2"));

Assert.Equal(new[] { expensive.Id }, result.Select(c => c.Id).ToArray());
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task cost_filter_less_than_or_equal_includes_matching_and_still_excludes_costless()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

var expensive = A.Capability.WithId(CapabilityId.CreateFrom("expensive")).Build();
var cheap = A.Capability.WithId(CapabilityId.CreateFrom("cheap")).Build();
var noCost = A.Capability.WithId(CapabilityId.CreateFrom("nocost")).Build();
dbContext.Capabilities.AddRange(expensive, cheap, noCost);
await dbContext.SaveChangesAsync();

var cache = await CacheWith(CostsFor(expensive, 10f), CostsFor(cheap, 1f));
var sut = new CapabilityFilterService(dbContext, cache);

var result = await sut.ResolveCapabilities(CostFilterAudience("lte", "2"));

// cheap (1) matches; expensive (10) and noCost (no data) do not.
Assert.Equal(new[] { cheap.Id }, result.Select(c => c.Id).ToArray());
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task cost_filter_with_empty_cache_narrows_audience_to_nothing()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

dbContext.Capabilities.AddRange(
A.Capability.WithId(CapabilityId.CreateFrom("alpha")).Build(),
A.Capability.WithId(CapabilityId.CreateFrom("beta")).Build()
);
await dbContext.SaveChangesAsync();

var sut = new CapabilityFilterService(dbContext, new AllCapabilitiesCostsCache());

var result = await sut.ResolveCapabilities(CostFilterAudience("gt", "0"));

Assert.Empty(result);
}

[Fact]
[Trait("Category", "InMemoryDatabase")]
public async Task no_cost_filter_leaves_costless_capabilities_untouched()
{
await using var databaseFactory = new InMemoryDatabaseFactory();
var dbContext = await databaseFactory.CreateSelfServiceDbContext();

dbContext.Capabilities.AddRange(
A.Capability.WithId(CapabilityId.CreateFrom("alpha")).Build(),
A.Capability.WithId(CapabilityId.CreateFrom("beta")).Build()
);
await dbContext.SaveChangesAsync();

var sut = new CapabilityFilterService(dbContext, new AllCapabilitiesCostsCache());

var audience = JsonSerializer.Serialize(new { mode = "all" });
var result = await sut.ResolveCapabilities(audience);

Assert.Equal(2, result.Count);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ private static TemplateRenderContext CreateContext(
AwsAccount? awsAccount = null,
List<AzureResource>? azureResources = null,
List<RequirementsMetric>? requirementScores = null,
int pendingMembershipApplicationCount = 0
int pendingMembershipApplicationCount = 0,
CapabilityCosts? costs = null
)
{
return new TemplateRenderContext
Expand All @@ -29,6 +30,7 @@ private static TemplateRenderContext CreateContext(
AzureResources = azureResources ?? new List<AzureResource>(),
RequirementScores = requirementScores ?? new List<RequirementsMetric>(),
PendingMembershipApplicationCount = pendingMembershipApplicationCount,
Costs = costs,
};
}

Expand Down Expand Up @@ -348,6 +350,83 @@ public void RenderTemplate_PendingMembershipApplicationCount_Zero_RendersZero()
Assert.Equal("Pending: 0", result);
}

[Fact]
public void RenderTemplate_CapabilityCost_WithData_RendersUsdSumOverWindow()
{
var cap = A.Capability.Build();
var costs = new CapabilityCosts(
cap.Id,
new[]
{
new TimeSeries(10.00f, new DateTime(2026, 6, 20)),
new TimeSeries(20.50f, new DateTime(2026, 6, 21)),
new TimeSeries(12.95f, new DateTime(2026, 6, 22)),
}
);
var context = CreateContext(capability: cap, costs: costs);

var result = _sut.RenderTemplate("{{Capability.Cost.7Days}}", context);

Assert.Equal("$43.45", result);
}

[Fact]
public void RenderTemplate_CapabilityCost_WindowsTakeMostRecentDays()
{
var cap = A.Capability.Build();
// 30 days of $2/day, plus one much older $1000 day that the 7/14/30-day windows exclude.
var points = new List<TimeSeries> { new(1000.00f, new DateTime(2026, 1, 1)) };
for (var d = 0; d < 30; d++)
points.Add(new TimeSeries(2.00f, new DateTime(2026, 6, 28).AddDays(-d)));
var context = CreateContext(capability: cap, costs: new CapabilityCosts(cap.Id, points.ToArray()));

var result = _sut.RenderTemplate(
"{{Capability.Cost.7Days}}|{{Capability.Cost.14Days}}|{{Capability.Cost.30Days}}",
context
);

Assert.Equal("$14.00|$28.00|$60.00", result);
}

[Fact]
public void RenderTemplate_CapabilityCost_NoData_RendersNA()
{
var context = CreateContext(costs: null);

var result = _sut.RenderTemplate("{{Capability.Cost.30Days}}", context);

Assert.Equal("N/A", result);
}

[Fact]
public void RenderTemplate_EachUserCapabilities_CostResolvesPerCapability()
{
var capA = A.Capability.WithName("Cap A").Build();
var capB = A.Capability.WithName("Cap B").Build();
var ctx = UserContext(
userCapabilities: new List<UserCapabilityRef>
{
new()
{
Capability = capA,
Costs = new CapabilityCosts(capA.Id, new[] { new TimeSeries(100.00f, new DateTime(2026, 6, 28)) }),
},
new()
{
Capability = capB,
Costs = new CapabilityCosts(capB.Id, new[] { new TimeSeries(5.50f, new DateTime(2026, 6, 28)) }),
},
}
);

var result = _sut.RenderTemplate(
"{{#each User.Capabilities}}[{{Capability.Name}}={{Capability.Cost.30Days}}]{{/each}}",
ctx
);

Assert.Equal("[Cap A=$100.00][Cap B=$5.50]", result);
}

[Fact]
public void RenderTemplate_AllNewVariablesCombined()
{
Expand Down Expand Up @@ -385,6 +464,9 @@ public void GetVariableDefinitions_ReturnsExpectedSet()
"Capability.CreatedBy",
"Capability.RequirementScore",
"Capability.MemberCount",
"Capability.Cost.7Days",
"Capability.Cost.14Days",
"Capability.Cost.30Days",
"Member.DisplayName",
"Member.Email",
"Campaign.Name",
Expand Down Expand Up @@ -589,24 +671,34 @@ public void RenderTemplate_EachUserCapabilities_OuterTokensStillResolve()
}

[Fact]
public void GetVariableDefinitions_User_OmitsCapabilityVariables()
public void GetVariableDefinitions_User_IncludesPerCapabilityVariablesWithScope()
{
var userVars = _sut.GetVariableDefinitions(EmailCampaignTargetType.User);

Assert.Contains(userVars, v => v.Name == "User.Email");
Assert.Contains(userVars, v => v.Name == "User.CapabilityCount");
Assert.Contains(userVars, v => v.Name == "Campaign.Name");
Assert.DoesNotContain(userVars, v => v.Name == "Capability.Name");
Assert.DoesNotContain(userVars, v => v.Name == "Aws.AccountId");
// Top-level user/shared variables resolve at the recipient/campaign root.
Assert.Contains(userVars, v => v.Name == "User.Email" && v.Scope == "topLevel");
Assert.Contains(userVars, v => v.Name == "User.CapabilityCount" && v.Scope == "topLevel");
Assert.Contains(userVars, v => v.Name == "Campaign.Name" && v.Scope == "topLevel");

// Capability-scoped variables now surface for use inside {{#each User.Capabilities}},
// flagged "perCapability" so the picker can mark them as loop-only.
Assert.Contains(userVars, v => v.Name == "Capability.Name" && v.Scope == "perCapability");
Assert.Contains(userVars, v => v.Name == "Aws.AccountId" && v.Scope == "perCapability");
Assert.Contains(userVars, v => v.Name == "Capability.Cost.30Days" && v.Scope == "perCapability");

// Member.* is replaced by User.* in user campaigns and must not appear.
Assert.DoesNotContain(userVars, v => v.Name == "Member.Email");
Assert.DoesNotContain(userVars, v => v.Name == "Member.DisplayName");
}

[Fact]
public void GetVariableDefinitions_Capability_OmitsUserVariables()
public void GetVariableDefinitions_Capability_OmitsUserVariables_AndScopesCostPerCapability()
{
var capVars = _sut.GetVariableDefinitions(EmailCampaignTargetType.Capability);

Assert.Contains(capVars, v => v.Name == "Capability.Name");
Assert.Contains(capVars, v => v.Name == "Member.Email");
Assert.Contains(capVars, v => v.Name == "Capability.Name" && v.Scope == "perCapability");
Assert.Contains(capVars, v => v.Name == "Capability.Cost.7Days" && v.Scope == "perCapability");
Assert.Contains(capVars, v => v.Name == "Member.Email" && v.Scope == "topLevel");
Assert.DoesNotContain(capVars, v => v.Name == "User.Email");
Assert.DoesNotContain(capVars, v => v.Name == "User.CapabilityCount");
}
Expand Down
Loading
Loading