feat/estimate #50

Merged
notazof merged 27 commits from feat/estimate into main 2026-09-04 22:07:17 +01:00
8 changed files with 5096 additions and 21 deletions
Showing only changes of commit b642cbf067 - Show all commits

set your activity country
All checks were successful
Forgejo Release / release (push) Successful in 10m6s

Paul Schneider 2026-08-31 05:40:34 +01:00
Signed by: notazof
GPG key ID: 1DD5D838E5343B06

View file

@ -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);
}
}

View file

@ -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]

File diff suppressed because it is too large Load diff

View file

@ -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");
}
}
}

View file

@ -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")

View file

@ -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>
}

View file

@ -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")]

View file

@ -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;