Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend

- GeradoresService: cada tipo passa a ser um IGenerator (NIF, NIPC, NISS, CC,
  passaporte, IBAN, NIB, cartões, referência MB, pessoa, telefone, código postal,
  email, matrícula, EAN, UUID) registado em GeneratorRegistry; remove DAL/EF
- GeradoresWS: minimal APIs em /api/v1 com OpenAPI + Scalar e health check
- UnitTest: testes de ida e volta, valores conhecidos, detecção e API
- geradoresfe: UI construída a partir dos metadados da API, validador livre,
  exportação CSV/JSON/TXT, nginx com API_UPSTREAM configurável
- Docker, docker-compose e Jenkinsfile actualizados

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:51:39 +01:00
parent 0f764b1239
commit d7ea8a9b9e
119 changed files with 7764 additions and 2502 deletions

66
UnitTest/ApiTests.cs Normal file
View File

@@ -0,0 +1,66 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
namespace UnitTest;
public class ApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Lists_generators()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators");
Assert.Contains(json.EnumerateArray(), g => g.GetProperty("id").GetString() == "nif");
}
[Fact]
public async Task Generates_batch_with_options()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/generate?count=25&tipo=5&seed=1");
var items = json.GetProperty("items").EnumerateArray().ToList();
Assert.Equal(25, items.Count);
Assert.All(items, i => Assert.StartsWith("5", i.GetProperty("value").GetString()));
}
[Theory]
[InlineData("/api/v1/generators/nif/generate?count=0")]
[InlineData("/api/v1/generators/nif/generate?count=5000")]
[InlineData("/api/v1/generators/nif/generate?tipo=xpto")]
[InlineData("/api/v1/generators/nif/generate?desconhecida=1")]
public async Task Bad_requests(string url)
{
var response = await _client.GetAsync(url);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Unknown_generator_is_404()
{
var response = await _client.GetAsync("/api/v1/generators/xpto/generate");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Validates_value()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/generators/nif/validate?value=123456789");
Assert.True(json.GetProperty("isValid").GetBoolean());
}
[Fact]
public async Task Detects_value()
{
var json = await _client.GetFromJsonAsync<JsonElement>("/api/v1/validate?value=" + Uri.EscapeDataString("PT50 0002 0123 1234 5678 9015 4"));
Assert.Equal("iban", json.GetProperty("matches")[0].GetProperty("generatorId").GetString());
}
[Fact]
public async Task OpenApi_and_health_are_exposed()
{
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/openapi/v1.json")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await _client.GetAsync("/health")).StatusCode);
}
}

View File

@@ -1,38 +0,0 @@
using GeradoresService;
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace UnitTest
{
public class DB
{
private readonly GeradoresContext _context;
public DB()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
// Obter a connection string a partir do arquivo de configuração
var connectionString = configuration.GetConnectionString("GeradoresConnection");
// Configurar o DbContext para usar o InMemoryDatabase
var options = new DbContextOptionsBuilder<GeradoresContext>()
.UseSqlServer(connectionString)
.Options;
_context = new GeradoresContext(options);
}
[Fact]
public void GenerateNIF()
{
var nif = new NIF(_context);
//var gerenatednif = nif.Generate("2");
//Assert.NotEmpty(gerenatednif);
}
}
}

47
UnitTest/DetectTests.cs Normal file
View File

@@ -0,0 +1,47 @@
using GeradoresService;
namespace UnitTest;
public class DetectTests
{
private static readonly GeneratorRegistry Registry = new();
[Fact]
public void Detects_nif_first()
{
var matches = Registry.Detect("123456789");
Assert.NotEmpty(matches);
Assert.Equal("nif", matches[0].Generator.Id);
Assert.True(matches[0].Result.IsValid);
}
[Fact]
public void Detects_iban()
{
var matches = Registry.Detect("PT50 0002 0123 1234 5678 9015 4");
Assert.Equal("iban", matches[0].Generator.Id);
}
[Fact]
public void Reports_near_miss_when_checksum_fails()
{
var matches = Registry.Detect("123456780");
Assert.Contains(matches, m => m.Generator.Id == "nif" && !m.Result.IsValid && m.Result.FormatMatch);
Assert.DoesNotContain(matches, m => m.Generator.Id == "nif" && m.Result.IsValid);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("isto não é nada")]
public void Nothing_detected(string value)
{
Assert.DoesNotContain(Registry.Detect(value), m => m.Result.IsValid);
}
[Fact]
public void Pessoa_is_never_detected()
{
Assert.DoesNotContain(Registry.Detect("João Silva"), m => m.Generator.Id == "pessoa");
}
}

View File

