feat/estimate #50
8 changed files with 5096 additions and 21 deletions
commit
b642cbf067
|
|
@ -0,0 +1,70 @@
|
|||
namespace Yavsc.Org.Tests.NonRegression;
|
||||
|
||||
/// <summary>
|
||||
/// Guard rails for the SetActivity performer settings page:
|
||||
/// - countries are provided to the ComboBox via ViewBag.Countries
|
||||
/// - client-side SIREN validation is wired to country-specific regex rules
|
||||
/// - controller exposes the validation catalog to the view
|
||||
/// </summary>
|
||||
public class SetActivityCountryValidationViewTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetActivity_cshtml_binds_country_combo_to_ViewBag_Countries()
|
||||
{
|
||||
var content = File.ReadAllText(ResolveSetActivityViewPath());
|
||||
|
||||
Assert.Contains("asp-for=\"ExerciseCountryCode\"", content);
|
||||
Assert.Contains("asp-items=\"ViewBag.Countries\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetActivity_cshtml_contains_country_aware_siren_javascript_validation()
|
||||
{
|
||||
var content = File.ReadAllText(ResolveSetActivityViewPath());
|
||||
|
||||
Assert.Contains("$.validator.addMethod(\"sirenByCountry\"", content);
|
||||
Assert.Contains("new RegExp(selectedRule.regex)", content);
|
||||
Assert.Contains("const countryInput = $(\"#ExerciseCountryCode\")", content);
|
||||
Assert.Contains("const sirenInput = $(\"#SIREN\")", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManageController_exposes_countries_and_country_validation_rules_to_view()
|
||||
{
|
||||
var content = File.ReadAllText(ResolveManageControllerPath());
|
||||
|
||||
Assert.Contains("ViewBag.Countries = countries;", content);
|
||||
Assert.Contains("ViewBag.CountryCodeValidationRules = PerformerCodeInputValidationCatalog.Rules;", content);
|
||||
Assert.Contains("ModelState.Remove(nameof(PerformerProfile.ExerciseCountryCode));", content);
|
||||
}
|
||||
|
||||
private static string ResolveSetActivityViewPath()
|
||||
{
|
||||
return ResolveFromWorkspaceRoot(
|
||||
"src", "Yavsc.Org", "Views", "Manage", "SetActivity.cshtml");
|
||||
}
|
||||
|
||||
private static string ResolveManageControllerPath()
|
||||
{
|
||||
return ResolveFromWorkspaceRoot(
|
||||
"src", "Yavsc.Org", "Controllers", "Accounting", "ManageController.cs");
|
||||
}
|
||||
|
||||
private static string ResolveFromWorkspaceRoot(params string[] relative)
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 10 && dir is not null; i++)
|
||||
{
|
||||
var candidate = Path.Combine(new[] { dir }.Concat(relative).ToArray());
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not locate test target from " + AppContext.BaseDirectory);
|
||||
}
|
||||
}
|
||||
|
|
@ -568,7 +568,17 @@ namespace Yavsc.Controllers
|
|||
{
|
||||
var user = GetCurrentUserAsync().Result;
|
||||
var uid = user.Id;
|
||||
var postedCountryCode = model.ExerciseCountryCode;
|
||||
model.ExerciseCountryCode = NormalizeCountryCodeOrDefault(model.ExerciseCountryCode);
|
||||
|
||||
// Model binding validated the raw posted payload before entering
|
||||
// the action. If country was empty, we fallback to "fr" above,
|
||||
// so remove the stale min-length error attached to the empty value.
|
||||
if (string.IsNullOrWhiteSpace(postedCountryCode))
|
||||
{
|
||||
ModelState.Remove(nameof(PerformerProfile.ExerciseCountryCode));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
|
|
@ -636,7 +646,7 @@ namespace Yavsc.Controllers
|
|||
return View(model);
|
||||
}
|
||||
|
||||
private static string NormalizeCountryCodeOrDefault(string? code)
|
||||
private static string NormalizeCountryCodeOrDefault(string code)
|
||||
{
|
||||
var normalized = PerformerCodeInputValidationCatalog.NormalizeCountryCode(code);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
|
|
@ -647,14 +657,17 @@ namespace Yavsc.Controllers
|
|||
return normalized;
|
||||
}
|
||||
|
||||
private void SetExerciseCountries(string? selectedCountryCode)
|
||||
private void SetExerciseCountries(string selectedCountryCode)
|
||||
{
|
||||
var selected = NormalizeCountryCodeOrDefault(selectedCountryCode);
|
||||
ViewBag.ExerciseCountries = new SelectList(
|
||||
var countries = new SelectList(
|
||||
PerformerCodeInputValidationCatalog.Countries,
|
||||
nameof(Country.Code),
|
||||
nameof(Country.DisplayName),
|
||||
selected);
|
||||
ViewBag.ExerciseCountries = countries;
|
||||
ViewBag.Countries = countries;
|
||||
ViewBag.CountryCodeValidationRules = PerformerCodeInputValidationCatalog.Rules;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
|
|
|
|||
4734
src/Yavsc.Org/Migrations/20260831040104_AddPerformerCountryValidation.Designer.cs
generated
Normal file
4734
src/Yavsc.Org/Migrations/20260831040104_AddPerformerCountryValidation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,97 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace Yavsc.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPerformerCountryValidation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExerciseCountryCode",
|
||||
table: "Performers",
|
||||
type: "character varying(2)",
|
||||
maxLength: 2,
|
||||
nullable: false,
|
||||
defaultValue: "fr");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Countries",
|
||||
columns: table => new
|
||||
{
|
||||
Code = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
|
||||
DisplayName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Countries", x => x.Code);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PerformerCodeInputValidations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
|
||||
RegularExpression = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ErrorMessage = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PerformerCodeInputValidations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PerformerCodeInputValidations_Countries_CountryCode",
|
||||
column: x => x.CountryCode,
|
||||
principalTable: "Countries",
|
||||
principalColumn: "Code",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Countries",
|
||||
columns: new[] { "Code", "DisplayName" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ "en", "England" },
|
||||
{ "fr", "France" },
|
||||
{ "pt", "Portugal" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "PerformerCodeInputValidations",
|
||||
columns: new[] { "Id", "CountryCode", "ErrorMessage", "RegularExpression" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1L, "fr", "Le code FR doit contenir entre 9 et 14 chiffres.", "^[0-9]{9,14}$" },
|
||||
{ 2L, "en", "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques.", "^[A-Za-z0-9]{8,14}$" },
|
||||
{ 3L, "pt", "Le code PT doit contenir exactement 9 chiffres.", "^[0-9]{9}$" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PerformerCodeInputValidations_CountryCode",
|
||||
table: "PerformerCodeInputValidations",
|
||||
column: "CountryCode");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PerformerCodeInputValidations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Countries");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExerciseCountryCode",
|
||||
table: "Performers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1762,19 +1762,6 @@ namespace Yavsc.Migrations
|
|||
b.ToTable("Color");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Forms.Form", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Form");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Haircut.BrusherProfile", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
|
|
@ -2997,6 +2984,92 @@ namespace Yavsc.Migrations
|
|||
b.ToTable("CommandForm");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Workflow.Country", b =>
|
||||
{
|
||||
b.Property<string>("Code")
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Code");
|
||||
|
||||
b.ToTable("Countries");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Code = "fr",
|
||||
DisplayName = "France"
|
||||
},
|
||||
new
|
||||
{
|
||||
Code = "en",
|
||||
DisplayName = "England"
|
||||
},
|
||||
new
|
||||
{
|
||||
Code = "pt",
|
||||
DisplayName = "Portugal"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("CountryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<string>("ErrorMessage")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("RegularExpression")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CountryCode");
|
||||
|
||||
b.ToTable("PerformerCodeInputValidations");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1L,
|
||||
CountryCode = "fr",
|
||||
ErrorMessage = "Le code FR doit contenir entre 9 et 14 chiffres.",
|
||||
RegularExpression = "^[0-9]{9,14}$"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 2L,
|
||||
CountryCode = "en",
|
||||
ErrorMessage = "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques.",
|
||||
RegularExpression = "^[A-Za-z0-9]{8,14}$"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = 3L,
|
||||
CountryCode = "pt",
|
||||
ErrorMessage = "Le code PT doit contenir exactement 9 chiffres.",
|
||||
RegularExpression = "^[0-9]{9}$"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b =>
|
||||
{
|
||||
b.Property<string>("PerformerId")
|
||||
|
|
@ -3011,6 +3084,11 @@ namespace Yavsc.Migrations
|
|||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ExerciseCountryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2)
|
||||
.HasColumnType("character varying(2)");
|
||||
|
||||
b.Property<int?>("MaxDailyCost")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
|
@ -4321,6 +4399,17 @@ namespace Yavsc.Migrations
|
|||
b.Navigation("Context");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerCodeInputValidation", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Workflow.Country", "Country")
|
||||
.WithMany()
|
||||
.HasForeignKey("CountryCode")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Country");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Yavsc.Models.Workflow.PerformerProfile", b =>
|
||||
{
|
||||
b.HasOne("Yavsc.Models.Relationship.Location", "OrganizationAddress")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@model PerformerProfile
|
||||
@using System.Text.Json
|
||||
@{ ViewBag.Title = "Your performer profile"; }
|
||||
@section header {
|
||||
<style>
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
|
||||
<label asp-for="ExerciseCountryCode" class="col-md-2 control-label">Pays d'exercice</label>
|
||||
<div class="col-md-10">
|
||||
<select asp-for="ExerciseCountryCode" asp-items="ViewBag.ExerciseCountries" class="form-control"></select>
|
||||
<select asp-for="ExerciseCountryCode" asp-items="ViewBag.Countries" class="form-control"></select>
|
||||
|
||||
<span asp-validation-for="ExerciseCountryCode" class="text-danger"></span>
|
||||
</div>
|
||||
|
|
@ -125,6 +126,59 @@
|
|||
addrValidationId: 'AddressError',
|
||||
formValidId: 'ValidationSummary',
|
||||
locComboId: 'LocationCombo'})
|
||||
|
||||
const rawCountryRules = @Html.Raw(JsonSerializer.Serialize(ViewBag.CountryCodeValidationRules));
|
||||
const countryRules = {};
|
||||
(rawCountryRules || []).forEach(function (rule) {
|
||||
if (!rule || !rule.CountryCode || !rule.RegularExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
countryRules[String(rule.CountryCode).toLowerCase()] = {
|
||||
regex: String(rule.RegularExpression),
|
||||
message: rule.ErrorMessage
|
||||
? String(rule.ErrorMessage)
|
||||
: "Code entreprise invalide pour le pays selectionne."
|
||||
};
|
||||
});
|
||||
|
||||
const countryInput = $("#ExerciseCountryCode");
|
||||
const sirenInput = $("#SIREN");
|
||||
let sirenValidationMessage = "Code entreprise invalide pour le pays selectionne.";
|
||||
|
||||
$.validator.addMethod("sirenByCountry", function (value, element) {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const selectedCountry = (countryInput.val() || "").toString().toLowerCase();
|
||||
const selectedRule = countryRules[selectedCountry];
|
||||
if (!selectedRule) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const regex = new RegExp(selectedRule.regex);
|
||||
const isValid = regex.test(value.trim());
|
||||
if (!isValid) {
|
||||
sirenValidationMessage = selectedRule.message;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}, function () {
|
||||
return sirenValidationMessage;
|
||||
});
|
||||
|
||||
if (sirenInput.length > 0) {
|
||||
sirenInput.rules("add", {
|
||||
sirenByCountry: true
|
||||
});
|
||||
}
|
||||
|
||||
countryInput.on("change", function () {
|
||||
if (sirenInput.val()) {
|
||||
sirenInput.valid();
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ namespace Yavsc.Models.Workflow
|
|||
public virtual List<UserActivity> Activity { get; set; }
|
||||
|
||||
[Required, Display(Name = "Country of exercise")]
|
||||
[MinLength(2), MaxLength(2)]
|
||||
[RegularExpression("^[A-Za-z]{2}$", ErrorMessage = "Country code must be a 2-letter code.")]
|
||||
public string ExerciseCountryCode { get; set; } = "fr";
|
||||
|
||||
[Required,YaStringLength(14),Display(Name="SIREN")]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ public abstract class WebHostFixture : IBackendFixture
|
|||
private static readonly object _sync = new object();
|
||||
private static WebApplication? _app;
|
||||
private static bool _isInitialized;
|
||||
private static int _instanceCount;
|
||||
private static readonly List<string> _sharedAddresses = new();
|
||||
private static IServiceProvider? _sharedServices;
|
||||
|
||||
|
|
@ -67,6 +68,7 @@ public abstract class WebHostFixture : IBackendFixture
|
|||
InitializeAsync().GetAwaiter().GetResult();
|
||||
_isInitialized = true;
|
||||
}
|
||||
_instanceCount++;
|
||||
CopySharedState();
|
||||
CopySpecialisedSharedState();
|
||||
}
|
||||
|
|
@ -75,6 +77,8 @@ public abstract class WebHostFixture : IBackendFixture
|
|||
private void CopySharedState()
|
||||
{
|
||||
Addresses = _sharedAddresses.ToArray();
|
||||
IsInitialized = _isInitialized;
|
||||
App = _app ?? App;
|
||||
}
|
||||
|
||||
/// <summary>Hook for specialisations to copy any other shared
|
||||
|
|
@ -149,11 +153,25 @@ public abstract class WebHostFixture : IBackendFixture
|
|||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
throw new InvalidOperationException("Cannot tear down a fixture that has not been initialized.");
|
||||
this.App.StopAsync().GetAwaiter().GetResult();
|
||||
if (_instanceCount > 0)
|
||||
{
|
||||
_instanceCount--;
|
||||
}
|
||||
|
||||
IsInitialized = false;
|
||||
|
||||
if (_instanceCount > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_app?.StopAsync().GetAwaiter().GetResult();
|
||||
_app = null;
|
||||
_isInitialized = false;
|
||||
_sharedAddresses.Clear();
|
||||
_sharedServices = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue