yavsc/src/Yavsc.Server/Helpers/WorkflowHelpers.cs
Paul Schneider 7f84d4d97a
fix(billing): tolerate ReflectionTypeLoadException during init
ConfigureBillingService() walks AppDomain.CurrentDomain.GetAssemblies()
and calls Assembly.GetTypes() on each. If any of the loaded assemblies
has a type that fails to resolve (a flaky dependency, an AddOn with a
broken reference, a test dependency that's been rewritten after compile),
GetTypes() throws ReflectionTypeLoadException (or, less commonly,
FileNotFoundException / TypeLoadException for the assembly itself).

In CI on the forgejo-runner (and especially in test discovery under
xunit v3), one such assembly is loaded somewhere between test runs and
silently throws. The exception is not handled, so:

  1. Collections are Cleared at the top of ConfigureBillingService().
  2. The reflection loop throws before reaching the
     RegisterBilling<HairCutQuery/HairMultiCutQuery/RdvQuery> calls.
  3. BillingService.Billing ends up empty (Count = 0).
  4. The second ConfigureBillingService() call sees the same assembly
     loaded (xunit v3 keeps the AppDomain warm for the whole suite),
     throws identically, and the test
     Yavsc.BillingServiceTests.ConfigureBillingService_CanBeCalledTwiceWithoutThrowing
     fails with 'Assert.Equal() Failure: Expected 3, Actual 0'.

Fix: catch ReflectionTypeLoadException and use the partial
.Types() list (the successfully-resolved subset), and use a
broader catch (with continue) for any other assembly-level
load failure. The lost user-settings types are not material;
they are derived from ApplicationDbContext in a separate loop
right after, and the RegisterBilling<>() calls that populate
BillingService.Billing run last, after both reflective phases
have completed best-effort.

The test still passes locally because the local test environment
loads a clean set of assemblies; only the CI runner (with its
extra test-time tooling) hits this path.
2026-08-16 16:35:05 +01:00

133 lines
5.3 KiB
C#

namespace Yavsc.Helpers
{
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Workflow;
using Yavsc.Billing;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow;
using Yavsc.Services;
using Yavsc.ViewModels.FrontOffice;
public static class WorkflowHelpers
{
// Synchronization lock for billing service configuration
private static readonly object _billingLock = new object();
public static async Task<List<PerformerProfileViewModel>>
ListPerformersAsync(this ApplicationDbContext context,
IBillingService billing,
string actCode)
{
var actors = context.Performers
.Include(p => p.Activity)
.Include(p => p.Performer)
.Where(p => p.Active && p.Activity.Any(u => u.DoesCode == actCode)).OrderBy(x => x.Rate)
.ToArray();
List<PerformerProfileViewModel> result = new();
foreach (var a in actors)
{
var settings = await billing.GetPerformersSettingsAsync(actCode, a.PerformerId);
result.Add(new PerformerProfileViewModel(a, actCode, settings));
}
return result;
}
public static void RegisterBilling<T>(string code, Func<ApplicationDbContext, long,
IQuery> getter) where T : IBillable
{
lock (_billingLock)
{
string typeName = typeof(T).Name;
if (BillingService.GlobalBillingMap.ContainsKey(typeName))
{
throw new InvalidOperationException($"Billing setup: type '{typeName}' already registered with different code");
}
if (BillingService.Billing.ContainsKey(code))
{
throw new InvalidOperationException($"Billing setup: code '{code}' already registered with different type");
}
BillingService.Billing.Add(code, getter);
BillingService.GlobalBillingMap.Add(typeName, code);
}
}
public static void ConfigureBillingService()
{
lock (_billingLock)
{
BillingService.Billing.Clear();
BillingService.GlobalBillingMap.Clear();
BillingService.UserSettings.Clear();
Config.ProfileTypes.Clear();
foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try
{
types = a.GetTypes();
}
catch (System.Reflection.ReflectionTypeLoadException rtle)
{
// Some referenced types failed to load; keep the
// ones that did and skip the rest so a flaky
// dependency in one assembly does not break
// billing initialization for every other assembly.
types = rtle.Types.Where(t => t != null).ToArray();
}
catch
{
// Assembly itself cannot be loaded (FileNotFoundException
// on a referenced assembly, etc.). Skip it entirely.
continue;
}
foreach (var c in types)
{
if (c.IsClass && !c.IsAbstract &&
c.GetInterface(nameof(IUserSettings)) != null)
{
Config.ProfileTypes.Add(c);
}
}
}
foreach (var propertyInfo in typeof(ApplicationDbContext).GetProperties())
{
if (propertyInfo.PropertyType.IsGenericType &&
propertyInfo.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>))
{
var entityType = propertyInfo.PropertyType.GetGenericArguments()[0];
if (typeof(IUserSettings).IsAssignableFrom(entityType))
{
BillingService.UserSettings.Add(propertyInfo);
}
}
}
RegisterBilling<HairCutQuery>(BillingCodes.Brush, new Func<ApplicationDbContext, long, IQuery>
((db, id) =>
{
var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularization).Single(q => q.Id == id);
query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId);
return query;
}));
RegisterBilling<HairMultiCutQuery>(BillingCodes.MBrush, new Func<ApplicationDbContext, long, IQuery>
((db, id) => db.HairMultiCutQueries.Include(q => q.Regularization).Single(q => q.Id == id)));
RegisterBilling<RdvQuery>(BillingCodes.Rdv, new Func<ApplicationDbContext, long, IQuery>
((db, id) => db.RdvQueries.Include(q => q.Regularization).Single(q => q.Id == id)));
}
}
}
}