@@ -0,0 +1,107 @@
using GeradoresService;
using GeradoresService.Core;
namespace UnitTest;
/// <summary>
/// Para cada gerador e cada valor de cada opção, os valores gerados têm de passar no próprio validador.
/// </summary>
public class GeneratorRoundTripTests
{
private static readonly GeneratorRegistry Registry = new();
public static TheoryData<string, string, string> AllOptionValues()
{
var data = new TheoryData<string, string, string>();
foreach (var g in Registry.All)
{
if (g.Options.Count == 0)
data.Add(g.Id, "", "");
foreach (var o in g.Options)
foreach (var c in o.Choices)
data.Add(g.Id, o.Key, c.Value);
}
return data;
}
[Theory]
[MemberData(nameof(AllOptionValues))]
public void Generated_values_are_valid(string generatorId, string optionKey, string optionValue)
{
var generator = Registry.Find(generatorId)!;
var options = new Dictionary<string, string?>();
if (optionKey != "")
options[optionKey] = optionValue;
var items = Registry.Generate(generator, 300, options, seed: 42);
Assert.All(items, item => Assert.False(string.IsNullOrWhiteSpace(item.Value)));
if (!generator.CanValidate)
return;
foreach (var item in items)
{
var result = generator.Validate(item.Value);
Assert.True(result.IsValid, $"{generatorId} gerou '{item.Value}' que não valida: {result.Message}");
}
}
[Fact]
public void Pessoa_fields_are_all_valid()
{
var pessoa = Registry.Find("pessoa")!;
foreach (var item in Registry.Generate(pessoa, 200, new Dictionary<string, string?>(), seed: 7))
{
var f = item.Fields!;
Assert.True(Registry.Find("nif")!.Validate(f["NIF"]).IsValid);
Assert.True(Registry.Find("niss")!.Validate(f["NISS"]).IsValid);
Assert.True(Registry.Find("cc")!.Validate(f["Cartão de Cidadão"]).IsValid);
Assert.True(Registry.Find("iban")!.Validate(f["IBAN"]).IsValid);
Assert.True(Registry.Find("telefone")!.Validate(f["Telemóvel"]).IsValid);
Assert.True(Registry.Find("email")!.Validate(f["Email"]).IsValid);
Assert.True(Registry.Find("codigo-postal")!.Validate(f["Código postal"]).IsValid);
var age = DateTime.UtcNow.Year - DateOnly.Parse(f["Data de nascimento"]).Year;
Assert.InRange(age, 18, 81);
}
}
[Fact]
public void Same_seed_gives_same_values()
{
var nif = Registry.Find("nif")!;
var a = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
var b = Registry.Generate(nif, 20, new Dictionary<string, string?>(), seed: 123);
Assert.Equal(a.Select(x => x.Value), b.Select(x => x.Value));
}
[Fact]
public void Generated_values_are_not_all_equal()
{
var items = Registry.Generate(Registry.Find("nif")!, 50, new Dictionary<string, string?>());
Assert.True(items.Select(i => i.Value).Distinct().Count() > 40);
}
[Fact]
public void Invalid_option_value_throws()
{
var nif = Registry.Find("nif")!;
Assert.Throws<ArgumentException>(() =>
Registry.Generate(nif, 1, new Dictionary<string, string?> { ["tipo"] = "4" }));
}
[Theory]
[InlineData(0)]
[InlineData(GeneratorRegistry.MaxBatch + 1)]
public void Count_out_of_range_throws(int count)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
Registry.Generate(Registry.Find("nif")!, count, new Dictionary<string, string?>()));
}
[Fact]
public void Ids_are_unique()
{
Assert.Equal(Registry.All.Count, Registry.All.Select(g => g.Id).Distinct().Count());
}
}

View File

@@ -1,19 +0,0 @@
using GeradoresService;
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace UnitTest
{
public class Geradores
{
private readonly GeradoresContext _context;
[Fact]
public void GenerateCC()
{
var cc = CartaoCidadao.Generate();
Console.WriteLine(cc);
}
}
}

View File

@@ -0,0 +1,168 @@
using GeradoresService;
namespace UnitTest;
/// <summary>Valores conhecidos (exemplos oficiais / números de teste públicos).</summary>
public class KnownValuesTests
{
private static readonly GeneratorRegistry Registry = new();
private static void AssertValid(string id, string value) =>
Assert.True(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser válido.");
private static void AssertInvalid(string id, string value) =>
Assert.False(Registry.Find(id)!.Validate(value).IsValid, $"{id}: '{value}' devia ser inválido.");
[Theory]
[InlineData("123456789")]
[InlineData("PT 123 456 789")]
[InlineData("100000010")] // resto 0 → dígito 0 (falhava na versão antiga)
[InlineData("500000000")] // resto 1 → dígito 0
[InlineData("451234561")] // prefixo 45
public void Nif_valid(string nif) => AssertValid("nif", nif);
[Theory]
[InlineData("123456780")]
[InlineData("100000011")]
[InlineData("12345678")]
[InlineData("412345678")] // prefixo 4 só é válido como 45
[InlineData("abcdefghi")]
public void Nif_invalid(string nif) => AssertInvalid("nif", nif);
[Fact]
public void Nipc_rejects_individual_nif()
{
AssertValid("nipc", "500000000");
AssertInvalid("nipc", "123456789");
}
[Fact]
public void Nif_reports_expected_check_digit()
{
var result = Registry.Find("nif")!.Validate("123456780");
Assert.True(result.FormatMatch);
Assert.Contains("esperado 9", result.Message);
}
[Theory]
[InlineData("12345678902")]
[InlineData("20000000001")]
public void Niss_valid(string niss) => AssertValid("niss", niss);
[Theory]
[InlineData("12345678903")]
[InlineData("32345678902")]
[InlineData("1234567890")]
public void Niss_invalid(string niss) => AssertInvalid("niss", niss);
[Theory]
[InlineData("00000000 0 ZZ4")] // exemplo da especificação da AMA
[InlineData("000000000zz4")]
public void Cc_valid(string cc) => AssertValid("cc", cc);
[Theory]
[InlineData("00000000 0 ZZ5")]
[InlineData("00000000 0 ZZ")]
public void Cc_invalid(string cc) => AssertInvalid("cc", cc);
[Theory]
[InlineData("PT50000201231234567890154")] // exemplo do Banco de Portugal
[InlineData("PT50 0002 0123 1234 5678 9015 4")]
[InlineData("GB82 WEST 1234 5698 7654 32")]
[InlineData("DE89370400440532013000")]
public void Iban_valid(string iban) => AssertValid("iban", iban);
[Theory]
[InlineData("PT50000201231234567890155")]
[InlineData("PT51000201231234567890154")]
[InlineData("PT5000020123123456789015")]
[InlineData("GB82WEST12345698765433")]
public void Iban_invalid(string iban) => AssertInvalid("iban", iban);
[Fact]
public void Nib_valid() => AssertValid("nib", "0002 0123 12345678901 54");
[Theory]
[InlineData("4111 1111 1111 1111")]
[InlineData("5555555555554444")]
[InlineData("2223003122003222")]
[InlineData("378282246310005")]
public void Card_valid(string card) => AssertValid("cartao-credito", card);
[Theory]
[InlineData("4111111111111112")]
[InlineData("411111")]
public void Card_invalid(string card) => AssertInvalid("cartao-credito", card);
[Theory]
[InlineData("4006381333931")]
[InlineData("96385074")]
public void Ean_valid(string ean) => AssertValid("ean", ean);
[Fact]
public void Ean_invalid() => AssertInvalid("ean", "4006381333932");
[Theory]
[InlineData("AA-00-AA")]
[InlineData("00 AB 00")]
[InlineData("12-34-XZ")]
[InlineData("ab0012")]
public void Matricula_valid(string m) => AssertValid("matricula", m);
[Theory]
[InlineData("AK-00-AA")]
[InlineData("A1-00-AA")]
[InlineData("AA-00-AA-00")]
public void Matricula_invalid(string m) => AssertInvalid("matricula", m);
[Theory]
[InlineData("+351 912 345 678")]
[InlineData("00351213456789")]
[InlineData("800 123 456")]
public void Telefone_valid(string t) => AssertValid("telefone", t);
[Theory]
[InlineData("942 345 678")]
[InlineData("91234567")]
public void Telefone_invalid(string t) => AssertInvalid("telefone", t);
[Theory]
[InlineData("1000-001")]
[InlineData("4000 123")]
public void CodigoPostal_valid(string cp) => AssertValid("codigo-postal", cp);
[Theory]
[InlineData("0100-001")]
[InlineData("1000-01")]
public void CodigoPostal_invalid(string cp) => AssertInvalid("codigo-postal", cp);
[Theory]
[InlineData("joao.silva@example.com")]
public void Email_valid(string e) => AssertValid("email", e);
[Theory]
[InlineData("joao.silva")]
[InlineData("joao@silva")]
[InlineData("jo ao@example.com")]
public void Email_invalid(string e) => AssertInvalid("email", e);
[Fact]
public void Multibanco_roundtrip_detects_tampering()
{
var mb = Registry.Find("multibanco")!;
var value = mb.Generate(new GeradoresService.Core.GeneratorContext(new Random(1), new Dictionary<string, string?>())).Value;
Assert.True(mb.Validate(value).IsValid);
var parts = value.Split(' ');
var amount = decimal.Parse(parts[^1], System.Globalization.CultureInfo.InvariantCulture) + 1;
var tampered = string.Join(' ', parts[..^1]) + " " + amount.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
Assert.False(mb.Validate(tampered).IsValid);
}
[Fact]
public void Uuid_versions()
{
Assert.Contains("versão 4", Registry.Find("uuid")!.Validate("f47ac10b-58cc-4372-a567-0e02b2c3d479").Message);
AssertInvalid("uuid", "not-a-uuid");
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -10,34 +10,14 @@
</PropertyGroup>
<ItemGroup>
<None Remove="appsettings.Development.json" />
<None Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -45,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\GeradoresService\GeradoresService.csproj" />
<ProjectReference Include="..\GeradoresWS\GeradoresWS.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
}
}