Notify performers of API bookings and client-signed estimates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fae754f102
commit
f9d95fd98f
13 changed files with 390 additions and 63 deletions
|
|
@ -0,0 +1,78 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.ApiControllers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Models.Billing;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class BillingSignatureNotificationTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("bob", "bob", true)]
|
||||
[InlineData("alice", "alice", false)]
|
||||
[InlineData("alice", "bob", false)]
|
||||
public async Task Json_signature_notifies_only_when_the_authenticated_client_signs(
|
||||
string caller, string signer, bool notifies)
|
||||
{
|
||||
fixture.ResetAndSeedActivityGraph();
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var estimate = new Estimate
|
||||
{
|
||||
ClientId = "bob", OwnerId = "alice", CommandType = BillingCodes.Rdv,
|
||||
Title = "Devis notification", Description = "Devis de test",
|
||||
AttachedFiles = new List<string>(), AttachedGraphics = new List<string>()
|
||||
};
|
||||
db.Estimates.Add(estimate);
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
var controller = ActivatorUtilities.CreateInstance<BillingController>(scope.ServiceProvider);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("sub", caller) }, "test"))
|
||||
}
|
||||
};
|
||||
controller.Url = new SignatureUrlHelper();
|
||||
var result = await controller.Sign(estimate.Id, new SignatureSubmission
|
||||
{
|
||||
SignerUserId = signer, CoordinateMax = 10000, Strokes = new[] { 1, 5000, 5000 }
|
||||
}, TestContext.Current.CancellationToken);
|
||||
if (caller != signer)
|
||||
{
|
||||
Assert.IsType<ForbidResult>(result);
|
||||
Assert.Empty(db.Signatures);
|
||||
}
|
||||
else
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
|
||||
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
if (notifies)
|
||||
{
|
||||
Assert.Equal("user/alice", Assert.Single(db.Notification).Target);
|
||||
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||
Assert.NotEqual(default, estimate.ClientValidationDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Empty(db.Notification);
|
||||
Assert.Empty(delivery.Emails);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SignatureUrlHelper : IUrlHelper
|
||||
{
|
||||
public ActionContext ActionContext => throw new NotSupportedException();
|
||||
public string? Action(UrlActionContext actionContext) => "/api/v1/bill/estimate/sign";
|
||||
public string? Content(string? contentPath) => throw new NotSupportedException();
|
||||
public bool IsLocalUrl(string? url) => throw new NotSupportedException();
|
||||
public string? Link(string? routeName, object? values) => throw new NotSupportedException();
|
||||
public string? RouteUrl(UrlRouteContext routeContext) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ using Yavsc.Models.Relationship;
|
|||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Services;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
namespace Yavsc.Api.Test.Fixtures;
|
||||
|
||||
|
|
@ -64,8 +67,13 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
|
||||
builder.Services.AddLocalization();
|
||||
builder.Services.Configure<GoogleAuthSettings>(_ => { });
|
||||
builder.Services.Configure<SiteSettings>(settings => settings.Authority = "example.test");
|
||||
builder.Services.AddTransient<IBillingService, BillingService>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, NoopMessageSender>();
|
||||
builder.Services.AddSingleton<RecordingMessageDelivery>();
|
||||
builder.Services.AddSingleton<ITrueEmailSender>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
|
||||
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
||||
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdministratorOnly", policy =>
|
||||
|
|
@ -204,23 +212,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed class NoopMessageSender : IYavscMessageSender
|
||||
{
|
||||
public Task<MessageWithPayloadResponse> NotifyBookQueryAsync(IEnumerable<string> connectionIds, RdvQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyEstimateAsync(IEnumerable<string> connectionIds, EstimationEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyHairCutQueryAsync(IEnumerable<string> connectionIds, HairCutQueryEvent ev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
|
||||
public Task<MessageWithPayloadResponse> NotifyAsync(IEnumerable<string> connectionIds, IEvent yaev)
|
||||
=> Task.FromResult(new MessageWithPayloadResponse());
|
||||
}
|
||||
|
||||
public void ResetAndSeedActivityGraph()
|
||||
{
|
||||
Services.GetRequiredService<RecordingMessageDelivery>().Clear();
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
|
|
@ -334,6 +328,8 @@ public sealed class ApiWebServerFixture : WebHostFixture
|
|||
// CommandLine reference NominativeServiceCommand (CommandId) and
|
||||
// must be deleted before the Rdv/HairCut/HairMultiCut queries.
|
||||
db.Set<CommandLine>().RemoveRange(db.Set<CommandLine>());
|
||||
db.DismissClicked.RemoveRange(db.DismissClicked);
|
||||
db.Notification.RemoveRange(db.Notification);
|
||||
db.Set<Estimate>().RemoveRange(db.Set<Estimate>());
|
||||
db.Set<RdvQuery>().RemoveRange(db.Set<RdvQuery>());
|
||||
db.Set<HairCutQuery>().RemoveRange(db.Set<HairCutQuery>());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Yavsc.Interface;
|
||||
using Yavsc.Server.Hubs;
|
||||
|
||||
namespace Yavsc.Api.Test.Fixtures;
|
||||
|
||||
public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<ChatHub>, IHubClients
|
||||
{
|
||||
public record Email(string Recipient, string Subject, string Body);
|
||||
public record Push(string ConnectionId, string Method, object?[] Arguments);
|
||||
|
||||
public ConcurrentQueue<Email> Emails { get; } = new();
|
||||
public ConcurrentQueue<Push> Pushes { get; } = new();
|
||||
IHubClients IHubContext<ChatHub>.Clients => this;
|
||||
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
|
||||
public IClientProxy All => throw new NotSupportedException();
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Group(string groupName) => throw new NotSupportedException();
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
|
||||
public IClientProxy User(string userId) => throw new NotSupportedException();
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<string> SendEmailAsync(string name, string email, string subject, string htmlMessage)
|
||||
{
|
||||
Emails.Enqueue(new Email(email, subject, htmlMessage));
|
||||
return Task.FromResult(Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Emails.Clear();
|
||||
Pushes.Clear();
|
||||
}
|
||||
|
||||
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
delivery.Pushes.Enqueue(new Push(connectionId, method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ using Yavsc.Models;
|
|||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
|
|
@ -138,6 +139,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
|
||||
// Default caller is "alice" (the provider) → ProAccepted.
|
||||
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -165,6 +168,8 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Pro, signature.Type);
|
||||
Assert.Equal("alice", signature.SignerId);
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -192,6 +197,48 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
|
|||
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
|
||||
Assert.Equal(SignatureType.Client, signature.Type);
|
||||
Assert.Equal("bob", signature.SignerId);
|
||||
var notification = Assert.Single(assertDb.Notification);
|
||||
Assert.Equal("user/alice", notification.Target);
|
||||
Assert.Equal($"estimate/{estimateId}/client-signed", notification.tag);
|
||||
Assert.Contains("bob", notification.body);
|
||||
Assert.Contains("Devis prestation", notification.body);
|
||||
var email = Assert.Single(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
Assert.Equal("alice@example.test", email.Recipient);
|
||||
Assert.Equal("Devis signé par le client", email.Subject);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_signature_pushes_to_the_performer_even_without_confirmed_email()
|
||||
{
|
||||
var (queryId, estimateId) = SeedQuery(withEstimate: true);
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Users.Single(u => u.Id == "alice").EmailConfirmed = false;
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
|
||||
manager.OnConnected("signed-alice", "alice", false);
|
||||
try
|
||||
{
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsync(
|
||||
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
|
||||
WithSignature(), TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
Assert.Empty(delivery.Emails);
|
||||
var push = Assert.Single(delivery.Pushes);
|
||||
Assert.Equal("signed-alice", push.ConnectionId);
|
||||
Assert.Equal("ClientEstimateSigned", push.Arguments[0]);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Equal($"estimate/{estimateId}/client-signed", Assert.Single(db.Notification).tag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.OnDisconnected("signed-alice");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ using Yavsc;
|
|||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Models.Workflow;
|
||||
using Yavsc.Tests.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Services;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
|
|
@ -34,6 +37,72 @@ public sealed class RdvQueryApiControllerTests : IClassFixture<ApiWebServerFixtu
|
|||
return http;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuery_notifies_performer_by_database_email_and_signalr()
|
||||
{
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
var manager = _fixture.Services.GetRequiredService<IConnexionManager>();
|
||||
manager.OnConnected("rdv-alice", "alice", false);
|
||||
try
|
||||
{
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
|
||||
{
|
||||
ActivityCode = "dev", PerformerId = "alice", ClientId = "spoofed",
|
||||
Consent = true, EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = new { Address = "Notification test", Latitude = 48.0, Longitude = 2.0 },
|
||||
Reason = "Demande notifiée", Status = QueryStatus.Inserted
|
||||
}, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var query = await response.Content.ReadFromJsonAsync<RdvQuery>(TestContext.Current.CancellationToken);
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notification = Assert.Single(db.Notification);
|
||||
Assert.Equal("user/alice", notification.Target);
|
||||
Assert.Equal($"rdv/{query!.Id}", notification.tag);
|
||||
Assert.Contains("bob", notification.title);
|
||||
var delivery = _fixture.Services.GetRequiredService<RecordingMessageDelivery>();
|
||||
Assert.Equal("alice@example.test", Assert.Single(delivery.Emails).Recipient);
|
||||
var push = Assert.Single(delivery.Pushes);
|
||||
Assert.Equal("rdv-alice", push.ConnectionId);
|
||||
Assert.Equal("push", push.Method);
|
||||
Assert.Equal("/topic/RdvQuery", push.Arguments[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
manager.OnDisconnected("rdv-alice");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
public async Task PostQuery_respects_performer_contact_preferences(bool acceptsNotifications, bool acceptsPublicContact)
|
||||
{
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var performer = db.Performers.Single(p => p.PerformerId == "alice");
|
||||
performer.AcceptNotifications = acceptsNotifications;
|
||||
performer.AcceptPublicContact = acceptsPublicContact;
|
||||
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
using var http = NewClient("bob");
|
||||
var response = await http.PostAsJsonAsync("/api/v1/billing/Rdv", new
|
||||
{
|
||||
ActivityCode = "dev", PerformerId = "alice", Consent = true,
|
||||
EventDate = DateTime.UtcNow.AddDays(1),
|
||||
Location = new { Address = "Contact refused", Latitude = 48.0, Longitude = 2.0 },
|
||||
Reason = "Demande", Status = QueryStatus.Inserted
|
||||
}, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
Assert.Empty(assertDb.Notification);
|
||||
Assert.Empty(_fixture.Services.GetRequiredService<RecordingMessageDelivery>().Emails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Billing_rdv_route_supports_crud()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue