feat/files-control #52

Open
notazof wants to merge 32 commits from feat/files-control into main
3 changed files with 225 additions and 90 deletions
Showing only changes of commit f7e1f4a736 - Show all commits

list only my billing codes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m23s

Paul Schneider 2026-09-13 03:49:07 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -7,6 +7,7 @@ using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
using Yavsc.Models.Workflow; using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
@ -154,6 +155,74 @@ VALUES
Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode)); Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode));
} }
[Fact]
public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "brush",
Weight = 50,
});
db.UserActivities.Add(new UserActivity
{
UserId = "alice",
DoesCode = "mbrush",
Weight = 50,
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "brush",
ActionName = BillingCodes.Brush,
Title = "Brush",
});
db.CommandForm.Add(new CommandForm
{
ActivityCode = "mbrush",
ActionName = BillingCodes.MBrush,
Title = "MBrush",
});
db.SaveChanges();
}
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice");
Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice");
}
[Fact]
public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities()
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedHaircutGraph();
using var http = NewClient();
var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
var payload = await response.Content.ReadFromJsonAsync<List<ProviderOngoingCommandDto>>(TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush);
Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush);
}
private sealed class ProviderOngoingCommandDto private sealed class ProviderOngoingCommandDto
{ {
public long Id { get; set; } public long Id { get; set; }

View file

@ -1,10 +1,10 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Npgsql; using Npgsql;
using System.Runtime.Loader;
using Yavsc.Controllers; using Yavsc.Controllers;
using Yavsc.Interfaces.Workflow; using Yavsc.Interfaces.Workflow;
using Yavsc.Models; using Yavsc.Models;
@ -22,6 +22,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
{ {
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true"; private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104; protected override int HttpsPort => 5104;
@ -30,9 +31,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
private static readonly object _sqliteLock = new(); private static readonly object _sqliteLock = new();
private static readonly object _npgsqlLock = new(); private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString; private static string? _sharedNpgsqlConnectionString;
private static string? _sharedNpgsqlAdminConnectionString;
private static string? _sharedNpgsqlDatabaseName;
private static bool _npgsqlCleanupRegistered;
protected override WebApplication BuildApp(WebApplicationBuilder builder) protected override WebApplication BuildApp(WebApplicationBuilder builder)
{ {
@ -126,14 +124,21 @@ public sealed class ApiWebServerFixture : WebHostFixture
} }
var adminConnectionString = BuildAdminConnectionString(); var adminConnectionString = BuildAdminConnectionString();
var databaseName = $"yavsc_api_test_{Guid.NewGuid():N}"; var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString)) using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{ {
adminConnection.Open(); adminConnection.Open();
using var createCommand = adminConnection.CreateCommand(); using var existsCommand = adminConnection.CreateCommand();
createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\""; existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
createCommand.ExecuteNonQuery(); existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
} }
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString) var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
@ -143,10 +148,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
IncludeErrorDetail = true IncludeErrorDetail = true
}; };
_sharedNpgsqlAdminConnectionString = adminConnectionString;
_sharedNpgsqlDatabaseName = databaseName;
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString(); _sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
RegisterNpgsqlCleanup();
return _sharedNpgsqlConnectionString; return _sharedNpgsqlConnectionString;
} }
} }
@ -176,61 +178,6 @@ public sealed class ApiWebServerFixture : WebHostFixture
return builder.ToString(); return builder.ToString();
} }
private static void RegisterNpgsqlCleanup()
{
if (_npgsqlCleanupRegistered)
{
return;
}
AppDomain.CurrentDomain.ProcessExit += (_, __) => DropTemporaryNpgsqlDatabase();
AssemblyLoadContext.Default.Unloading += _ => DropTemporaryNpgsqlDatabase();
_npgsqlCleanupRegistered = true;
}
private static void DropTemporaryNpgsqlDatabase()
{
lock (_npgsqlLock)
{
if (string.IsNullOrWhiteSpace(_sharedNpgsqlDatabaseName)
|| string.IsNullOrWhiteSpace(_sharedNpgsqlAdminConnectionString))
{
return;
}
try
{
using var adminConnection = new NpgsqlConnection(_sharedNpgsqlAdminConnectionString);
adminConnection.Open();
using (var terminateCommand = adminConnection.CreateCommand())
{
terminateCommand.CommandText = @"
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = @databaseName
AND pid <> pg_backend_pid();";
terminateCommand.Parameters.AddWithValue("databaseName", _sharedNpgsqlDatabaseName);
terminateCommand.ExecuteNonQuery();
}
using var dropCommand = adminConnection.CreateCommand();
dropCommand.CommandText = $"DROP DATABASE IF EXISTS \"{_sharedNpgsqlDatabaseName}\"";
dropCommand.ExecuteNonQuery();
}
catch
{
// Best-effort cleanup only.
}
finally
{
_sharedNpgsqlConnectionString = null;
_sharedNpgsqlAdminConnectionString = null;
_sharedNpgsqlDatabaseName = null;
}
}
}
private sealed class NoopMessageSender : IYavscMessageSender private sealed class NoopMessageSender : IYavscMessageSender
{ {
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev) public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
@ -251,7 +198,7 @@ WHERE datname = @databaseName
using var scope = Services.CreateScope(); using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted(); ResetDatabase(db);
db.Database.EnsureCreated(); db.Database.EnsureCreated();
var user = new ApplicationUser var user = new ApplicationUser
@ -352,6 +299,94 @@ WHERE datname = @databaseName
db.SaveChanges(); db.SaveChanges();
} }
private static void ResetDatabase(ApplicationDbContext db)
{
if (UseNpgsqlProvider())
{
db.Set<UserActivity>().RemoveRange(db.Set<UserActivity>());
db.Set<Activity>().RemoveRange(db.Set<Activity>());
db.Set<PerformerProfile>().RemoveRange(db.Set<PerformerProfile>());
db.Set<ApplicationUser>().RemoveRange(db.Set<ApplicationUser>());
db.Set<Location>().RemoveRange(db.Set<Location>());
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
db.Set<HairMultiCutQuery>().RemoveRange(db.Set<HairMultiCutQuery>());
db.Set<HairPrestation>().RemoveRange(db.Set<HairPrestation>());
db.Set<HairPrestationCollectionItem>().RemoveRange(db.Set<HairPrestationCollectionItem>());
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
}
private static IReadOnlyList<IEntityType> GetDeletionOrder(IModel model)
{
var entityTypes = model
.GetEntityTypes()
.Where(et =>
et.ClrType is not null &&
!et.IsOwned() &&
et.FindPrimaryKey() is not null)
.ToArray();
var included = new HashSet<IEntityType>(entityTypes);
var dependencies = new Dictionary<IEntityType, HashSet<IEntityType>>();
foreach (var entityType in entityTypes)
{
var principals = entityType
.GetForeignKeys()
.Where(fk => !fk.IsOwnership)
.Select(fk => fk.PrincipalEntityType)
.Where(included.Contains)
.ToHashSet();
dependencies[entityType] = principals;
}
var queue = new Queue<IEntityType>(
dependencies.Where(kvp => kvp.Value.Count == 0).Select(kvp => kvp.Key));
var order = new List<IEntityType>(entityTypes.Length);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!order.Contains(current))
{
order.Add(current);
}
foreach (var kvp in dependencies)
{
if (!kvp.Value.Remove(current) || kvp.Value.Count != 0)
{
continue;
}
if (!order.Contains(kvp.Key) && !queue.Contains(kvp.Key))
{
queue.Enqueue(kvp.Key);
}
}
}
// If cycles remain (rare), append unresolved types last and rely on DB cascades.
foreach (var entityType in entityTypes)
{
if (!order.Contains(entityType))
{
order.Add(entityType);
}
}
return order;
}
public void ResetAndSeedRdvQueryGraph() public void ResetAndSeedRdvQueryGraph()
{ {
ResetAndSeedActivityGraph(); ResetAndSeedActivityGraph();

View file

@ -123,34 +123,65 @@ namespace Yavsc.ApiControllers
WorkflowHelpers.ConfigureBillingService(); WorkflowHelpers.ConfigureBillingService();
} }
// Query known derived types explicitly so legacy rows with var allowedActivityCodes = dbContext.UserActivities
// invalid/empty discriminator values are naturally ignored.
var rdvCommands = dbContext.Set<RdvQuery>()
.AsNoTracking() .AsNoTracking()
.Where(q => q.PerformerId == uid) .Where(a => a.UserId == uid)
.Where(q => q.Status == QueryStatus.Inserted .Select(a => a.DoesCode)
|| q.Status == QueryStatus.Accepted .Distinct()
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList(); .ToList();
var hairCommands = dbContext.Set<HairCutQuery>() if (allowedActivityCodes.Count == 0)
.AsNoTracking() {
.Where(q => q.PerformerId == uid) return Ok(Array.Empty<object>());
.Where(q => q.Status == QueryStatus.Inserted }
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList();
var hairMultiCommands = dbContext.Set<HairMultiCutQuery>() var allowedBillingCodes = dbContext.CommandForm
.AsNoTracking() .AsNoTracking()
.Where(q => q.PerformerId == uid) .Where(form => allowedActivityCodes.Contains(form.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Select(form => form.ActionName)
|| q.Status == QueryStatus.Accepted .Where(actionName => !string.IsNullOrWhiteSpace(actionName))
|| q.Status == QueryStatus.InProgress) .Distinct()
.Cast<NominativeServiceCommand>() .ToHashSet(StringComparer.OrdinalIgnoreCase);
.ToList();
var fallbackToActivityFilteringOnly = allowedBillingCodes.Count == 0;
// Query only the command types allowed by the performer's declared
// activities; this avoids touching unrelated legacy slices.
var rdvCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Rdv)
? dbContext.Set<RdvQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.Brush)
? dbContext.Set<HairCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var hairMultiCommands = fallbackToActivityFilteringOnly || allowedBillingCodes.Contains(BillingCodes.MBrush)
? dbContext.Set<HairMultiCutQuery>()
.AsNoTracking()
.Where(q => q.PerformerId == uid)
.Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>()
.ToList()
: new List<NominativeServiceCommand>();
var commands = rdvCommands var commands = rdvCommands
.Concat(hairCommands) .Concat(hairCommands)