diff --git a/.editorconfig b/.editorconfig index 9be589b..c448aa7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,3 +15,56 @@ insert_final_newline = true # Windows-only batch/cmd scripts must stay CRLF. [*.{cmd,bat}] end_of_line = crlf + +# --------------------------------------------------------------------------- +# C#: indentação e analisadores .NET (ROADMAP Fase 5) +# --------------------------------------------------------------------------- +[*.cs] +indent_style = space +indent_size = 4 + +# Regras de design/perf/estilo rebaixadas para suggestion: corrigi-las exige +# mudanças de código que ficam para PRs incrementais dedicados (visíveis na +# IDE, sem quebrar o build). Subir a severidade conforme forem resolvidas. +dotnet_diagnostic.CA1304.severity = suggestion +dotnet_diagnostic.CA1305.severity = suggestion +dotnet_diagnostic.CA1310.severity = suggestion +dotnet_diagnostic.CA1311.severity = suggestion +dotnet_diagnostic.CA1707.severity = suggestion +dotnet_diagnostic.CA1822.severity = suggestion +dotnet_diagnostic.CA1845.severity = suggestion +dotnet_diagnostic.CA1848.severity = suggestion +dotnet_diagnostic.CA1051.severity = suggestion +dotnet_diagnostic.CA2201.severity = suggestion + +# Criptografia fraca (MD5/SHA1/DES): usada apenas nos utilitários legados já +# marcados [Obsolete] em Codout.Framework.Common (Crypto). Não introduzir +# novos usos; remoção planejada para o próximo major. +dotnet_diagnostic.CA5350.severity = suggestion +dotnet_diagnostic.CA5351.severity = suggestion + +[tests/**.cs] +# Projetos de teste: convenções de naming xUnit (underscores) e padrões de +# teste tornam várias regras CA inadequadas. +dotnet_analyzer_diagnostic.category-Design.severity = none +dotnet_analyzer_diagnostic.category-Naming.severity = none +dotnet_analyzer_diagnostic.category-Performance.severity = none +dotnet_analyzer_diagnostic.category-Usage.severity = suggestion +dotnet_analyzer_diagnostic.category-Globalization.severity = none +dotnet_analyzer_diagnostic.category-Security.severity = none +dotnet_analyzer_diagnostic.category-Reliability.severity = suggestion + +[*.cs] +# Naming de API pública (renomear seria breaking change) e micro-otimizações: +# suggestion até serem tratados em PRs dedicados (idealmente no próximo major). +dotnet_diagnostic.CA1716.severity = suggestion +dotnet_diagnostic.CA1720.severity = suggestion +dotnet_diagnostic.CA1200.severity = suggestion +dotnet_diagnostic.CA1309.severity = suggestion +dotnet_diagnostic.CA1507.severity = suggestion +dotnet_diagnostic.CA1850.severity = suggestion +dotnet_diagnostic.CA1859.severity = suggestion +dotnet_diagnostic.CA1861.severity = suggestion +dotnet_diagnostic.CA1862.severity = suggestion +dotnet_diagnostic.CA1873.severity = suggestion +dotnet_diagnostic.CA2208.severity = suggestion diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0a9fb44 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + # Agrupa updates minor/patch num PR só; majors saem individualmente + # para revisão de breaking changes. + nuget-minor-patch: + update-types: ["minor", "patch"] + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7ae255a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + schedule: + - cron: "30 5 * * 1" + +jobs: + analyze: + name: Analyze (csharp) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: csharp + build-mode: manual + + - name: Build + run: | + dotnet restore Codout.Framework.sln + dotnet build Codout.Framework.sln --no-restore --configuration Release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:csharp" diff --git a/.github/workflows/core-release.yml b/.github/workflows/core-release.yml index 24cea42..f6c0c9e 100644 --- a/.github/workflows/core-release.yml +++ b/.github/workflows/core-release.yml @@ -36,7 +36,7 @@ env: DOTNET_NOLOGO: true DOTNET_CLI_TELEMETRY_OPTOUT: true CONFIGURATION: Release - TEST_PROJECT: NetCore/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj + TEST_PROJECT: tests/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj DEFAULT_PROJECTS: >- Codout.Framework.Data/Codout.Framework.Data.csproj Codout.Framework.Domain/Codout.Framework.Domain.csproj @@ -101,7 +101,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: codout-framework-core-nupkg - path: ./artifacts/*.nupkg + path: | + ./artifacts/*.nupkg + ./artifacts/*.snupkg if-no-files-found: error publish: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 382d37e..2f37c5f 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,5 +1,4 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# CI de PR/push: build + testes da solution completa. name: .NET @@ -9,6 +8,10 @@ on: pull_request: branches: [ "master" ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: @@ -19,10 +22,37 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage + - name: Coverage gate + # Ratchet: começa em 45% (cobertura atual ~47%) e deve SUBIR conforme a + # suíte crescer — nunca baixar. Falha o CI se a cobertura de linhas dos + # assemblies Codout.* cair abaixo do piso. + run: | + python3 - <<'EOF' + import glob, sys, xml.etree.ElementTree as ET + THRESHOLD = 45.0 + hit = total = 0 + for f in glob.glob('coverage/**/coverage.cobertura.xml', recursive=True): + for pkg in ET.parse(f).getroot().iter('package'): + if not pkg.get('name', '').startswith(('Codout', 'Softprime')): + continue + for line in pkg.iter('line'): + total += 1 + hit += int(line.get('hits', '0')) > 0 + pct = 100 * hit / total if total else 0.0 + print(f'Cobertura de linhas (Codout.*): {pct:.1f}% (piso: {THRESHOLD}%)') + sys.exit(0 if pct >= THRESHOLD else 1) + EOF + - name: Upload coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: ./coverage + retention-days: 14 diff --git a/.github/workflows/mass-release.yml b/.github/workflows/mass-release.yml index 4ac8d94..5f73f79 100644 --- a/.github/workflows/mass-release.yml +++ b/.github/workflows/mass-release.yml @@ -138,7 +138,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: codout-framework-all-nupkg - path: ./artifacts/*.nupkg + path: | + ./artifacts/*.nupkg + ./artifacts/*.snupkg if-no-files-found: error - name: Push to NuGet.org diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37faad5..63b9f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,7 +147,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ steps.resolve.outputs.package }}-nupkg - path: ./artifacts/*.nupkg + path: | + ./artifacts/*.nupkg + ./artifacts/*.snupkg if-no-files-found: error publish: diff --git a/CHANGELOG.md b/CHANGELOG.md index 729ab3b..acec688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,54 @@ A partir desta entrada cada pacote NuGet é versionado individualmente `Directory.Build.props`). As entradas abaixo identificam o pacote e a versão afetados. +## 2026-06-15 + +> Bump **minor** coordenado de todos os pacotes publicáveis, preparando o +> release das melhorias descritas em 2026-06-12 (testes, nullable, SourceLink, +> READMEs, package validation, correção de vulnerabilidade do Mongo). **Ainda +> não publicado** — as tags só serão criadas após merge na master e revisão. +> O `PackageValidationBaselineVersion` de cada pacote permanece apontando para +> a última versão publicada (6.3.0 / 6.4.0), garantindo a checagem de +> compatibilidade no `pack`. + +### Versionamento + +- `6.3.0 → 6.4.0`: Api, Api.Client, Api.Dto, Application, Common, DynamicLinq, Image.Extensions, Mailer, Mailer.AWS, Mailer.Razor, Mailer.SendGrid, Mongo, Multitenancy, Softprime.Multitenancy, NH, Security.Core, Security.Argon2, Security.Bcrypt, Security.Scrypt, Storage, Storage.Azure. +- `6.4.0 → 6.5.0`: Data, Domain, EF. +- `Codout.Framework.Mcp` segue o próprio ciclo (`mcp-release.yml`) e não foi bumpado aqui. + +## 2026-06-12 + +> Mudanças abaixo ainda **não publicadas** no NuGet.org — os bumps de versão +> acontecerão na próxima release de cada pacote. + +### Tests + +- Fase 4 do ROADMAP concluída: **921 testes** em 18 projetos sob `tests/`, cobrindo os 24 pacotes publicáveis (Common, Security, Image, Data, Domain, EF com SQLite, Mongo com mongod efêmero + replica set, NH com SQLite, Mailer + AWS/SendGrid/Razor com mocks e Razor real, Storage, Storage.Azure, DynamicLinq, Api.Dto, Application, Api.Client, Api, Multitenancy). Bugs pré-existentes encontrados foram **caracterizados** (testes documentam o comportamento atual, sem alterá-lo) e catalogados em `tests/FINDINGS-{A..E}.md` para triagem. + +### Build + +- `Directory.Build.props`: ligados globalmente `Nullable`, `TreatWarningsAsErrors`, analyzers .NET (`latest-recommended`), `GenerateDocumentationFile` (CS1591 suprimido até completar as docs por pacote), SourceLink (GitHub), símbolos `snupkg`, `PackageLicenseExpression MIT` e build determinístico em CI. `.editorconfig` calibra regras CA de estilo/perf como suggestion (subir severidade é trabalho incremental). +- **Package validation**: todos os 24 csproj publicáveis têm `EnablePackageValidation` + `PackageValidationBaselineVersion` apontando para a última versão no NuGet.org — o `pack` falha se a API pública quebrar vs. a baseline. A validação já reverteu duas quebras acidentais durante a anotação nullable (CP0001 em `LimitedList.Enumerator`, CP0021 em `JsonExtensions`). +- `Directory.Build.targets`: `README.md` de cada pasta de pacote é embarcado automaticamente como `PackageReadmeFile` no nupkg. +- Anotações **nullable** em todos os pacotes refletindo o contrato real (ex.: `IRepository.GetAsync`/`LoadAsync` agora declaram `Task`). Sem mudança de comportamento — apenas metadados; consumidores com nullable habilitado podem ver warnings novos (e verdadeiros). + +### Codout.Framework.Mongo (não publicado) + +#### Fixed +- `MongoDB.Driver` 3.7.0 → 3.9.0, eliminando vulnerabilidades conhecidas nos transitivos `SharpCompress` 0.30.1 (moderada) e `Snappier` 1.0.0 (alta). + +### Repository + +- Removidas as árvores legadas `NetFull/`, `NetCore/`, `src/NetCore/` (Cosmos/DocumentDB em `netcoreapp2.0`, EOL), `Codout.Framework.DP` (quebrado, referenciava `Codout.Framework.DAL` inexistente), `Shared/Codout.Framework.Shared.Commom` e `Shared.msbuild`. Nenhum desses projetos era publicado no NuGet (`IsPackable=false` ou fora de `.github/release-packages.json`); o histórico permanece no git. O typo "Commom" deixa de existir no repositório. +- Removido `appveyor.yml` (referenciava Visual Studio 2012; o pipeline real é GitHub Actions). +- `Codout.Framework.Api.Dto` e `Softprime.Multitenancy` adicionados à `Codout.Framework.sln`; `Softprime.Multitenancy` passou a usar `obj`/`bin` isolados para coexistir com `Codout.Multitenancy` na mesma pasta sem corromper o restore. +- Testes movidos para a pasta `tests/` na raiz e incluídos na solution — `dotnet test Codout.Framework.sln` (CI de PR e gate do `release.yml`) agora executa os testes de verdade. `core-release.yml` atualizado para o novo caminho. + +### Build + +- `dotnet.yml`: SDK do CI atualizado de 9.0.x para 10.0.x, alinhado ao `TargetFramework` `net10.0` do `Directory.Build.props`. + ## 2026-05-12 ### Build diff --git a/CLAUDE.md b/CLAUDE.md index bc25dde..50a1d88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,15 +57,11 @@ Quando o usuário pedir "gerar nova versão e publicar do pacote X" (ou equivale - **Codout.Framework.Mcp**: usa workflow próprio (`.github/workflows/mcp-release.yml`) com passo `--validate` específico do CLI. Tag deve ser `mcp-v` (NÃO use `mcp` como short-name no `release.yml` — está intencionalmente bloqueado por padrão de tag para evitar duplo release). - **Mass release** (todos os pacotes de uma vez via `.github/workflows/mass-release.yml`): só execute se o usuário pedir explicitamente "mass release" ou "publicar todos". Mesmo assim, oriente o usuário a disparar via Actions UI com `dry_run: true` primeiro e revisar os artifacts antes de re-disparar com `dry_run: false`. Não tente disparar pela CLI sem autorização explícita. -## Pacotes excluídos do release automatizado +## Projetos legados removidos -Os seguintes csproj têm `false` e **não** estão em `.github/release-packages.json`. Não tente publicá-los antes de modernizá-los: +Em 2026-06-12 as árvores legadas foram **removidas do repositório** (histórico preservado no git): `NetFull/`, `NetCore/`, `src/NetCore/` (Cosmos/DocumentDB), `Codout.Framework.DP`, `Shared/` e `Shared.msbuild`. Nenhum deles era publicado no NuGet. Se o usuário pedir por um deles (ex.: suporte a Cosmos), a recomendação é recriar do zero como pacote moderno (ex.: `Codout.Framework.Cosmos` com SDK `Microsoft.Azure.Cosmos`) — não recuperar o código antigo do histórico sem modernizá-lo. -- `Codout.Framework.DP` — implementa um `IRepository` antigo (sem `Where`, `AllReadOnly`, `WherePaged`, `Refresh`, overloads de `CancellationToken`, etc.) e referencia a pasta legada `Codout.Framework.DAL` que não existe mais. -- `src/NetCore/Codout.Framework.NetCore.Repository.Cosmos` — `netcoreapp2.0` (fora de suporte), SDK legado `Microsoft.Azure.DocumentDB.Core`, refs pra projetos `NetStandard.*` que sumiram do repo. -- `src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB` — idem. - -Se o usuário pedir pra publicar um deles, **avise que está deprecated** e pergunte se quer fazer o port completo antes (não tente packar como está — vai falhar). +Os shared projects ativos na raiz (`Codout.Framework.Api.Shared`, `Codout.Framework.Dto.Shared`) **não** são legados — são `.shproj` importados por Api, Api.Client, Api.Dto e Application. ## Cuidados ao usar `dotnet pack` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..672aa23 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contribuindo com o Codout.Framework + +Obrigado pelo interesse em contribuir! Este guia resume o essencial. + +## Antes de começar + +- Procure uma [issue existente](https://github.com/Codout/Codout.Framework/issues) + ou abra uma nova descrevendo o problema/proposta antes de PRs grandes. +- O repositório usa UTF-8 (sem BOM), LF e newline final — já configurado em + `.editorconfig`. + +## Fluxo + +1. Faça um fork e crie uma branch: `git checkout -b feature/minha-mudanca` +2. Implemente a mudança **com testes** (os projetos de teste ficam em `tests/`) +3. Garanta build e testes verdes: + ```bash + dotnet build Codout.Framework.sln + dotnet test Codout.Framework.sln + ``` +4. Abra um Pull Request descrevendo motivação e impacto + +## Commits + +Seguimos [Conventional Commits](https://www.conventionalcommits.org/pt-br/): +`fix:`, `feat:`, `chore:`, `docs:`, `ci:`, `refactor:`, `test:` — com escopo +opcional pelo nome curto do pacote (ex.: `fix(ef): ...`). Mensagens em modo +imperativo, primeira linha com até 72 caracteres. + +## Versionamento e releases + +- Cada pacote tem `` no próprio `.csproj` e segue + [SemVer](https://semver.org) — não versione pacotes que sua mudança não toca. +- O CHANGELOG.md é por pacote, sob a data da mudança. +- Releases são disparados por tag (`-v`) e publicados no + NuGet.org via GitHub Actions. O mapeamento de nomes curtos está em + `.github/release-packages.json`. Criação de tags é responsabilidade dos + mantenedores. + +## Compatibilidade + +- Mudanças que quebram API pública exigem bump **major** e destaque no + CHANGELOG. +- Não remova membros `[Obsolete]` fora de um major. + +## Licença + +Ao contribuir, você concorda que sua contribuição será licenciada sob a +[MIT License](LICENSE). diff --git a/Codout.DynamicLinq/Aggregator.cs b/Codout.DynamicLinq/Aggregator.cs index fe4faa6..813e06f 100644 --- a/Codout.DynamicLinq/Aggregator.cs +++ b/Codout.DynamicLinq/Aggregator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -16,20 +16,20 @@ public class Aggregator /// Gets or sets the name of the aggregated field (property). /// [DataMember(Name = "field")] - public string Field { get; set; } + public string Field { get; set; } = null!; /// /// Gets or sets the aggregate. /// [DataMember(Name = "aggregate")] - public string Aggregate { get; set; } + public string Aggregate { get; set; } = null!; /// /// Get MethodInfo. /// /// Specifies the type of querable data. /// A MethodInfo for field. - public MethodInfo MethodInfo(Type type) + public MethodInfo? MethodInfo(Type type) { if (type == null) throw new ArgumentNullException(nameof(type), "Type cannot be null."); @@ -48,8 +48,8 @@ public MethodInfo MethodInfo(Type type) case "average": case "sum": return GetMethod(ConvertTitleCase(Aggregate), - ((Func)GetType().GetMethod("SumAvgFunc", BindingFlags.Static | BindingFlags.NonPublic) - .MakeGenericMethod(propType).Invoke(null, null)) + ((Func)GetType().GetMethod("SumAvgFunc", BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(propType).Invoke(null, null)!) .GetMethodInfo(), 1).MakeGenericMethod(type); case "count": return GetMethod(ConvertTitleCase(Aggregate), @@ -81,9 +81,9 @@ private static MethodInfo GetMethod(string methodName, MethodInfo methodTypes, i where method.Name == methodName && genericArguments.Length == genericArgumentsCount && parameters.Select(p => p.ParameterType) - .SequenceEqual((Type[])methodTypes.Invoke(null, genericArguments)) + .SequenceEqual((Type[])methodTypes.Invoke(null, genericArguments)!) select method; - return methods.FirstOrDefault(); + return methods.FirstOrDefault()!; } private static Func CountNullableFunc() @@ -140,4 +140,4 @@ private static Type[] SumAvgDelegate(Type t) typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(t, typeof(TU))) ]; } -} \ No newline at end of file +} diff --git a/Codout.DynamicLinq/Codout.DynamicLinq.csproj b/Codout.DynamicLinq/Codout.DynamicLinq.csproj index 71c60b8..56ccd4c 100644 --- a/Codout.DynamicLinq/Codout.DynamicLinq.csproj +++ b/Codout.DynamicLinq/Codout.DynamicLinq.csproj @@ -1,13 +1,15 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Wrapper para datasource de lista paginada Codout;Framework;DynamicLinq - diff --git a/Codout.DynamicLinq/DataSourceRequest.cs b/Codout.DynamicLinq/DataSourceRequest.cs index d43da17..694f0cf 100644 --- a/Codout.DynamicLinq/DataSourceRequest.cs +++ b/Codout.DynamicLinq/DataSourceRequest.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Codout.DynamicLinq; @@ -17,20 +17,20 @@ public class DataSourceRequest /// /// Specifies the requested sort order. /// - public IEnumerable Sort { get; set; } + public IEnumerable? Sort { get; set; } /// /// Specifies the requested filter. /// - public Filter Filter { get; set; } + public Filter? Filter { get; set; } /// /// Specifies the requested grouping . /// - public IEnumerable Group { get; set; } + public IEnumerable? Group { get; set; } /// /// Specifies the requested aggregators. /// - public IEnumerable Aggregate { get; set; } -} \ No newline at end of file + public IEnumerable? Aggregate { get; set; } +} diff --git a/Codout.DynamicLinq/DataSourceResult.cs b/Codout.DynamicLinq/DataSourceResult.cs index 258398a..1be9273 100644 --- a/Codout.DynamicLinq/DataSourceResult.cs +++ b/Codout.DynamicLinq/DataSourceResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Runtime.Serialization; @@ -13,17 +13,17 @@ public class DataSourceResult /// /// Represents a single page of processed data. /// - public object Data { get; set; } + public object? Data { get; set; } /// /// Represents a single page of processed grouped data. /// - public object Groups { get; set; } + public object? Groups { get; set; } /// /// Represents a requested aggregates. /// - public object Aggregates { get; set; } + public object? Aggregates { get; set; } /// /// The total number of records available. @@ -33,7 +33,7 @@ public class DataSourceResult /// /// Represents error information from server-side. /// - public object Errors { get; set; } + public object? Errors { get; set; } /// /// Used by the KnownType attribute which is required for WCF serialization support @@ -45,4 +45,4 @@ private static Type[] GetKnownTypes() .FirstOrDefault(a => a.FullName != null && a.FullName.StartsWith("DynamicClasses")); return assembly == null ? [] : assembly.GetTypes().Where(t => t.Name.StartsWith("DynamicClass")).ToArray(); } -} \ No newline at end of file +} diff --git a/Codout.DynamicLinq/EnumerableExtensions.cs b/Codout.DynamicLinq/EnumerableExtensions.cs index f041934..dd7673f 100644 --- a/Codout.DynamicLinq/EnumerableExtensions.cs +++ b/Codout.DynamicLinq/EnumerableExtensions.cs @@ -3,6 +3,10 @@ using System.Linq; using System.Linq.Dynamic.Core; +// CA1829: Count() mantido como está para preservar o comportamento original +// byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1829 + namespace Codout.DynamicLinq; public static class EnumerableExtensions @@ -57,4 +61,4 @@ public static dynamic GroupByMany(this IEnumerable elements, // If there are not more group selectors return data return elements; } -} \ No newline at end of file +} diff --git a/Codout.DynamicLinq/Filter.cs b/Codout.DynamicLinq/Filter.cs index 9d5aaf6..b561c86 100644 --- a/Codout.DynamicLinq/Filter.cs +++ b/Codout.DynamicLinq/Filter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -54,31 +54,31 @@ public class Filter /// Gets or sets the name of the sorted field (property). Set to null if the Filters property is set. /// [DataMember(Name = "field")] - public string Field { get; set; } + public string? Field { get; set; } /// /// Gets or sets the filtering operator. Set to null if the Filters property is set. /// [DataMember(Name = "operator")] - public string Operator { get; set; } + public string? Operator { get; set; } /// /// Gets or sets the filtering value. Set to null if the Filters property is set. /// [DataMember(Name = "value")] - public object Value { get; set; } + public object? Value { get; set; } /// /// Gets or sets the filtering logic. Can be set to "or" or "and". Set to null unless Filters is set. /// [DataMember(Name = "logic")] - public string Logic { get; set; } + public string? Logic { get; set; } /// /// Gets or sets the child filter expressions. Set to null if there are no child expressions. /// [DataMember(Name = "filters")] - public IEnumerable Filters { get; set; } + public IEnumerable? Filters { get; set; } /// /// Get a flattened list of all child filter expressions. @@ -111,12 +111,12 @@ public string ToExpression(Type type, IList filters) return "(" + string.Join(" " + Logic + " ", Filters.Select(filter => filter.ToExpression(type, filters)).ToArray()) + ")"; - var currentPropertyType = GetLastPropertyType(type, Field); + var currentPropertyType = GetLastPropertyType(type, Field!); if (currentPropertyType != typeof(string) && StringOperators.Contains(Operator)) throw new NotSupportedException($"Operator {Operator} not support non-string type"); var index = filters.IndexOf(this); - var comparison = Operators[Operator]; + var comparison = Operators[Operator!]; //switch(Operator) //{ @@ -160,7 +160,7 @@ public Expression ToLambdaExpression(ParameterExpression parameter, IList filter.ToLambdaExpression(parameter, filters)) .ToArray()) @@ -173,15 +173,15 @@ public Expression ToLambdaExpression(ParameterExpression parameter, IList(ParameterExpression parameter, IList(ParameterExpression parameter, IList(ParameterExpression parameter, IList Aggregates { get; set; } -} \ No newline at end of file + [DataMember(Name = "aggregates")] public IEnumerable? Aggregates { get; set; } +} diff --git a/Codout.DynamicLinq/GroupResult.cs b/Codout.DynamicLinq/GroupResult.cs index 0d29c7f..cdee189 100644 --- a/Codout.DynamicLinq/GroupResult.cs +++ b/Codout.DynamicLinq/GroupResult.cs @@ -1,4 +1,4 @@ -using System.Runtime.Serialization; +using System.Runtime.Serialization; namespace Codout.DynamicLinq; @@ -7,17 +7,17 @@ namespace Codout.DynamicLinq; public class GroupResult { // Small letter properties are kendo js properties so please excuse the warnings - [DataMember(Name = "value")] public object Value { get; set; } + [DataMember(Name = "value")] public object? Value { get; set; } - public string SelectorField { get; set; } + public string SelectorField { get; set; } = null!; [DataMember(Name = "field")] public string Field => $"{SelectorField} ({Count})"; public int Count { get; set; } - [DataMember(Name = "aggregates")] public object Aggregates { get; set; } + [DataMember(Name = "aggregates")] public object? Aggregates { get; set; } - [DataMember(Name = "items")] public dynamic Items { get; set; } + [DataMember(Name = "items")] public dynamic Items { get; set; } = null!; [DataMember(Name = "hasSubgroups")] public bool HasSubgroups { get; set; } // true if there are subgroups -} \ No newline at end of file +} diff --git a/Codout.DynamicLinq/GroupSelector.cs b/Codout.DynamicLinq/GroupSelector.cs index bc78150..4ce7924 100644 --- a/Codout.DynamicLinq/GroupSelector.cs +++ b/Codout.DynamicLinq/GroupSelector.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Collections.Generic; namespace Codout.DynamicLinq; public class GroupSelector { - public Func Selector { get; set; } - public string Field { get; set; } - public IEnumerable Aggregates { get; set; } -} \ No newline at end of file + public Func Selector { get; set; } = null!; + public string Field { get; set; } = null!; + public IEnumerable? Aggregates { get; set; } +} diff --git a/Codout.DynamicLinq/QueryableExtensions.cs b/Codout.DynamicLinq/QueryableExtensions.cs index 74b856a..de20abe 100644 --- a/Codout.DynamicLinq/QueryableExtensions.cs +++ b/Codout.DynamicLinq/QueryableExtensions.cs @@ -1,10 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Dynamic.Core; using System.Linq.Expressions; using System.Reflection; +// CA1860/CA2249: padrões Any()/IndexOf mantidos como estão para preservar o +// comportamento original byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1860, CA2249 + namespace Codout.DynamicLinq; public static class QueryableExtensions @@ -20,7 +24,7 @@ public static class QueryableExtensions /// Specifies the current filter. /// A DataSourceResult object populated from the processed IQueryable. public static DataSourceResult ToDataSourceResult(this IQueryable queryable, int take, int skip, - IEnumerable sort, Filter filter) + IEnumerable? sort, Filter? filter) { return queryable.ToDataSourceResult(take, skip, sort, filter, null, null); } @@ -51,7 +55,7 @@ public static DataSourceResult ToDataSourceResult(this IQueryable queryabl /// Specifies the current groups. /// A DataSourceResult object populated from the processed IQueryable. public static DataSourceResult ToDataSourceResult(this IQueryable queryable, int take, int skip, - IEnumerable sort, Filter filter, IEnumerable aggregates, IEnumerable group) + IEnumerable? sort, Filter? filter, IEnumerable? aggregates, IEnumerable? group) { var errors = new List(); @@ -64,7 +68,7 @@ public static DataSourceResult ToDataSourceResult(this IQueryable queryabl // Calculate the aggregates var aggregate = Aggregates(queryable, aggregates); - var groupSelectors = group as Group[] ?? group.ToArray(); + var groupSelectors = group as Group[] ?? group!.ToArray(); if (groupSelectors?.Any() == true) { @@ -104,7 +108,7 @@ public static DataSourceResult ToDataSourceResult(this IQueryable queryabl return result; } - private static IQueryable Filters(IQueryable queryable, Filter filter, List errors) + private static IQueryable Filters(IQueryable queryable, Filter? filter, List errors) { if (filter?.Logic != null) { @@ -157,19 +161,19 @@ private static IQueryable Filters(IQueryable queryable, Filter filter, return queryable; } - internal static object Aggregates(IQueryable queryable, IEnumerable aggregates) + internal static object? Aggregates(IQueryable queryable, IEnumerable? aggregates) { - var aggregators = aggregates as Aggregator[] ?? aggregates.ToArray(); + var aggregators = aggregates as Aggregator[] ?? aggregates!.ToArray(); if (aggregators?.Any() == true) { - var objProps = new Dictionary(); + var objProps = new Dictionary(); var groups = aggregators.GroupBy(g => g.Field); - Type type = null; + Type type; foreach (var group in groups) { - var fieldProps = new Dictionary(); + var fieldProps = new Dictionary(); foreach (var aggregate in group) { var prop = typeof(T).GetProperty(aggregate.Field) ?? throw new ArgumentNullException("typeof(T).GetProperty(aggregate.Field)"); @@ -202,7 +206,7 @@ internal static object Aggregates(IQueryable queryable, IEnumerable(IQueryable queryable, IEnumerable Sort(IQueryable queryable, IEnumerable sort) + private static IQueryable Sort(IQueryable queryable, IEnumerable? sort) { - var enumerable = sort as Sort[] ?? sort.ToArray(); + var enumerable = sort as Sort[] ?? sort!.ToArray(); if (enumerable?.Any() == true) { @@ -234,6 +238,7 @@ private static IQueryable Page(IQueryable queryable, int take, int skip /// /// Pretreatment of specific DateTime type and convert some illegal value type /// + /// /// private static Filter PreliminaryWork(Type type, Filter filter) { @@ -248,7 +253,7 @@ private static Filter PreliminaryWork(Type type, Filter filter) if (filter.Value == null) return filter; // When we have a decimal value, it gets converted to an integer/double that will result in the query break - var currentPropertyType = Filter.GetLastPropertyType(type, filter.Field); + var currentPropertyType = Filter.GetLastPropertyType(type, filter.Field!); if (currentPropertyType == typeof(decimal) && decimal.TryParse(filter.Value.ToString(), out var number)) { filter.Value = number; @@ -311,7 +316,7 @@ private static Filter PreliminaryWork(Type type, Filter filter) /// The way this extension works it pages the records using skip and takes to do that we need at least one sort /// property. /// - private static IEnumerable GetDefaultSort(Type type, IEnumerable sort) + private static IEnumerable GetDefaultSort(Type type, IEnumerable? sort) { if (sort == null) { @@ -324,7 +329,7 @@ private static IEnumerable GetDefaultSort(Type type, IEnumerable sor Dir = "desc" }; - PropertyInfo propertyInfo; + PropertyInfo? propertyInfo; //look for property that is called id if (properties.Any(p => string.Equals(p.Name, "id", StringComparison.OrdinalIgnoreCase))) propertyInfo = @@ -343,4 +348,4 @@ private static IEnumerable GetDefaultSort(Type type, IEnumerable sor return sort; } -} \ No newline at end of file +} diff --git a/Codout.DynamicLinq/README.md b/Codout.DynamicLinq/README.md new file mode 100644 index 0000000..8900b5d --- /dev/null +++ b/Codout.DynamicLinq/README.md @@ -0,0 +1,58 @@ +# Codout.DynamicLinq + +Wrapper para datasource de lista paginada: aplica paginação, ordenação, filtragem, agrupamento e agregações sobre qualquer `IQueryable` usando Dynamic LINQ, retornando um resultado pronto para grids (ex.: Kendo UI DataSource). + +## Instalação + +```bash +dotnet add package Codout.DynamicLinq +``` + +## Uso + +O ponto de entrada é o método de extensão `ToDataSourceResult` (classe `QueryableExtensions`), que recebe um `DataSourceRequest` (com `Take`, `Skip`, `Sort`, `Filter`, `Group` e `Aggregate`) e devolve um `DataSourceResult` com `Data`, `Total`, `Groups`, `Aggregates` e `Errors`: + +```csharp +using Codout.DynamicLinq; + +[HttpPost] +public DataSourceResult GetPedidos([FromBody] DataSourceRequest request) +{ + IQueryable query = _db.Pedidos.AsQueryable(); + return query.ToDataSourceResult(request); +} +``` + +Também é possível chamar o overload completo manualmente, informando `take`, `skip`, ordenação (`Sort`), filtro (`Filter`), agregações (`Aggregator`) e agrupamentos (`Group`): + +```csharp +using Codout.DynamicLinq; + +var result = _db.Pedidos.AsQueryable().ToDataSourceResult( + take: 10, + skip: 0, + sort: new[] { new Sort { Field = "Data", Dir = "desc" } }, + filter: new Filter + { + Logic = "and", + Filters = new[] + { + new Filter { Field = "Cliente", Operator = "contains", Value = "Maria" } + } + }, + aggregates: Array.Empty(), + group: Array.Empty()); + +var pagina = result.Data; // página atual +var total = result.Total; // total de registros (antes da paginação) +``` + +Observação: forneça coleções vazias (e não `null`) em `Sort`, `Group` e `Aggregate` quando não utilizá-los. Filtros sobre campos `DateTime` e `decimal` recebem conversão automática de tipo, e o operador `eq` em datas sem hora é expandido para o intervalo do dia. + +## Pacotes relacionados + +- [Codout.Framework.EF](https://www.nuget.org/packages/Codout.Framework.EF) — repositórios com Entity Framework Core; o `IQueryable` retornado pode ser consumido diretamente pelo `ToDataSourceResult`. +- [Codout.Framework.Data](https://www.nuget.org/packages/Codout.Framework.Data) — abstrações de repositório e Unit of Work usadas em conjunto nas consultas. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.DynamicLinq/Sort.cs b/Codout.DynamicLinq/Sort.cs index 90a4eae..f8eaab4 100644 --- a/Codout.DynamicLinq/Sort.cs +++ b/Codout.DynamicLinq/Sort.cs @@ -1,4 +1,4 @@ -using System.Runtime.Serialization; +using System.Runtime.Serialization; namespace Codout.DynamicLinq; @@ -12,13 +12,13 @@ public class Sort /// Gets or sets the name of the sorted field (property). /// [DataMember(Name = "field")] - public string Field { get; set; } + public string Field { get; set; } = null!; /// /// Gets or sets the sort direction. Should be either "asc" or "desc". /// [DataMember(Name = "dir")] - public string Dir { get; set; } + public string Dir { get; set; } = null!; /// /// Converts to form required by Dynamic Linq e.g. "Field1 desc" @@ -27,4 +27,4 @@ public string ToExpression() { return Field + " " + Dir; } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api.Client/Codout.Framework.Api.Client.csproj b/Codout.Framework.Api.Client/Codout.Framework.Api.Client.csproj index 1b8fd50..2eb12b6 100644 --- a/Codout.Framework.Api.Client/Codout.Framework.Api.Client.csproj +++ b/Codout.Framework.Api.Client/Codout.Framework.Api.Client.csproj @@ -1,8 +1,12 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Biblioteca de funções para Cliente Api do Framework da Codout + Codout;Framework;ApiClient;HttpClient;REST diff --git a/Codout.Framework.Api.Client/EntityDtoBase.cs b/Codout.Framework.Api.Client/EntityDtoBase.cs index 0392fc5..6c47af9 100644 --- a/Codout.Framework.Api.Client/EntityDtoBase.cs +++ b/Codout.Framework.Api.Client/EntityDtoBase.cs @@ -1,4 +1,4 @@ -namespace Codout.Framework.Api.Client; +namespace Codout.Framework.Api.Client; /// /// Classe DTO base para transporte com WebAPI @@ -9,5 +9,5 @@ public abstract class EntityDtoBase : IEntityDto /// /// Id do objeto /// - public TId Id { get; set; } -} \ No newline at end of file + public TId Id { get; set; } = default!; +} diff --git a/Codout.Framework.Api.Client/Extensions/HttpClientExtensions.cs b/Codout.Framework.Api.Client/Extensions/HttpClientExtensions.cs index 272f8d2..ae6f4c8 100644 --- a/Codout.Framework.Api.Client/Extensions/HttpClientExtensions.cs +++ b/Codout.Framework.Api.Client/Extensions/HttpClientExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net.Http; using System.Threading.Tasks; @@ -40,7 +40,7 @@ public static async Task DeleteAsync(this HttpClient client, string uriService) private static async Task CallClientAsync(Func> client) { - HttpResponseMessage response = null; + HttpResponseMessage? response = null; try { @@ -56,7 +56,7 @@ private static async Task CallClientAsync(Func> client) { - HttpResponseMessage response = null; + HttpResponseMessage? response = null; try { @@ -85,4 +85,4 @@ private static async Task GetExceptionAsync(this HttpResponseMessage throw new Exception(await httpResponseMessage.Content.ReadAsStringAsync()); } } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api.Client/Extensions/JsonExtensions.cs b/Codout.Framework.Api.Client/Extensions/JsonExtensions.cs index 6085622..cc5e755 100644 --- a/Codout.Framework.Api.Client/Extensions/JsonExtensions.cs +++ b/Codout.Framework.Api.Client/Extensions/JsonExtensions.cs @@ -1,4 +1,4 @@ -using System.Net.Http; +using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; @@ -24,9 +24,13 @@ public static async Task ReadAsAsync(this HttpContent httpConten var items = JsonSerializer.Deserialize(data, JsonSerializerOptions); - return items; + return items!; } + // Oblivious de propósito: sob #nullable enable o compilador emite metadados + // que o ApiCompat lê como constraint 'notnull' nova em TModel (CP0021), + // quebrando a baseline publicada. Anotar apenas no próximo major. +#nullable disable extension(HttpClient client) { public async Task PostAsJsonAsync(string requestUrl, TModel model) @@ -45,4 +49,4 @@ public async Task PutAsJsonAsync(string requestUrl, return await client.PutAsync(requestUrl, stringContent); } } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api.Client/README.md b/Codout.Framework.Api.Client/README.md new file mode 100644 index 0000000..d63326b --- /dev/null +++ b/Codout.Framework.Api.Client/README.md @@ -0,0 +1,54 @@ +# Codout.Framework.Api.Client + +Cliente HTTP genérico e tipado para consumir as Web APIs CRUD construídas com Codout.Framework.Api, incluindo tratamento de erros padronizado. + +## Instalação + +```bash +dotnet add package Codout.Framework.Api.Client +``` + +## Uso + +`RestApiClient` implementa `IRestApi` (`GetAsync`, `PostAsync`, `PutAsync`, `DeleteAsync`, `GetAllAsync`) sobre um `HttpClient` configurado pela classe base `ApiClientBase`: + +```csharp +using Codout.DynamicLinq; +using Codout.Framework.Api.Client; + +public class ClienteDto : EntityDtoBase +{ + public string Nome { get; set; } +} + +var api = new RestApiClient("api/clientes", "https://api.exemplo.com/"); +// Ou, com autenticação via header "ApiKey": +// var api = new RestApiClient("api/clientes", "https://api.exemplo.com/", apiKey); + +var criado = await api.PostAsync(new ClienteDto { Nome = "Maria" }); +var cliente = await api.GetAsync(criado.Id); + +DataSourceResult pagina = await api.GetAllAsync(new DataSourceRequest { Take = 20, Skip = 0 }); +await api.DeleteAsync(criado.Id); +``` + +Falhas HTTP são convertidas em `ApiClientException`, que carrega o `ApiException` retornado pelo servidor: + +```csharp +try +{ + await api.GetAsync(id); +} +catch (ApiClientException ex) +{ + Console.WriteLine($"{ex.ApiException.StatusCode}: {ex.ApiException.Message}"); +} +``` + +## Pacotes relacionados + +- [Codout.Framework.Api](https://www.nuget.org/packages/Codout.Framework.Api) — lado servidor: controllers REST que este cliente consome. +- [Codout.Framework.Api.Dto](https://www.nuget.org/packages/Codout.Framework.Api.Dto) — DTOs base compartilhados entre cliente e servidor. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Api.Client/RestApiClient.cs b/Codout.Framework.Api.Client/RestApiClient.cs index 55527f7..efb45f7 100644 --- a/Codout.Framework.Api.Client/RestApiClient.cs +++ b/Codout.Framework.Api.Client/RestApiClient.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Codout.DynamicLinq; using Codout.Framework.Api.Client.Extensions; @@ -63,8 +63,10 @@ public async Task DeleteAsync(TId id) await Client.DeleteAsync($"{UriService}/{id}"); } +#pragma warning disable CA1725 // Nome do parâmetro preservado para não quebrar chamadas com argumento nomeado. public async Task GetAllAsync(DataSourceRequest obj) +#pragma warning restore CA1725 { return await Client.PostAsync($"{UriService}/get-all", obj); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api.Dto/Codout.Framework.Api.Dto.csproj b/Codout.Framework.Api.Dto/Codout.Framework.Api.Dto.csproj index 78581f9..3ac5bd9 100644 --- a/Codout.Framework.Api.Dto/Codout.Framework.Api.Dto.csproj +++ b/Codout.Framework.Api.Dto/Codout.Framework.Api.Dto.csproj @@ -1,12 +1,16 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 netstandard2.1 Biblioteca de funções para classes DTO da Api do Framework da Codout + Codout;Framework;DTO;REST false diff --git a/Codout.Framework.Api.Dto/README.md b/Codout.Framework.Api.Dto/README.md new file mode 100644 index 0000000..c865a4d --- /dev/null +++ b/Codout.Framework.Api.Dto/README.md @@ -0,0 +1,46 @@ +# Codout.Framework.Api.Dto + +Tipos base para classes DTO (Data Transfer Objects) trafegadas entre as APIs do Codout.Framework e seus clientes. + +## Instalação + +```bash +dotnet add package Codout.Framework.Api.Dto +``` + +## Uso + +O pacote fornece os contratos `IDto` e `IEntityDto` e as implementações base `Dto` e `EntityDto` (esta última expõe a propriedade `Id`). Observação: por razões históricas, os tipos residem no namespace `Codout.Framework.Api.Client`. + +```csharp +using Codout.Framework.Api.Client; + +public class ClienteDto : EntityDto +{ + public string Nome { get; set; } + public string Email { get; set; } +} +``` + +DTOs sem identidade podem derivar de `Dto` ou implementar `IDto` diretamente: + +```csharp +using Codout.Framework.Api.Client; + +public class ResumoVendasDto : Dto +{ + public decimal Total { get; set; } + public int Quantidade { get; set; } +} +``` + +Esses mesmos tipos são usados como restrição genérica em `RestApiEntityBase` (servidor), `RestApiClient` (cliente) e `CrudAppServiceBase` (application service), garantindo um contrato único de transporte. O pacote tem target `netstandard2.1` e não possui dependências externas. + +## Pacotes relacionados + +- [Codout.Framework.Api](https://www.nuget.org/packages/Codout.Framework.Api) — controllers REST que recebem/retornam estes DTOs. +- [Codout.Framework.Api.Client](https://www.nuget.org/packages/Codout.Framework.Api.Client) — cliente HTTP tipado que consome APIs usando estes DTOs. +- [Codout.Framework.Application](https://www.nuget.org/packages/Codout.Framework.Application) — serviços de aplicação que mapeiam entidades para estes DTOs. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Api.Shared/ApiException.cs b/Codout.Framework.Api.Shared/ApiException.cs index 8aa1824..f3ce56b 100644 --- a/Codout.Framework.Api.Shared/ApiException.cs +++ b/Codout.Framework.Api.Shared/ApiException.cs @@ -1,8 +1,10 @@ -using System.Text.Json; +using System.Text.Json; namespace Codout.Framework.Api.Client; +#pragma warning disable CA1711 // Nome público existente preservado para não quebrar consumidores. public class ApiException(int statusCode, string message, params ApiErrorMessage[] errors) +#pragma warning restore CA1711 { public int StatusCode { get; set; } = statusCode; @@ -14,4 +16,4 @@ public override string ToString() { return JsonSerializer.Serialize(this, JsonSerializerOptions.Web); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api/Codout.Framework.Api.csproj b/Codout.Framework.Api/Codout.Framework.Api.csproj index 5e0f6d2..f670a95 100644 --- a/Codout.Framework.Api/Codout.Framework.Api.csproj +++ b/Codout.Framework.Api/Codout.Framework.Api.csproj @@ -1,9 +1,12 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Bliblioteca abstrata de implementação para Web API Restfull - Codout + Codout;Framework;AspNetCore;WebApi;REST diff --git a/Codout.Framework.Api/Middleware/ApiExceptionMiddleware.cs b/Codout.Framework.Api/Middleware/ApiExceptionMiddleware.cs index 1651b13..93469db 100644 --- a/Codout.Framework.Api/Middleware/ApiExceptionMiddleware.cs +++ b/Codout.Framework.Api/Middleware/ApiExceptionMiddleware.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Codout.Framework.Api.Client; using Microsoft.AspNetCore.Builder; @@ -17,7 +17,9 @@ public async Task InvokeAsync(HttpContext httpContext) } catch (Exception ex) { +#pragma warning disable CA2254 // Chamada original mantida para preservar o comportamento de log. logger.LogError(ex.Message, ex); +#pragma warning restore CA2254 await HandleExceptionAsync(httpContext, ex); } } @@ -41,4 +43,4 @@ public static void ConfigureExceptionMiddleware(this IApplicationBuilder app) { app.UseMiddleware(); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Api/README.md b/Codout.Framework.Api/README.md new file mode 100644 index 0000000..8315863 --- /dev/null +++ b/Codout.Framework.Api/README.md @@ -0,0 +1,49 @@ +# Codout.Framework.Api + +Biblioteca abstrata para construção de Web APIs RESTful CRUD em ASP.NET Core, com controller base genérico plugado na camada de Application Service do Codout.Framework. + +## Instalação + +```bash +dotnet add package Codout.Framework.Api +``` + +## Uso + +Herde de `RestApiEntityBase` para expor um CRUD completo (`Get`, `Post`, `Put`, `Delete` e `GetAll` paginado via `DataSourceRequest`) implementando o contrato `IRestApi`: + +```csharp +using Codout.Framework.Api; +using Codout.Framework.Application.Interfaces; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/clientes")] +public class ClientesController(ICrudAppService appService) + : RestApiEntityBase(appService); +``` + +Registre o middleware de tratamento de exceções (`ApiExceptionMiddleware`), que serializa erros como `ApiException` / `ApiErrorMessage` em JSON: + +```csharp +using Codout.Framework.Api.Middleware; + +var app = builder.Build(); + +app.ConfigureExceptionMiddleware(); + +app.MapControllers(); +app.Run(); +``` + +Todos os endpoints são `virtual` e podem ser sobrescritos no controller concreto. + +## Pacotes relacionados + +- [Codout.Framework.Api.Client](https://www.nuget.org/packages/Codout.Framework.Api.Client) — cliente HTTP tipado para consumir APIs construídas com este pacote. +- [Codout.Framework.Api.Dto](https://www.nuget.org/packages/Codout.Framework.Api.Dto) — DTOs base (`EntityDto`) compartilhados entre servidor e cliente. +- [Codout.Framework.Application](https://www.nuget.org/packages/Codout.Framework.Application) — camada Application Service (`ICrudAppService`) consumida pelos controllers. +- [Codout.Framework.Domain](https://www.nuget.org/packages/Codout.Framework.Domain) — entidades base de domínio (`Entity`). + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Api/RestApiEntityBase.cs b/Codout.Framework.Api/RestApiEntityBase.cs index 4d8e3c6..c3d71d7 100644 --- a/Codout.Framework.Api/RestApiEntityBase.cs +++ b/Codout.Framework.Api/RestApiEntityBase.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Codout.DynamicLinq; using Codout.Framework.Api.Client; using Codout.Framework.Application.Interfaces; @@ -64,9 +64,11 @@ public virtual async Task Delete(TId id) [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ApiException))] [ProducesResponseType(StatusCodes.Status500InternalServerError, Type = typeof(ApiException))] +#pragma warning disable CA1725 // Nome do parâmetro preservado para não quebrar chamadas com argumento nomeado. public virtual async Task GetAll([FromBody] DataSourceRequest value) +#pragma warning restore CA1725 { var result = await AppService.GetAllAsync(value); return Ok(result); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Application/Codout.Framework.Application.csproj b/Codout.Framework.Application/Codout.Framework.Application.csproj index ec8de4e..4bed323 100644 --- a/Codout.Framework.Application/Codout.Framework.Application.csproj +++ b/Codout.Framework.Application/Codout.Framework.Application.csproj @@ -1,9 +1,12 @@ - 6.3.0 + 6.4.0 + + true + 6.3.0 Bliblioteca de implementação base da camada Application Service - Codout + Codout;Framework;Application;CRUD;DDD diff --git a/Codout.Framework.Application/CrudAppServiceBase.cs b/Codout.Framework.Application/CrudAppServiceBase.cs index 872a2cb..94ab630 100644 --- a/Codout.Framework.Application/CrudAppServiceBase.cs +++ b/Codout.Framework.Application/CrudAppServiceBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using AutoMapper; using Codout.DynamicLinq; @@ -25,10 +25,10 @@ public virtual async Task GetAllAsync(DataSourceRequest dataSo public virtual async Task GetAsync(TId id) { - var entity = await Repository.GetAsync(id); + var entity = await Repository.GetAsync(id!); if (entity == null) - return null; + return null!; var output = Mapper.Map(entity); @@ -53,12 +53,14 @@ public virtual async Task SaveAsync(TDto input) public virtual async Task UpdateAsync(TDto input) { - var entity = await Repository.GetAsync(input.Id); + var entity = await Repository.GetAsync(input.Id!); if (entity == null) - return null; + return null!; +#pragma warning disable CA2263 // Overload não-genérico mantido para preservar o comportamento original. entity = Mapper.Map(input, entity, typeof(TDto), typeof(TEntity)) as TEntity; +#pragma warning restore CA2263 UnitOfWork.Commit(); @@ -67,7 +69,7 @@ public virtual async Task UpdateAsync(TDto input) public virtual async Task DeleteAsync(TId id) { - var entity = await Repository.LoadAsync(id); + var entity = await Repository.LoadAsync(id!); if (entity == null) return; @@ -76,4 +78,4 @@ public virtual async Task DeleteAsync(TId id) UnitOfWork.Commit(); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Application/README.md b/Codout.Framework.Application/README.md new file mode 100644 index 0000000..ec38723 --- /dev/null +++ b/Codout.Framework.Application/README.md @@ -0,0 +1,53 @@ +# Codout.Framework.Application + +Implementação base da camada Application Service do Codout.Framework: serviços CRUD genéricos que orquestram `IRepository` / `IUnitOfWork` e mapeiam entidades para DTOs com AutoMapper. + +## Instalação + +```bash +dotnet add package Codout.Framework.Application +``` + +## Uso + +Registre os serviços no container (registra `ICrudAppService<,,>` como `CrudAppServiceBase<,,>` e o AutoMapper com o `MappingProfile` base, que mapeia `Entity` ⇄ `EntityDto`): + +```csharp +using Codout.Framework.Application; + +builder.Services.AddCrudAppServices(); +``` + +Herde de `CrudAppServiceBase` para customizar um serviço. A classe implementa `ICrudAppService` com `GetAsync`, `SaveAsync`, `UpdateAsync`, `DeleteAsync` e `GetAllAsync(DataSourceRequest)` — todos `virtual`: + +```csharp +using AutoMapper; +using Codout.Framework.Application; +using Codout.Framework.Data; +using Codout.Framework.Data.Repository; + +public class ClienteAppService( + IUnitOfWork unitOfWork, + IRepository repository, + IMapper mapper) + : CrudAppServiceBase(unitOfWork, repository, mapper) +{ + public override async Task SaveAsync(ClienteDto input) + { + // validações de negócio aqui + return await base.SaveAsync(input); + } +} +``` + +`AppServiceBase` expõe `UnitOfWork`, `Repository` e `Mapper` para serviços que não seguem o padrão CRUD. + +## Pacotes relacionados + +- [Codout.Framework.Data](https://www.nuget.org/packages/Codout.Framework.Data) — abstrações `IRepository` e `IUnitOfWork` consumidas pelos serviços. +- [Codout.Framework.Domain](https://www.nuget.org/packages/Codout.Framework.Domain) — entidades base (`Entity`). +- [Codout.Framework.Api](https://www.nuget.org/packages/Codout.Framework.Api) — controllers REST que consomem `ICrudAppService`. +- Persistência: [Codout.Framework.EF](https://www.nuget.org/packages/Codout.Framework.EF), [Codout.Framework.NH](https://www.nuget.org/packages/Codout.Framework.NH), [Codout.Framework.Mongo](https://www.nuget.org/packages/Codout.Framework.Mongo). + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Common/Annotations/CompareAttribute.cs b/Codout.Framework.Common/Annotations/CompareAttribute.cs index e796a42..95e8574 100644 --- a/Codout.Framework.Common/Annotations/CompareAttribute.cs +++ b/Codout.Framework.Common/Annotations/CompareAttribute.cs @@ -41,7 +41,7 @@ public CompareAttribute(string otherProperty) /// /// The value to validate. /// The context information about the validation operation. - protected override ValidationResult IsValid(object value, ValidationContext validationContext) + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { var otherPropertyInfo = validationContext.ObjectType.GetProperty(OtherProperty); if (otherPropertyInfo == null) diff --git a/Codout.Framework.Common/Annotations/ConditionalAttributeBase.cs b/Codout.Framework.Common/Annotations/ConditionalAttributeBase.cs index 9959281..7d64c9d 100644 --- a/Codout.Framework.Common/Annotations/ConditionalAttributeBase.cs +++ b/Codout.Framework.Common/Annotations/ConditionalAttributeBase.cs @@ -25,9 +25,9 @@ public abstract class ConditionalAttributeBase : ValidationAttribute /// /// protected bool ShouldRunValidation( - object value, + object? value, string dependentProperty, - object targetValue, + object? targetValue, ValidationContext validationContext) { var dependentValue = GetDependentFieldValue(dependentProperty, validationContext); @@ -47,7 +47,7 @@ protected bool ShouldRunValidation( /// /// /// - protected object GetDependentFieldValue(string dependentProperty, ValidationContext validationContext) + protected object? GetDependentFieldValue(string dependentProperty, ValidationContext validationContext) { // get a reference to the property this validation depends upon var containerType = validationContext.ObjectInstance.GetType(); @@ -77,8 +77,8 @@ protected ConditionalAttributeBase() /// Construtor com opção de mensagem de erro. /// /// - protected ConditionalAttributeBase(string errorMessage) - : base(errorMessage) + protected ConditionalAttributeBase(string? errorMessage) + : base(errorMessage!) { } diff --git a/Codout.Framework.Common/Annotations/CpfCnpjAttribute.cs b/Codout.Framework.Common/Annotations/CpfCnpjAttribute.cs index ce6dc77..1f02b56 100644 --- a/Codout.Framework.Common/Annotations/CpfCnpjAttribute.cs +++ b/Codout.Framework.Common/Annotations/CpfCnpjAttribute.cs @@ -19,7 +19,7 @@ public class CpfCnpjAttribute : ValidationAttribute /// true se o valor especificado é válido; Caso contrário, false. /// /// O valor do objeto a ser validado. - public override bool IsValid(object value) + public override bool IsValid(object? value) { var cpfCnpj = value as string; diff --git a/Codout.Framework.Common/Annotations/MustBeTrueAttribute.cs b/Codout.Framework.Common/Annotations/MustBeTrueAttribute.cs index b3b0aea..32e0bef 100644 --- a/Codout.Framework.Common/Annotations/MustBeTrueAttribute.cs +++ b/Codout.Framework.Common/Annotations/MustBeTrueAttribute.cs @@ -6,7 +6,7 @@ namespace Codout.Framework.Common.Annotations; [AttributeUsage(AttributeTargets.Property)] public class MustBeTrueAttribute : ValidationAttribute { - public override bool IsValid(object value) + public override bool IsValid(object? value) { return value != null && (bool)value; } diff --git a/Codout.Framework.Common/Annotations/RequiredIfAttribute.cs b/Codout.Framework.Common/Annotations/RequiredIfAttribute.cs index 9b765c9..e54a86a 100644 --- a/Codout.Framework.Common/Annotations/RequiredIfAttribute.cs +++ b/Codout.Framework.Common/Annotations/RequiredIfAttribute.cs @@ -25,7 +25,7 @@ public class RequiredIfAttribute : ConditionalAttributeBase /// /// The value to validate. /// The context information about the validation operation. - protected override ValidationResult IsValid(object value, ValidationContext validationContext) + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { // check if the current value matches the target value if (ShouldRunValidation(value, DependentProperty, TargetValue, validationContext)) @@ -33,7 +33,7 @@ protected override ValidationResult IsValid(object value, ValidationContext vali if (!_innerAttribute.IsValid(value)) // validation failed - return an error return new ValidationResult(FormatErrorMessage(validationContext.DisplayName), - new[] { validationContext.MemberName }); + new[] { validationContext.MemberName! }); return ValidationResult.Success; } @@ -50,7 +50,7 @@ protected override ValidationResult IsValid(object value, ValidationContext vali /// /// Alvo. /// - public object TargetValue { get; set; } + public object? TargetValue { get; set; } #endregion @@ -61,7 +61,7 @@ protected override ValidationResult IsValid(object value, ValidationContext vali /// /// /// - public RequiredIfAttribute(string dependentProperty, object targetValue) + public RequiredIfAttribute(string dependentProperty, object? targetValue) : this(dependentProperty, targetValue, null) { } @@ -72,7 +72,7 @@ public RequiredIfAttribute(string dependentProperty, object targetValue) /// /// /// - public RequiredIfAttribute(string dependentProperty, object targetValue, string errorMessage) + public RequiredIfAttribute(string dependentProperty, object? targetValue, string? errorMessage) : base(errorMessage) { DependentProperty = dependentProperty; diff --git a/Codout.Framework.Common/Codout.Framework.Common.csproj b/Codout.Framework.Common/Codout.Framework.Common.csproj index f484e83..7f290d0 100644 --- a/Codout.Framework.Common/Codout.Framework.Common.csproj +++ b/Codout.Framework.Common/Codout.Framework.Common.csproj @@ -1,14 +1,20 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 + + $(NoWarn);SYSLIB0021;SYSLIB0023 Biblioteca de funcões comuns do Framework da Codout - Codout;Framework + Codout;Framework;Extensions;Helpers;Validation;CPF;CNPJ - diff --git a/Codout.Framework.Common/Extensions/LinqExtensions.cs b/Codout.Framework.Common/Extensions/LinqExtensions.cs index 81e50d3..4803d10 100644 --- a/Codout.Framework.Common/Extensions/LinqExtensions.cs +++ b/Codout.Framework.Common/Extensions/LinqExtensions.cs @@ -1,4 +1,4 @@ -using System.Linq.Expressions; +using System.Linq.Expressions; namespace Codout.Framework.Common.Extensions; @@ -56,9 +56,9 @@ public static bool IsConstraint(this Expression exp) /// /// The exp. /// - public static object GetConstantValue(this Expression exp) + public static object? GetConstantValue(this Expression exp) { - object result = null; + object? result = null; if (exp is ConstantExpression expression) result = expression.Value; return result; diff --git a/Codout.Framework.Common/Extensions/ObjectExtensions.cs b/Codout.Framework.Common/Extensions/ObjectExtensions.cs index 07677e5..eaa0421 100644 --- a/Codout.Framework.Common/Extensions/ObjectExtensions.cs +++ b/Codout.Framework.Common/Extensions/ObjectExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel; @@ -28,7 +28,7 @@ public static class ObjectExtensions /// This method was written by Peter Johnson at: /// http://aspalliance.com/author.aspx?uId=1026. /// - public static object ChangeTypeTo(this object value) + public static object? ChangeTypeTo(this object? value) { var conversionType = typeof(T); return ChangeTypeTo(value, conversionType); @@ -48,12 +48,11 @@ public static object ChangeTypeTo(this object value) /// is Nullable<>) and whose value is equivalent to value. -or- a null reference, if value is a null /// reference and conversionType is not a value type. /// - public static object ChangeTypeTo(this object value, Type conversionType) + public static object? ChangeTypeTo(this object? value, Type conversionType) { // Note: This if block was taken from Convert.ChangeType as is, and is needed here since we're // checking properties on conversionType below. - if (conversionType == null) - throw new ArgumentNullException("conversionType"); + ArgumentNullException.ThrowIfNull(conversionType); // If it's not a nullable type, just pass through the parameters to Convert.ChangeType @@ -77,7 +76,7 @@ public static object ChangeTypeTo(this object value, Type conversionType) } else if (conversionType == typeof(Guid)) { - return new Guid(value.ToString() ?? string.Empty); + return new Guid(value!.ToString() ?? string.Empty); } else if (conversionType == typeof(long) && value is int) { @@ -110,7 +109,7 @@ public static Dictionary ToDictionary(this object value) foreach (var pi in props) try { - result.Add(pi.Name, pi.GetValue(value, null)); + result.Add(pi.Name, pi.GetValue(value, null)!); } catch { diff --git a/Codout.Framework.Common/Extensions/StringExtensions.cs b/Codout.Framework.Common/Extensions/StringExtensions.cs index cc47eb6..db5a19b 100644 --- a/Codout.Framework.Common/Extensions/StringExtensions.cs +++ b/Codout.Framework.Common/Extensions/StringExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -259,7 +259,7 @@ public static string Clip(this string sourceString) /// The pattern. /// The replacement. /// - public static string FastReplace(this string original, string pattern, string replacement) + public static string? FastReplace(this string? original, string pattern, string replacement) { return FastReplace(original, pattern, replacement, StringComparison.InvariantCultureIgnoreCase); } @@ -276,7 +276,7 @@ public static string FastReplace(this string original, string pattern, string re /// The replacement. /// Type of the comparison. /// - public static string FastReplace(this string original, string pattern, string replacement, + public static string? FastReplace(this string? original, string pattern, string replacement, StringComparison comparisonType) { if (original == null) @@ -586,13 +586,13 @@ public static string ToFormattedString(this string fmt, params object[] args) /// /// The value. /// - public static T ToEnum(this string value) + public static T? ToEnum(this string value) { var oOut = default(T); var t = typeof(T); foreach (var fi in t.GetFields()) if (fi.Name.Matches(value)) - oOut = (T)fi.GetValue(null); + oOut = (T)fi.GetValue(null)!; return oOut; } diff --git a/Codout.Framework.Common/Extensions/TaskExtensions.cs b/Codout.Framework.Common/Extensions/TaskExtensions.cs index 688ddd2..409d912 100644 --- a/Codout.Framework.Common/Extensions/TaskExtensions.cs +++ b/Codout.Framework.Common/Extensions/TaskExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -16,7 +16,7 @@ public static class TaskExtensions /// A task a ser executada em background /// Logger opcional para registrar exceções /// Define se deve capturar o contexto de sincronização - public static void Forget(this Task task, ILogger logger = null, bool continueOnCapturedContext = false) + public static void Forget(this Task task, ILogger? logger = null, bool continueOnCapturedContext = false) { if (task == null) return; @@ -31,7 +31,7 @@ public static void Forget(this Task task, ILogger logger = null, bool continueOn /// A task a ser executada em background /// Logger opcional para registrar exceções /// Define se deve capturar o contexto de sincronização - public static void Forget(this Task task, ILogger logger = null, bool continueOnCapturedContext = false) + public static void Forget(this Task task, ILogger? logger = null, bool continueOnCapturedContext = false) { if (task == null) return; @@ -55,7 +55,7 @@ public static void Forget(this Task task, Action onException, bool co /// /// Implementação interna assíncrona para capturar exceções /// - private static async Task ForgetAsync(Task task, ILogger logger, bool continueOnCapturedContext) + private static async Task ForgetAsync(Task task, ILogger? logger, bool continueOnCapturedContext) { try { diff --git a/Codout.Framework.Common/Helpers/EnumHelper.cs b/Codout.Framework.Common/Helpers/EnumHelper.cs index ea2c7e8..41905ff 100644 --- a/Codout.Framework.Common/Helpers/EnumHelper.cs +++ b/Codout.Framework.Common/Helpers/EnumHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -24,7 +24,7 @@ public static string GetDescription(this Enum value) var field = value.GetType().GetField(value.ToString()); - return Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) is not DescriptionAttribute attribute + return Attribute.GetCustomAttribute(field!, typeof(DescriptionAttribute)) is not DescriptionAttribute attribute ? value.ToString() : attribute.Description; } @@ -43,7 +43,7 @@ public static string GetDescription(Type value, string name) var field = value.GetField(name); - var attributes = (DescriptionAttribute[])field.GetCustomAttributes(typeof(DescriptionAttribute), false); + var attributes = (DescriptionAttribute[])field!.GetCustomAttributes(typeof(DescriptionAttribute), false); return attributes.Length > 0 ? attributes[0].Description : name; } @@ -53,7 +53,7 @@ public static string GetDescription(Type value, string name) /// Tipo do enumerador /// Descrição do Enumerador /// Valor do Enumerador - public static T GetValueFromDescription(string description) + public static T? GetValueFromDescription(string description) { var type = typeof(T); if (!type.IsEnum) throw new InvalidOperationException(); @@ -61,29 +61,29 @@ public static T GetValueFromDescription(string description) if (Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) is DescriptionAttribute attribute) { if (attribute.Description == description) - return (T)field.GetValue(null); + return (T)field.GetValue(null)!; } else { if (field.Name == description) - return (T)field.GetValue(null); + return (T)field.GetValue(null)!; } return default; } - public static string GetLocalizedName(this Enum @enum) + public static string? GetLocalizedName(this Enum @enum) { if (@enum == null) return null; - var description = @enum.ToString(); + string? description = @enum.ToString(); var fieldInfo = @enum.GetType().GetField(description); var attributes = - (DisplayAttribute[])fieldInfo.GetCustomAttributes(typeof(DisplayAttribute), false); + (DisplayAttribute[])fieldInfo!.GetCustomAttributes(typeof(DisplayAttribute), false); - if (attributes.Any()) + if (attributes.Length > 0) description = attributes[0].GetDescription(); return description; @@ -116,9 +116,9 @@ public static object GetEnumValue(Type value, string description) if (attributes.Length > 0) if (attributes[0].Description == description) - return fi.GetValue(fi.Name); + return fi.GetValue(fi.Name)!; - if (fi.Name == description) return fi.GetValue(fi.Name); + if (fi.Name == description) return fi.GetValue(fi.Name)!; } return description; diff --git a/Codout.Framework.Common/Helpers/Inflector.cs b/Codout.Framework.Common/Helpers/Inflector.cs index 3d6b697..0e664b6 100644 --- a/Codout.Framework.Common/Helpers/Inflector.cs +++ b/Codout.Framework.Common/Helpers/Inflector.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; using Codout.Framework.Common.Extensions; @@ -407,7 +407,7 @@ public static string ConvertUnderscoresToDashes(this string underscoredWord) /// /// Summary for the InflectorRule class /// - private class InflectorRule + private sealed class InflectorRule { #region Construtores @@ -431,7 +431,7 @@ public InflectorRule(string regexPattern, string replacementText) /// /// The word. /// - public string Apply(string word) + public string? Apply(string word) { if (!_regex.IsMatch(word)) return null; diff --git a/Codout.Framework.Common/Helpers/LimitedList.cs b/Codout.Framework.Common/Helpers/LimitedList.cs index b0f9ce3..723713f 100644 --- a/Codout.Framework.Common/Helpers/LimitedList.cs +++ b/Codout.Framework.Common/Helpers/LimitedList.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Runtime.InteropServices; @@ -115,7 +115,12 @@ public void CopyTo(T[] array) [Serializable] [StructLayout(LayoutKind.Sequential)] + // CS0693: o type parameter sombreia o T externo; o nome é preservado por + // compatibilidade binária com a API publicada (renomear quebra a baseline + // do package validation). Tratar apenas em um futuro major. +#pragma warning disable CS0693 public struct Enumerator : IEnumerator +#pragma warning restore CS0693 { private readonly LimitedList thing; private int index; @@ -124,7 +129,7 @@ internal Enumerator(LimitedList thing) { this.thing = thing; index = 0; - Current = default; + Current = default!; } public void Dispose() @@ -142,18 +147,18 @@ public bool MoveNext() } index = thing.MaxSize + 1; - Current = default; + Current = default!; return false; } public T Current { get; private set; } - object IEnumerator.Current => Current; + object? IEnumerator.Current => Current; void IEnumerator.Reset() { index = 0; - Current = default; + Current = default!; } } } \ No newline at end of file diff --git a/Codout.Framework.Common/Helpers/NumberToText.cs b/Codout.Framework.Common/Helpers/NumberToText.cs index de50f6d..a169000 100644 --- a/Codout.Framework.Common/Helpers/NumberToText.cs +++ b/Codout.Framework.Common/Helpers/NumberToText.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; @@ -24,7 +24,7 @@ public class NumberToText { "decilhão", "decilhões" } }; - private static readonly string[,] Numeros = + private static readonly string?[,] Numeros = { { "zero", "um", "dois", "três", "quatro", @@ -173,7 +173,7 @@ private string NumToString(int numero, int escala) buf.Append(Numeros[0, dezena]); } - buf.Append(" "); + buf.Append(' '); buf.Append(numero == 1 ? Qualificadores[escala, 0] @@ -197,7 +197,7 @@ public override string ToString() if (buf.Length > 0) { - while (buf.ToString().EndsWith(" ")) + while (buf.ToString().EndsWith(' ')) buf.Length -= 1; if (EhUnicoGrupo()) diff --git a/Codout.Framework.Common/Helpers/PluginLoader.cs b/Codout.Framework.Common/Helpers/PluginLoader.cs index 5d71e71..36384c3 100644 --- a/Codout.Framework.Common/Helpers/PluginLoader.cs +++ b/Codout.Framework.Common/Helpers/PluginLoader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Reflection; @@ -12,7 +12,11 @@ namespace Codout.Framework.Common.Helpers; /// Interface do Plugin public static class PluginLoader { + // CA1000: membro estático em tipo genérico faz parte da API pública atual; + // alterar a forma de chamada seria breaking change (tratar no próximo major). +#pragma warning disable CA1000 public static ICollection LoadPlugins(string path) +#pragma warning restore CA1000 { if (!Directory.Exists(path)) return new List(); @@ -47,14 +51,14 @@ public static ICollection LoadPlugins(string path) { if (type.IsInterface || type.IsAbstract) continue; - if (type.GetInterface(pluginType.FullName) != null) pluginTypes.Add(type); + if (type.GetInterface(pluginType.FullName!) != null) pluginTypes.Add(type); } } ICollection plugins = new List(pluginTypes.Count); foreach (var type in pluginTypes) { - var plugin = (T)Activator.CreateInstance(type); + var plugin = (T)Activator.CreateInstance(type)!; plugins.Add(plugin); } diff --git a/Codout.Framework.Common/Helpers/ResourceExtractor.cs b/Codout.Framework.Common/Helpers/ResourceExtractor.cs index 4d4f305..f314264 100644 --- a/Codout.Framework.Common/Helpers/ResourceExtractor.cs +++ b/Codout.Framework.Common/Helpers/ResourceExtractor.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Reflection; namespace Codout.Framework.Common.Helpers; @@ -21,10 +21,10 @@ internal static void ExtractResourceToFile(string resourceName, string filename) return; using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); - if (filename == null) + if (filename == null) return; using var fs = new FileStream(filename, FileMode.Create); - var b = new byte[s.Length]; + var b = new byte[s!.Length]; s.ReadExactly(b, 0, b.Length); fs.Write(b, 0, b.Length); } @@ -38,7 +38,7 @@ internal static void ExtractResourceToFile(string resourceName, string filename) /// /// /// - internal static string ExtractResourceString(string resourceName) + internal static string? ExtractResourceString(string resourceName) { using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); @@ -68,7 +68,7 @@ public static void ExtractResourceToFile(this Assembly assembly, string resource using var s = assembly.GetManifestResourceStream(resourceName); if (filename == null) return; using var fs = new FileStream(filename, FileMode.Create); - var b = new byte[s.Length]; + var b = new byte[s!.Length]; s.ReadExactly(b, 0, b.Length); fs.Write(b, 0, b.Length); } @@ -83,7 +83,7 @@ public static void ExtractResourceToFile(this Assembly assembly, string resource /// /// /// - public static string ExtractResourceString(this Assembly assembly, string resourceName) + public static string? ExtractResourceString(this Assembly assembly, string resourceName) { using var s = assembly.GetManifestResourceStream(resourceName); diff --git a/Codout.Framework.Common/Helpers/ResourcesHelper.cs b/Codout.Framework.Common/Helpers/ResourcesHelper.cs index dffede1..2e18fcc 100644 --- a/Codout.Framework.Common/Helpers/ResourcesHelper.cs +++ b/Codout.Framework.Common/Helpers/ResourcesHelper.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Reflection; namespace Codout.Framework.Common.Helpers; @@ -8,7 +8,7 @@ public class ResourcesHelper public static string GetResourceContent(string baseName, Assembly assembly, string resourceName) { var stream = assembly.GetManifestResourceStream(baseName + "." + resourceName); - var reader = new StreamReader(stream); + var reader = new StreamReader(stream!); var strContent = reader.ReadToEnd(); return strContent; } diff --git a/Codout.Framework.Common/Helpers/RunSafeHelper.cs b/Codout.Framework.Common/Helpers/RunSafeHelper.cs index 36572f2..75aa883 100644 --- a/Codout.Framework.Common/Helpers/RunSafeHelper.cs +++ b/Codout.Framework.Common/Helpers/RunSafeHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -6,10 +6,10 @@ namespace Codout.Framework.Common.Helpers; public static class RunSafeHelper { - public static async Task RunSafe(this Task task, Action onError = null, + public static async Task RunSafe(this Task task, Action? onError = null, CancellationToken token = default) { - Exception exception = null; + Exception? exception = null; try { if (!token.IsCancellationRequested) diff --git a/Codout.Framework.Common/Helpers/SlugHelper.cs b/Codout.Framework.Common/Helpers/SlugHelper.cs index fdeb582..576ed53 100644 --- a/Codout.Framework.Common/Helpers/SlugHelper.cs +++ b/Codout.Framework.Common/Helpers/SlugHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Text; @@ -191,7 +191,7 @@ public class SlugConfig /// /// NOVO: Permitir slug vazio /// - public bool AllowEmptySlug { get; set; } = false; + public bool AllowEmptySlug { get; set; } /// /// NOVO: Fallback quando slug fica vazio (use {hash} para incluir hash do original) diff --git a/Codout.Framework.Common/Helpers/StringEnumJsonConverter.cs b/Codout.Framework.Common/Helpers/StringEnumJsonConverter.cs index b2a0b98..e1cc882 100644 --- a/Codout.Framework.Common/Helpers/StringEnumJsonConverter.cs +++ b/Codout.Framework.Common/Helpers/StringEnumJsonConverter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -30,7 +30,7 @@ public class JsonStringEnumConverter : JsonConverter where TEnum : public JsonStringEnumConverter() { var type = typeof(TEnum); - foreach (TEnum value in Enum.GetValues(typeof(TEnum))) + foreach (var value in Enum.GetValues()) { var enumMember = type.GetMember(value.ToString())[0]; var attr = enumMember.GetCustomAttributes(typeof(JsonPropertyNameAttribute), false) diff --git a/Codout.Framework.Common/Helpers/WebPageFetcher.cs b/Codout.Framework.Common/Helpers/WebPageFetcher.cs index 10b696e..e7a5908 100644 --- a/Codout.Framework.Common/Helpers/WebPageFetcher.cs +++ b/Codout.Framework.Common/Helpers/WebPageFetcher.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net.Http; using System.Text; @@ -158,7 +158,7 @@ public class WebPageOptions /// /// Encoding forçado para o conteúdo /// - public Encoding ForceEncoding { get; set; } + public Encoding? ForceEncoding { get; set; } /// /// Headers HTTP customizados @@ -168,7 +168,7 @@ public class WebPageOptions /// /// Logger para registrar operações /// - public ILogger Logger { get; set; } + public ILogger? Logger { get; set; } /// /// Opções padrão otimizadas diff --git a/Codout.Framework.Common/README.md b/Codout.Framework.Common/README.md new file mode 100644 index 0000000..1745383 --- /dev/null +++ b/Codout.Framework.Common/README.md @@ -0,0 +1,51 @@ +# Codout.Framework.Common + +Biblioteca de funções comuns do Codout.Framework: métodos de extensão, helpers, validações brasileiras (CPF/CNPJ), anotações de validação e utilitários de criptografia para uso geral em aplicações .NET. + +## Instalação + +```bash +dotnet add package Codout.Framework.Common +``` + +## O que contém + +- **Extensions/** — extensões para `string` (`StringExtensions`: `RemoveAccents`, `OnlyNumbers`, `StripHtml`, `Truncate`, `ToEnum`...), datas (`DateTimeExtensions`), números (`NumericExtensions`), coleções (`LinqExtensions`), IO, streams, tasks e validações (`ValidationExtensions`: `IsCpf`, `IsCnpj`, `IsEmail`...). +- **Helpers/** — `SlugHelper`/`SlugExtensions`, `Inflector`, `EnumHelper`, `GeoHelper`, `NumberToText`, `AsyncHelper`, `LimitedList`, entre outros. +- **Annotations/** — atributos de validação como `CpfCnpjAttribute`, `RequiredIfAttribute`, `DateRangeAttribute`, `MustBeTrueAttribute`. +- **Security/** — `Crypto`, `CryptoString`, `CryptoFile`, `RandomPassword`, `SimpleHash`. +- **Constants/** — padrões de regex reutilizáveis (`RegexPattern`). + +## Uso + +Validação de CPF/CNPJ e manipulação de strings: + +```csharp +using Codout.Framework.Common.Extensions; +using Codout.Framework.Common.Helpers; + +bool ok = "123.456.789-09".OnlyNumbers().IsCpf(); + +string slug = "Título do Artigo 2026".ToSlug(); // "titulo-do-artigo-2026" +string limpo = "Café com Açúcar".RemoveAccents(); // "Cafe com Acucar" +``` + +Anotação de validação em um modelo: + +```csharp +using Codout.Framework.Common.Annotations; + +public class ClienteDto +{ + [CpfCnpj(ErrorMessage = "CPF ou CNPJ inválido")] + public string Documento { get; set; } +} +``` + +## Pacotes relacionados + +- [Codout.Framework.Domain](https://www.nuget.org/packages/Codout.Framework.Domain) — entidades base de domínio do ecossistema Codout, em que estas extensões e anotações se encaixam naturalmente. +- [Codout.Framework.Data](https://www.nuget.org/packages/Codout.Framework.Data) — abstrações de repositório e Unit of Work do mesmo ecossistema. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Common/Security/CryptoFile.cs b/Codout.Framework.Common/Security/CryptoFile.cs index 2fa259e..0062e03 100644 --- a/Codout.Framework.Common/Security/CryptoFile.cs +++ b/Codout.Framework.Common/Security/CryptoFile.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -9,8 +9,12 @@ namespace Codout.Framework.Common.Security; public class CryptoFile { // Call this function to remove the key from memory after use for security + // CA1401: P/Invoke público faz parte da API atual deste helper legado; + // mudar a visibilidade seria breaking change (tratar no próximo major). +#pragma warning disable CA1401 [DllImport("KERNEL32.DLL", EntryPoint = "RtlZeroMemory")] public static extern bool ZeroMemory(IntPtr destination, int length); +#pragma warning restore CA1401 // Function to Generate a 64 bits Key. public static string GenerateKey() @@ -41,7 +45,11 @@ public static void EncryptFile(string sInputFilename, CryptoStreamMode.Write); var bytearrayinput = new byte[fsInput.Length]; + // CA2022: leitura possivelmente inexata mantida como está — trocar por + // ReadExactly mudaria o comportamento deste helper legado. +#pragma warning disable CA2022 fsInput.Read(bytearrayinput, 0, bytearrayinput.Length); +#pragma warning restore CA2022 cryptostream.Write(bytearrayinput, 0, bytearrayinput.Length); cryptostream.Close(); fsInput.Close(); diff --git a/Codout.Framework.Common/Security/RandomPassword.cs b/Codout.Framework.Common/Security/RandomPassword.cs index 048c52c..e0dd662 100644 --- a/Codout.Framework.Common/Security/RandomPassword.cs +++ b/Codout.Framework.Common/Security/RandomPassword.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Security.Cryptography; namespace Codout.Framework.Common.Security; @@ -78,8 +78,11 @@ public static string Generate(int minLength, int maxLength) { // Make sure that input parameters are valid. + // Nota nullable: retorna null com parâmetros inválidos (comportamento + // preservado); a assinatura permanece não-anulável porque o caminho + // normal nunca retorna null. if (minLength <= 0 || maxLength <= 0 || minLength > maxLength) - return null; + return null!; // Create a local array containing supported password characters // grouped by types. You can remove character groups from this diff --git a/Codout.Framework.Common/Security/SimpleHash.cs b/Codout.Framework.Common/Security/SimpleHash.cs index 064e0fc..d233f47 100644 --- a/Codout.Framework.Common/Security/SimpleHash.cs +++ b/Codout.Framework.Common/Security/SimpleHash.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.IO; using System.Linq; @@ -232,16 +232,11 @@ public static SecureHashAlgorithm[] GetSupportedAlgorithms() var allAlgorithms = Enum.GetValues(); return allAlgorithms.Where(IsAlgorithmSupported).ToArray(); } - /// Caminho do arquivo - /// Hash esperado em hexadecimal - /// Algoritmo de hash - /// Token de cancelamento - /// True se o arquivo está íntegro /// /// Implementação interna para computar hash /// - private static string ComputeHashInternal(string plainText, + private static string ComputeHashInternal(string plainText, SecureHashAlgorithm algorithm, ReadOnlySpan salt, HashOptions options) diff --git a/Codout.Framework.DP/Codout.Framework.DP.csproj b/Codout.Framework.DP/Codout.Framework.DP.csproj deleted file mode 100644 index 832e5b3..0000000 --- a/Codout.Framework.DP/Codout.Framework.DP.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - 6.2.2 - netstandard2.1 - - false - - - - - - - - - - - - - - diff --git a/Codout.Framework.DP/DPRepository.cs b/Codout.Framework.DP/DPRepository.cs deleted file mode 100644 index 7ccd8a2..0000000 --- a/Codout.Framework.DP/DPRepository.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using Codout.Framework.DAL.Entity; -using Codout.Framework.DAL.Repository; -using Dapper; -using Dapper.Contrib.Extensions; -using MicroOrm.Dapper.Repositories; -using MicroOrm.Dapper.Repositories.SqlGenerator; - -namespace Codout.Framework.DP -{ - public class DPRepository : DapperRepository, IRepository where T : class, IEntity - { - protected IDbConnection DbConnection { get; } - - public DPRepository(IDbConnection connection, ISqlGenerator sqlGenerator) - : base(connection, sqlGenerator) - { - DbConnection = connection; - } - - private IDbConnection CreateConnection() - { - DbConnection.Open(); - return DbConnection; - } - - public void Dispose() - { - DbConnection.Dispose(); - } - - public IQueryable All() - { - using var con = CreateConnection(); - - return DbConnection.GetAll().AsQueryable(); - } - - public IQueryable Find(Expression> predicate) - { - using var con = CreateConnection(); - - return base.Find(predicate).AsQueryable(); - } - - public IQueryable Find(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public T Get(Expression> predicate) - { - throw new NotImplementedException(); - } - - public T Get(object key) - { - using var con = CreateConnection(); - - return DbConnection.Get(key); - } - - public T Load(object key) - { - return Get(key); - } - - public void Delete(T entity) - { - using var con = CreateConnection(); - - var result = DbConnection.Delete(entity); - } - - public void Delete(Expression> predicate) - { - using var con = CreateConnection(); - - base.Delete(predicate); - } - - public T Save(T entity) - { - using var con = CreateConnection(); - - var result = DbConnection.Insert(entity); - - return entity; - } - - public T SaveOrUpdate(T entity) - { - using var con = CreateConnection(); - - var result = DbConnection.Update(entity); - - return entity; - } - - public void Update(T entity) - { - throw new NotImplementedException(); - } - - public T Merge(T entity) - { - throw new NotImplementedException(); - } - - public Task GetAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task GetAsync(object key) - { - throw new NotImplementedException(); - } - - public Task LoadAsync(object key) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task SaveAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task SaveOrUpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task MergeAsync(T entity) - { - throw new NotImplementedException(); - } - } -} diff --git a/Codout.Framework.DP/DbContext.cs b/Codout.Framework.DP/DbContext.cs deleted file mode 100644 index ee37225..0000000 --- a/Codout.Framework.DP/DbContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Data; - -namespace Codout.Framework.DP -{ - public class DbContext - { - private IDbStrategy _dbStrategy; - - public DbContext SetStrategy(IDbStrategy dbStrategy) - { - _dbStrategy = dbStrategy; - return this; - } - - public IDbConnection GetDbContext(string connectionString) - { - return _dbStrategy.GetConnection(connectionString); - } - } -} diff --git a/Codout.Framework.DP/IDbStrategy.cs b/Codout.Framework.DP/IDbStrategy.cs deleted file mode 100644 index 9e9c9b5..0000000 --- a/Codout.Framework.DP/IDbStrategy.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Data; - -namespace Codout.Framework.DP -{ - public interface IDbStrategy - { - IDbConnection GetConnection(string connectionString); - } -} \ No newline at end of file diff --git a/Codout.Framework.Data/Codout.Framework.Data.csproj b/Codout.Framework.Data/Codout.Framework.Data.csproj index 10f4167..8fb678b 100644 --- a/Codout.Framework.Data/Codout.Framework.Data.csproj +++ b/Codout.Framework.Data/Codout.Framework.Data.csproj @@ -1,13 +1,21 @@ - + - 6.4.0 + 6.5.0 + + true + 6.4.0 Interface da Biblioteca de persistência do Framework da Codout Codout;Framework;ORM;DAL;Repository;UnitOfWork;Specification;Auditing true - $(NoWarn);CS1591 latest enable + + + $(NoWarn.Replace(';CS1591', '')) + $(WarningsAsErrors);CS1591 + + diff --git a/Codout.Framework.Data/Entity/IEntity'1.cs b/Codout.Framework.Data/Entity/IEntity'1.cs index deb4c32..b4b390a 100644 --- a/Codout.Framework.Data/Entity/IEntity'1.cs +++ b/Codout.Framework.Data/Entity/IEntity'1.cs @@ -1,12 +1,14 @@ -namespace Codout.Framework.Data.Entity; +namespace Codout.Framework.Data.Entity; /// -/// This serves as a base interface for and +/// Entidade com identificador tipado: estende expondo o +/// Id de forma covariante (out TId). /// +/// Tipo do identificador da entidade. public interface IEntity : IEntity { /// /// Gets the ID which uniquely identifies the entity instance within its type's bounds. /// TId Id { get; } -} \ No newline at end of file +} diff --git a/Codout.Framework.Data/Entity/IEntity.cs b/Codout.Framework.Data/Entity/IEntity.cs index aff6ba0..07a5445 100644 --- a/Codout.Framework.Data/Entity/IEntity.cs +++ b/Codout.Framework.Data/Entity/IEntity.cs @@ -1,10 +1,13 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Reflection; namespace Codout.Framework.Data.Entity; /// -/// This serves as a base interface for and +/// Interface base (não genérica) de toda entidade do framework: expõe os membros +/// de identidade que não dependem do tipo do Id, permitindo que repositórios e +/// infraestrutura tratem entidades de forma polimórfica. Para acesso tipado ao +/// Id, use . /// public interface IEntity { @@ -23,4 +26,4 @@ public interface IEntity /// objects to be lazily loaded. /// bool IsTransient(); -} \ No newline at end of file +} diff --git a/Codout.Framework.Data/Entity/IHasAssignedId.cs b/Codout.Framework.Data/Entity/IHasAssignedId.cs index c6ba030..f378b77 100644 --- a/Codout.Framework.Data/Entity/IHasAssignedId.cs +++ b/Codout.Framework.Data/Entity/IHasAssignedId.cs @@ -1,4 +1,4 @@ -namespace Codout.Framework.Data.Entity; +namespace Codout.Framework.Data.Entity; /// /// Defines the public members of a class that supports setting an assigned ID of an object. @@ -10,9 +10,9 @@ internal interface IHasAssignedId /// Sets the assigned ID of an object. /// /// - /// This is not part of since most entities do not have assigned + /// This is not part of since most entities do not have assigned /// IDs and since business rules will certainly vary as to what constitutes a valid, /// assigned ID for one object but not for another. /// void SetAssignedIdTo(TId assignedId); -} \ No newline at end of file +} diff --git a/Codout.Framework.Data/README.md b/Codout.Framework.Data/README.md index 865422a..cfa6481 100644 --- a/Codout.Framework.Data/README.md +++ b/Codout.Framework.Data/README.md @@ -1,397 +1,41 @@ # Codout.Framework.Data -**Abstrações de persistência ORM-agnostic para .NET 10** +Abstrações de acesso a dados para .NET: repositório genérico (`IRepository`), unidade de trabalho (`IUnitOfWork`), entidades (`IEntity`, `IEntity`), especificações (`ISpecification`) e auditoria (`IAuditable`, `ISoftDeletable`), independentes do provedor de persistência. -Este projeto define os contratos (interfaces) para implementação de padrões Repository e Unit of Work, independente de tecnologia de persistência. - -## ?? Objetivo - -Fornecer abstrações que podem ser implementadas por qualquer ORM: -- ? Entity Framework Core ([`Codout.Framework.EF`](../Codout.Framework.EF/README.md)) -- ? NHibernate ([`Codout.Framework.NH`](../Codout.Framework.NH/README.md)) -- ? MongoDB ([`Codout.Framework.Mongo`](../Codout.Framework.Mongo/README.md)) - -## ?? Instalação +## Instalação ```bash dotnet add package Codout.Framework.Data ``` -## ??? Arquitetura - -### Contratos Principais - -#### IEntity / IEntity\ -Define a base para todas as entidades do domínio. - -```csharp -public interface IEntity -{ - IEnumerable GetSignatureProperties(); - bool IsTransient(); -} - -public interface IEntity : IEntity -{ - TId Id { get; } -} -``` - -#### IRepository\ -Define operações CRUD genéricas para entidades. - -```csharp -public interface IRepository : IDisposable where T : class, IEntity -{ - // Query Methods - IQueryable All(); - IQueryable AllReadOnly(); - IQueryable Where(Expression> predicate); - T Get(Expression> predicate); - Task GetAsync(Expression> predicate, CancellationToken ct = default); - - // Auxiliary Methods (v10.0+) - Task FirstOrDefaultAsync(Expression> predicate, CancellationToken ct = default); - Task AnyAsync(Expression> predicate, CancellationToken ct = default); - Task CountAsync(Expression> predicate, CancellationToken ct = default); - Task> ToListAsync(Expression> predicate, CancellationToken ct = default); - - // Command Methods - void Delete(T entity); - T Save(T entity); - void Update(T entity); - Task SaveAsync(T entity, CancellationToken ct = default); - Task UpdateAsync(T entity, CancellationToken ct = default); - - // Paging - IQueryable WherePaged(Expression> predicate, out int total, int index = 0, int size = 50); - - // Includes - IQueryable IncludeMany(params Expression>[] includes); -} -``` - -#### IUnitOfWork -Define o padrão Unit of Work para gerenciamento de transações. +## Uso -```csharp -public interface IUnitOfWork : IDisposable, IAsyncDisposable -{ - // Transaction Management - void BeginTransaction(); - void BeginTransaction(IsolationLevel isolationLevel); - Task BeginTransactionAsync(CancellationToken ct = default); - Task BeginTransactionAsync(IsolationLevel isolationLevel, CancellationToken ct = default); - - void Commit(); - void Commit(IsolationLevel isolationLevel); - Task CommitAsync(CancellationToken ct = default); - - void Rollback(); - Task RollbackAsync(CancellationToken ct = default); - - // Transaction Helpers - T InTransaction(Func work) where T : class, IEntity; - Task InTransactionAsync(Func> work, CancellationToken ct = default) where T : class, IEntity; -} -``` - -### Specification Pattern (v10.0+) - -#### ISpecification\ -Define especificações reutilizáveis para consultas complexas. +Defina suas entidades implementando `IEntity` e dependa apenas dos contratos: ```csharp -public interface ISpecification where T : class, IEntity -{ - Expression>? Criteria { get; } - Func, IOrderedQueryable>? OrderBy { get; } - List>> Includes { get; } - List IncludeStrings { get; } - int Take { get; } - int Skip { get; } - bool IsPagingEnabled { get; } - bool AsNoTracking { get; } -} -``` +using Codout.Framework.Data; +using Codout.Framework.Data.Repository; -**Exemplo de Uso:** -```csharp -public class ActiveProductsSpec : Specification +public class ClienteService(IRepository repository, IUnitOfWork unitOfWork) { - public ActiveProductsSpec() + public async Task CriarAsync(string nome, CancellationToken ct) { - AddCriteria(p => p.IsActive && !p.IsDeleted); - ApplyOrderBy(q => q.OrderBy(p => p.Name)); - AddInclude(p => p.Category); - ApplyNoTracking(); + var cliente = await repository.SaveAsync(new Cliente { Nome = nome }, ct); + await unitOfWork.CommitAsync(ct); + return cliente; } -} -``` - -### Auditing Interfaces (v10.0+) - -#### IAuditable -Para entidades que precisam de auditoria automática. - -```csharp -public interface IAuditable -{ - DateTime CreatedAt { get; set; } - string? CreatedBy { get; set; } - DateTime? UpdatedAt { get; set; } - string? UpdatedBy { get; set; } -} -``` - -#### ISoftDeletable -Para entidades com soft delete (exclusão lógica). - -```csharp -public interface ISoftDeletable -{ - bool IsDeleted { get; set; } - DateTime? DeletedAt { get; set; } - string? DeletedBy { get; set; } -} -``` -#### ICurrentUserProvider -Provider para obter o usuário atual. - -```csharp -public interface ICurrentUserProvider -{ - string? GetCurrentUserId(); + public Task> BuscarAsync(string nome, CancellationToken ct) => + repository.ToListAsync(c => c.Nome.Contains(nome), ct); } ``` -**Exemplo de Implementação:** -```csharp -public class HttpContextUserProvider : ICurrentUserProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpContextUserProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public string? GetCurrentUserId() - { - return _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - } -} -``` - -## ?? Implementando as Abstrações - -### Exemplo: Entity Framework Core - -```csharp -public class EFRepository : IRepository where T : class, IEntity -{ - protected readonly DbContext Context; - protected DbSet DbSet => Context.Set(); - - public EFRepository(DbContext context) - { - Context = context; - } - - public IQueryable All() => DbSet; - - public async Task GetAsync(Expression> predicate, CancellationToken ct = default) => - await DbSet.SingleOrDefaultAsync(predicate, ct); - - public async Task AnyAsync(Expression> predicate, CancellationToken ct = default) => - await DbSet.AnyAsync(predicate, ct); - - // ... implementar outros métodos -} -``` - -### Exemplo: Unit of Work - -```csharp -public class EFUnitOfWork : IUnitOfWork -{ - private readonly DbContext _context; - private IDbContextTransaction? _transaction; - - public EFUnitOfWork(DbContext context) - { - _context = context; - } - - public async Task BeginTransactionAsync(CancellationToken ct = default) - { - _transaction = await _context.Database.BeginTransactionAsync(ct); - } - - public async Task CommitAsync(CancellationToken ct = default) - { - await _context.SaveChangesAsync(ct); - await _transaction?.CommitAsync(ct); - } - - // ... implementar outros métodos -} -``` - -## ?? Exemplo Completo - -```csharp -// Entidade -public class Product : IEntity, IAuditable, ISoftDeletable -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - - // IAuditable - public DateTime CreatedAt { get; set; } - public string? CreatedBy { get; set; } - public DateTime? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } - - // ISoftDeletable - public bool IsDeleted { get; set; } - public DateTime? DeletedAt { get; set; } - public string? DeletedBy { get; set; } - - public IEnumerable GetSignatureProperties() => - new[] { typeof(Product).GetProperty(nameof(Id))! }; - - public bool IsTransient() => Id == 0; -} - -// Specification -public class ActiveProductsSpec : ISpecification -{ - public Expression>? Criteria => p => p.IsActive && !p.IsDeleted; - public Func, IOrderedQueryable>? OrderBy => q => q.OrderBy(p => p.Name); - public List>> Includes { get; } = new(); - public List IncludeStrings { get; } = new(); - public int Take { get; } - public int Skip { get; } - public bool IsPagingEnabled => false; - public bool AsNoTracking => true; -} - -// Uso -public class ProductService -{ - private readonly IRepository _repository; - private readonly IUnitOfWork _unitOfWork; - - public async Task CreateProductAsync(Product product, CancellationToken ct = default) - { - await _unitOfWork.BeginTransactionAsync(ct); - - try - { - await _repository.SaveAsync(product, ct); - await _unitOfWork.CommitAsync(ct); - return product; - } - catch - { - await _unitOfWork.RollbackAsync(ct); - throw; - } - } - - public async Task> GetActiveProductsAsync(CancellationToken ct = default) - { - var spec = new ActiveProductsSpec(); - return await _repository.ToListAsync(spec.Criteria!, ct); - } -} -``` - -## ?? Novidades v10.0 - -### Breaking Changes -**Nenhum!** Todas as mudanças são aditivas e retrocompatíveis. - -### Novas Interfaces -- ? `ISpecification` - Specification Pattern -- ? `IAuditable` - Auditoria automática -- ? `ISoftDeletable` - Soft delete -- ? `ICurrentUserProvider` - Provider de usuário - -### Métodos Adicionados - -#### IUnitOfWork -- ? `Task BeginTransactionAsync(CancellationToken)` -- ? `Task BeginTransactionAsync(IsolationLevel, CancellationToken)` -- ? `Task CommitAsync(CancellationToken)` -- ? `Task RollbackAsync(CancellationToken)` -- ? `Task InTransactionAsync(Func>, CancellationToken)` -- ? `IAsyncDisposable` support - -#### IRepository\ -- ? Sobrecargas com `CancellationToken` em todos métodos async -- ? `Task FirstOrDefaultAsync(Expression, CancellationToken)` -- ? `Task AnyAsync(Expression, CancellationToken)` -- ? `Task CountAsync(Expression, CancellationToken)` -- ? `Task> ToListAsync(Expression, CancellationToken)` - -### Deprecations -- ?? `IUnitOfWorkProvider` - Use DI direto em vez de factory pattern - -## ?? Compatibilidade - -| Implementação | Status | Versão | Link | -|---------------|--------|--------|------| -| **Entity Framework Core** | ? Completo | 10.0.0 | [README](../Codout.Framework.EF/README.md) | -| **NHibernate** | ? Completo | 10.0.0 | [README](../Codout.Framework.NH/README.md) | -| **MongoDB** | ? Completo | 10.0.0 | [README](../Codout.Framework.Mongo/README.md) | - -## ?? Próximos Passos - -1. Escolha uma implementação: - - [Codout.Framework.EF](../Codout.Framework.EF/README.md) - Entity Framework Core - - [Codout.Framework.NH](../Codout.Framework.NH/README.md) - NHibernate - - [Codout.Framework.Mongo](../Codout.Framework.Mongo/README.md) - MongoDB - -2. Implemente suas entidades usando `IEntity` ou `IEntity` - -3. Adicione auditoria com `IAuditable` e soft delete com `ISoftDeletable` - -4. Use `ISpecification` para queries complexas reutilizáveis - -5. Gerencie transações com `IUnitOfWork` - -## ?? Qualidade - -- ? **100% retrocompatível** com versões anteriores -- ? **Nullable Reference Types** habilitado -- ? **Documentação XML** completa -- ? **CancellationToken** em todas operações async -- ? **IAsyncDisposable** suportado -- ? **Specification Pattern** para queries complexas -- ? **Auditing** e **Soft Delete** built-in - -## ?? Comparação de Implementações - -| Recurso | EF Core | NHibernate | MongoDB | -|---------|---------|------------|---------| -| Transações | ? | ? | ? (replica set) | -| Lazy Loading | ? | ? (virtual) | ? | -| Change Tracking | ? | ? | ? | -| LINQ Support | ??? | ?? | ? | -| Migrations | ? | ? | ? | -| NoSQL | ? | ? | ? | -| Interceptors | ? | ?? | ? | -| Specifications | ? | ? | ? | - -## ?? Licença +`IRepository` expõe consultas (`All`, `AllReadOnly`, `Where`, `WherePaged`, `Get`, `FirstOrDefaultAsync`, `AnyAsync`, `CountAsync`) e comandos (`Save`, `SaveOrUpdate`, `Update`, `Delete`, `Merge`, `Refresh`) em versões síncronas e assíncronas com `CancellationToken`. `IUnitOfWork` oferece `BeginTransaction`, `Commit`, `Rollback` e `InTransactionAsync`. -Propriedade da Codout +## Pacotes relacionados ---- +- `Codout.Framework.EF` — implementação para Entity Framework Core. +- `Codout.Framework.NH` — implementação para NHibernate. +- `Codout.Framework.Mongo` — implementação para MongoDB. -**Versão:** 10.0.0 -**Status:** Estável para produção -**Target:** .NET 10 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Data/Repository/IRepository.cs b/Codout.Framework.Data/Repository/IRepository.cs index e3c6c2a..c6bafc5 100644 --- a/Codout.Framework.Data/Repository/IRepository.cs +++ b/Codout.Framework.Data/Repository/IRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -110,24 +110,24 @@ public interface IRepository : IDisposable where T : class, IEntity Task GetAsync(Expression> predicate, CancellationToken cancellationToken); /// - /// Gets an entity by its key asynchronously + /// Gets an entity by its key asynchronously, or null when not found /// - Task GetAsync(object key); - + Task GetAsync(object key); + /// - /// Gets an entity by its key asynchronously with cancellation support + /// Gets an entity by its key asynchronously with cancellation support, or null when not found /// - Task GetAsync(object key, CancellationToken cancellationToken); - + Task GetAsync(object key, CancellationToken cancellationToken); + /// - /// Loads an entity by its key asynchronously + /// Loads an entity by its key asynchronously, or null when not found /// - Task LoadAsync(object key); - + Task LoadAsync(object key); + /// - /// Loads an entity by its key asynchronously with cancellation support + /// Loads an entity by its key asynchronously with cancellation support, or null when not found /// - Task LoadAsync(object key, CancellationToken cancellationToken); + Task LoadAsync(object key, CancellationToken cancellationToken); /// /// Gets the first entity matching the predicate or null asynchronously @@ -233,4 +233,4 @@ public interface IRepository : IDisposable where T : class, IEntity IQueryable IncludeMany(params Expression>[] includes); #endregion -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Base/BaseObject.cs b/Codout.Framework.Domain/Base/BaseObject.cs index 52eb930..df87715 100644 --- a/Codout.Framework.Domain/Base/BaseObject.cs +++ b/Codout.Framework.Domain/Base/BaseObject.cs @@ -1,8 +1,12 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; +// CA1860: padrões Any() mantidos como estão para preservar o comportamento +// original byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1860 + namespace Codout.Framework.Domain.Base; /// @@ -35,14 +39,14 @@ public abstract class BaseObject /// http://www.dotnetjunkies.com/WebLog/chris.taylor/archive/2005/08/18/132026.aspx /// [ThreadStatic] - private static Dictionary> _signaturePropertiesDictionary; + private static Dictionary>? _signaturePropertiesDictionary; /// /// Determines whether the specified is equal to this instance. /// /// The to compare with the current . /// true if the specified is equal to this instance; otherwise, false. - public override bool Equals(object obj) + public override bool Equals(object? obj) { var compareTo = obj as BaseObject; @@ -79,7 +83,7 @@ public override int GetHashCode() hashCode = propertyInfos.Select(property => property.GetValue(this, null)) .Where(value => value != null) - .Aggregate(hashCode, (current, value) => current * HashMultiplier ^ value.GetHashCode()); + .Aggregate(hashCode, (current, value) => current * HashMultiplier ^ value!.GetHashCode()); if (propertyInfos.Any()) return hashCode; @@ -95,7 +99,7 @@ public override int GetHashCode() /// A collection of instances. public virtual IEnumerable GetSignatureProperties() { - IEnumerable properties; + IEnumerable? properties; // Init the signaturePropertiesDictionary here due to reasons described at // http://blogs.msdn.com/jfoscoding/archive/2006/07/18/670497.aspx @@ -169,4 +173,4 @@ protected virtual Type GetTypeUnproxied() { return GetType(); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Base/BaseObjectEqualityComparer.cs b/Codout.Framework.Domain/Base/BaseObjectEqualityComparer.cs index 03a9c34..468071c 100644 --- a/Codout.Framework.Domain/Base/BaseObjectEqualityComparer.cs +++ b/Codout.Framework.Domain/Base/BaseObjectEqualityComparer.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Codout.Framework.Domain.Base; @@ -16,8 +16,10 @@ namespace Codout.Framework.Domain.Base; /// use IEqualityComparer's GetHashCode() method. /// /// +#pragma warning disable CA1852 // Mantido não-selado para preservar a forma original do tipo. internal class BaseObjectEqualityComparer : IEqualityComparer where T : BaseObject +#pragma warning restore CA1852 { /// /// Compares the specified objects for equality. @@ -25,7 +27,7 @@ internal class BaseObjectEqualityComparer : IEqualityComparer /// The first object. /// The second object. /// true if the objects are equal, false otherwise. - public bool Equals(T firstObject, T secondObject) + public bool Equals(T? firstObject, T? secondObject) { // While SQL would return false for the following condition, returning true when // comparing two null values is consistent with the C# language @@ -33,7 +35,7 @@ public bool Equals(T firstObject, T secondObject) if (firstObject == null ^ secondObject == null) return false; - return firstObject.Equals(secondObject); + return firstObject!.Equals(secondObject); } /// @@ -45,4 +47,4 @@ public int GetHashCode(T obj) { return obj.GetHashCode(); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Base/ValueObject.cs b/Codout.Framework.Domain/Base/ValueObject.cs index 826eff2..3f7cdf7 100644 --- a/Codout.Framework.Domain/Base/ValueObject.cs +++ b/Codout.Framework.Domain/Base/ValueObject.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -23,9 +23,9 @@ public abstract class ValueObject : BaseObject /// The first value object. /// The second value object. /// The result of the operator. - public static bool operator ==(ValueObject valueObject1, ValueObject valueObject2) + public static bool operator ==(ValueObject? valueObject1, ValueObject? valueObject2) { - if ((object)valueObject1 == null) return (object)valueObject2 == null; + if ((object?)valueObject1 == null) return (object?)valueObject2 == null; return valueObject1.Equals(valueObject2); } @@ -36,7 +36,7 @@ public abstract class ValueObject : BaseObject /// The first value object. /// The second value object. /// The result of the operator. - public static bool operator !=(ValueObject valueObject1, ValueObject valueObject2) + public static bool operator !=(ValueObject? valueObject1, ValueObject? valueObject2) { return !(valueObject1 == valueObject2); } @@ -46,7 +46,7 @@ public abstract class ValueObject : BaseObject /// /// The to compare with the current . /// true if the specified is equal to this instance; otherwise, false. - public override bool Equals(object obj) + public override bool Equals(object? obj) { return base.Equals(obj); } @@ -88,4 +88,4 @@ to the properties of a value object's properties is misleading and should be rem return GetType().GetProperties(); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Codout.Framework.Domain.csproj b/Codout.Framework.Domain/Codout.Framework.Domain.csproj index 87febc1..823ad85 100644 --- a/Codout.Framework.Domain/Codout.Framework.Domain.csproj +++ b/Codout.Framework.Domain/Codout.Framework.Domain.csproj @@ -1,13 +1,21 @@ - + - 6.4.0 + 6.5.0 + + true + 6.4.0 Camada de Domínio do Framework da Codout Codout;Framework;Domain; + + + $(NoWarn.Replace(';CS1591', '')) + $(WarningsAsErrors);CS1591 + + - diff --git a/Codout.Framework.Domain/Entities/AuditEntity.cs b/Codout.Framework.Domain/Entities/AuditEntity.cs index 9ceff96..26fcaf5 100644 --- a/Codout.Framework.Domain/Entities/AuditEntity.cs +++ b/Codout.Framework.Domain/Entities/AuditEntity.cs @@ -1,13 +1,27 @@ -using System; +using System; using Codout.Framework.Domain.Interfaces; namespace Codout.Framework.Domain.Entities; +/// +/// Entidade base com identificador tipado e campos de auditoria +/// (). Herde desta classe quando a entidade precisa +/// registrar criação/alteração e o tipo do Id não é o padrão +/// de . +/// +/// Tipo do identificador da entidade (int, long, Guid, string, etc.). [Serializable] public abstract class AuditEntity : Entity, IAudit { + /// public virtual DateTime? CreatedAt { get; set; } + + /// public virtual DateTime? UpdatedAt { get; set; } - public virtual string CreatedBy { get; set; } - public virtual string UpdatedBy { get; set; } -} \ No newline at end of file + + /// + public virtual string? CreatedBy { get; set; } + + /// + public virtual string? UpdatedBy { get; set; } +} diff --git a/Codout.Framework.Domain/Entities/AuditEntityBase.cs b/Codout.Framework.Domain/Entities/AuditEntityBase.cs index ef00cf1..6ccb128 100644 --- a/Codout.Framework.Domain/Entities/AuditEntityBase.cs +++ b/Codout.Framework.Domain/Entities/AuditEntityBase.cs @@ -1,16 +1,25 @@ -using System; +using System; using Codout.Framework.Domain.Interfaces; namespace Codout.Framework.Domain.Entities; +/// +/// Entidade base padrão do framework (Id anulável, via +/// ) com campos de auditoria (). +/// Herde desta classe quando a entidade precisa registrar criação/alteração. +/// [Serializable] public abstract class AuditEntityBase : EntityBase, IAudit { + /// public virtual DateTime? CreatedAt { get; set; } + /// public virtual DateTime? UpdatedAt { get; set; } - public virtual string CreatedBy { get; set; } + /// + public virtual string? CreatedBy { get; set; } - public virtual string UpdatedBy { get; set; } -} \ No newline at end of file + /// + public virtual string? UpdatedBy { get; set; } +} diff --git a/Codout.Framework.Domain/Entities/ClientGeneratedEntity.cs b/Codout.Framework.Domain/Entities/ClientGeneratedEntity.cs index 50a4dd4..cd900f2 100644 --- a/Codout.Framework.Domain/Entities/ClientGeneratedEntity.cs +++ b/Codout.Framework.Domain/Entities/ClientGeneratedEntity.cs @@ -18,6 +18,11 @@ namespace Codout.Framework.Domain.Entities; [Serializable] public abstract class ClientGeneratedEntity : Entity, IClientGeneratedId { + /// + /// Atribui Guid.NewGuid() ao Id quando a instância ainda é transient. + /// Na materialização pelo EF o comportamento é preservado: o Id atribuído + /// aqui é sobrescrito em seguida pelos valores vindos do banco. + /// protected ClientGeneratedEntity() { // Só atribui na criação real. Na materialização do EF a instância ainda é diff --git a/Codout.Framework.Domain/Entities/Entity.cs b/Codout.Framework.Domain/Entities/Entity.cs index 12742fd..9a363d3 100644 --- a/Codout.Framework.Domain/Entities/Entity.cs +++ b/Codout.Framework.Domain/Entities/Entity.cs @@ -9,9 +9,16 @@ namespace Codout.Framework.Domain.Entities; /// +/// Classe base para entidades de domínio com identificador tipado. Implementa +/// igualdade por Id quando a entidade está persistida e por assinatura de domínio +/// (propriedades com ) quando ainda é +/// transient, com hash code estável durante o ciclo de vida da instância. +/// +/// Tipo do identificador da entidade (int, long, Guid, string, etc.). +/// /// For a discussion of this object, see /// http://devlicio.us/blogs/billy_mccafferty/archive/2007/04/25/using-equals-gethashcode-effectively.aspx -/// +/// [Serializable] public abstract class Entity : ValidatableObject, IEntity { @@ -25,7 +32,7 @@ public abstract class Entity : ValidatableObject, IEntity private const int HashMultiplier = 31; private int? _cachedHashcode; - private TId _id; + private TId _id = default!; /// /// Gets or sets the ID. @@ -61,6 +68,13 @@ public virtual bool IsTransient() return Id == null || Id.Equals(default(TId)); } + /// + /// Define explicitamente o Id da entidade (assigned/client-generated id). + /// Use quando a identidade é atribuída pela aplicação (ex.: + /// ); em cenários store-generated o Id é + /// preenchido pela infraestrutura de persistência e este método não deve ser chamado. + /// + /// Identificador a atribuir à entidade. public virtual void SetId(TId id) { Id = id; @@ -71,7 +85,7 @@ public virtual void SetId(TId id) /// /// The to compare with the current . /// true if the specified is equal to this instance; otherwise, false. - public override bool Equals(object obj) + public override bool Equals(object? obj) { var compareTo = obj as Entity; @@ -112,7 +126,7 @@ public override int GetHashCode() // identically valued properties, even if they're of two different types, // so we include the object's type in the hash calculation var hashCode = GetType().GetHashCode(); - _cachedHashcode = hashCode * HashMultiplier ^ Id.GetHashCode(); + _cachedHashcode = hashCode * HashMultiplier ^ Id!.GetHashCode(); } return _cachedHashcode.Value; @@ -143,6 +157,6 @@ protected override IEnumerable GetTypeSpecificSignatureProperties( /// private bool HasSameNonDefaultIdAs(Entity compareTo) { - return !IsTransient() && !compareTo.IsTransient() && Id.Equals(compareTo.Id); + return !IsTransient() && !compareTo.IsTransient() && Id!.Equals(compareTo.Id); } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Entities/EntityBase.cs b/Codout.Framework.Domain/Entities/EntityBase.cs index 7c2e934..fd1840e 100644 --- a/Codout.Framework.Domain/Entities/EntityBase.cs +++ b/Codout.Framework.Domain/Entities/EntityBase.cs @@ -1,6 +1,12 @@ -using System; +using System; namespace Codout.Framework.Domain.Entities; +/// +/// Entidade base padrão do framework, com identificador +/// anulável (null enquanto transient). Use quando o Id é gerado pelo +/// store (ex.: ValueGeneratedOnAdd no EF); para Id atribuído pela +/// aplicação na criação, prefira . +/// [Serializable] -public abstract class EntityBase : Entity; \ No newline at end of file +public abstract class EntityBase : Entity; diff --git a/Codout.Framework.Domain/Entities/Events/EntityChanged.cs b/Codout.Framework.Domain/Entities/Events/EntityChanged.cs index 95ca709..26b376e 100644 --- a/Codout.Framework.Domain/Entities/Events/EntityChanged.cs +++ b/Codout.Framework.Domain/Entities/Events/EntityChanged.cs @@ -1,9 +1,19 @@ -using Codout.Framework.Data.Entity; +using Codout.Framework.Data.Entity; namespace Codout.Framework.Domain.Entities.Events; +/// +/// Evento de domínio que sinaliza que uma entidade existente foi alterada +/// (atualizada). Publique/assine este evento para reagir a atualizações sem +/// acoplar o código de negócio à infraestrutura de persistência. +/// +/// Tipo da entidade alterada. +/// Entidade que foi alterada. public class EntityChanged(T entity) where T : IEntity { + /// + /// Entidade alterada, associada ao evento. + /// public T Entity { get; set; } = entity; -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Entities/Events/EntityCreated.cs b/Codout.Framework.Domain/Entities/Events/EntityCreated.cs index 58f36e7..8a50595 100644 --- a/Codout.Framework.Domain/Entities/Events/EntityCreated.cs +++ b/Codout.Framework.Domain/Entities/Events/EntityCreated.cs @@ -1,9 +1,19 @@ -using Codout.Framework.Data.Entity; +using Codout.Framework.Data.Entity; namespace Codout.Framework.Domain.Entities.Events; +/// +/// Evento de domínio que sinaliza que uma nova entidade foi criada. +/// Publique/assine este evento para reagir a criações sem acoplar o código +/// de negócio à infraestrutura de persistência. +/// +/// Tipo da entidade criada. +/// Entidade que foi criada. public class EntityCreated(T entity) where T : IEntity { + /// + /// Entidade criada, associada ao evento. + /// public T Entity { get; set; } = entity; -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Entities/Events/EntityDeleted.cs b/Codout.Framework.Domain/Entities/Events/EntityDeleted.cs index 6f4aca3..f742c76 100644 --- a/Codout.Framework.Domain/Entities/Events/EntityDeleted.cs +++ b/Codout.Framework.Domain/Entities/Events/EntityDeleted.cs @@ -1,9 +1,19 @@ -using Codout.Framework.Data.Entity; +using Codout.Framework.Data.Entity; namespace Codout.Framework.Domain.Entities.Events; +/// +/// Evento de domínio que sinaliza que uma entidade foi excluída. +/// Publique/assine este evento para reagir a exclusões sem acoplar o código +/// de negócio à infraestrutura de persistência. +/// +/// Tipo da entidade excluída. +/// Entidade que foi excluída. public class EntityDeleted(T entity) where T : IEntity { + /// + /// Entidade excluída, associada ao evento. + /// public T Entity { get; set; } = entity; -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Interfaces/IAudit.cs b/Codout.Framework.Domain/Interfaces/IAudit.cs index 3f908e4..4d3681d 100644 --- a/Codout.Framework.Domain/Interfaces/IAudit.cs +++ b/Codout.Framework.Domain/Interfaces/IAudit.cs @@ -1,11 +1,36 @@ -using System; +using System; namespace Codout.Framework.Domain.Interfaces; +/// +/// Contrato de auditoria básica para entidades de domínio: registra quando e por +/// quem a entidade foi criada e alterada pela última vez. Os campos normalmente +/// são preenchidos pela infraestrutura de persistência (interceptors/handlers de +/// auditoria), não pelo código de negócio. +/// public interface IAudit { + /// + /// Data e hora em que a entidade foi criada; null enquanto a entidade + /// ainda não foi persistida (ou quando a auditoria não está habilitada). + /// DateTime? CreatedAt { get; set; } + + /// + /// Data e hora da última alteração da entidade; null quando ela nunca + /// foi alterada após a criação. + /// DateTime? UpdatedAt { get; set; } - string CreatedBy { get; set; } - string UpdatedBy { get; set; } -} \ No newline at end of file + + /// + /// Identificador do usuário que criou a entidade; null quando + /// desconhecido (ex.: processo sem usuário autenticado). + /// + string? CreatedBy { get; set; } + + /// + /// Identificador do usuário responsável pela última alteração; null + /// quando desconhecido. + /// + string? UpdatedBy { get; set; } +} diff --git a/Codout.Framework.Domain/Interfaces/ISequence.cs b/Codout.Framework.Domain/Interfaces/ISequence.cs index 84585d0..c85431d 100644 --- a/Codout.Framework.Domain/Interfaces/ISequence.cs +++ b/Codout.Framework.Domain/Interfaces/ISequence.cs @@ -1,6 +1,14 @@ -namespace Codout.Framework.Domain.Interfaces; +namespace Codout.Framework.Domain.Interfaces; +/// +/// Contrato para entidades que possuem um código sequencial numérico legível +/// (ex.: número de pedido ou de nota), geralmente gerado pela infraestrutura +/// de persistência (sequence/identity), independente da chave primária. +/// public interface ISequence { + /// + /// Código sequencial da entidade. + /// long Code { get; set; } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/Interfaces/ISoftDeletable.cs b/Codout.Framework.Domain/Interfaces/ISoftDeletable.cs index 6189fa9..8e5df4b 100644 --- a/Codout.Framework.Domain/Interfaces/ISoftDeletable.cs +++ b/Codout.Framework.Domain/Interfaces/ISoftDeletable.cs @@ -1,8 +1,17 @@ -using System; +using System; namespace Codout.Framework.Domain.Interfaces; +/// +/// Contrato para entidades com exclusão lógica (soft delete): em vez de remover +/// o registro fisicamente, a infraestrutura de persistência marca a data de +/// exclusão e passa a filtrar a entidade das consultas. +/// public interface ISoftDeletable { + /// + /// Data e hora da exclusão lógica; null enquanto a entidade está ativa + /// (não excluída). + /// DateTime? DeletedAt { get; set; } -} \ No newline at end of file +} diff --git a/Codout.Framework.Domain/README.md b/Codout.Framework.Domain/README.md new file mode 100644 index 0000000..ae92977 --- /dev/null +++ b/Codout.Framework.Domain/README.md @@ -0,0 +1,57 @@ +# Codout.Framework.Domain + +Camada de domínio do Codout.Framework: classes base para entidades, value objects e contratos de auditoria/soft delete, com `Equals`/`GetHashCode` baseados em identidade ou em assinatura de domínio. + +## Instalação + +```bash +dotnet add package Codout.Framework.Domain +``` + +## Uso + +Declare uma entidade herdando de `Entity` (o `Id` é `virtual` e o setter é `protected`; use `SetId` quando precisar atribuir manualmente). Para o caso comum de PK `Guid?`, herde de `EntityBase`; para Guid gerado pela aplicação no construtor, use `ClientGeneratedEntity`; para campos de auditoria (`CreatedAt`, `UpdatedAt`, `CreatedBy`, `UpdatedBy`), use `AuditEntityBase` ou `AuditEntity`: + +```csharp +using Codout.Framework.Domain.Base; +using Codout.Framework.Domain.Entities; + +public class Cliente : EntityBase // Entity +{ + [DomainSignature] // participa do Equals/GetHashCode enquanto a entidade é transient + public virtual string Documento { get; set; } + + public virtual string Nome { get; set; } +} + +public class Pedido : AuditEntityBase // EntityBase + IAudit +{ + public virtual decimal Total { get; set; } +} +``` + +Value objects herdam de `ValueObject` e comparam por todas as propriedades (não use `[DomainSignature]` neles — lança `InvalidOperationException`): + +```csharp +using Codout.Framework.Domain.Base; + +public class Endereco : ValueObject +{ + public string Logradouro { get; set; } + public string Cidade { get; set; } + public string Cep { get; set; } +} +``` + +As interfaces `IAudit`, `ISoftDeletable` (`DeletedAt`) e `ISequence` (`Code`) ficam em `Codout.Framework.Domain.Interfaces` e são reconhecidas pelas implementações de persistência do framework. + +## Pacotes relacionados + +- [Codout.Framework.Data](https://www.nuget.org/packages/Codout.Framework.Data) — abstrações `IRepository` / `IUnitOfWork` (dependência deste pacote). +- [Codout.Framework.EF](https://www.nuget.org/packages/Codout.Framework.EF) — persistência com Entity Framework Core. +- [Codout.Framework.NH](https://www.nuget.org/packages/Codout.Framework.NH) — persistência com NHibernate. +- [Codout.Framework.Mongo](https://www.nuget.org/packages/Codout.Framework.Mongo) — persistência com MongoDB. +- [Codout.Framework.Application](https://www.nuget.org/packages/Codout.Framework.Application) — camada de aplicação/serviços. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Dto.Shared/EntityDto`1.cs b/Codout.Framework.Dto.Shared/EntityDto`1.cs index fe0dd01..2832128 100644 --- a/Codout.Framework.Dto.Shared/EntityDto`1.cs +++ b/Codout.Framework.Dto.Shared/EntityDto`1.cs @@ -1,6 +1,6 @@ -namespace Codout.Framework.Api.Client; +namespace Codout.Framework.Api.Client; public class EntityDto : IEntityDto { - public TId Id { get; set; } + public TId Id { get; set; } = default!; } \ No newline at end of file diff --git a/Codout.Framework.EF/Codout.Framework.EF.csproj b/Codout.Framework.EF/Codout.Framework.EF.csproj index 9f3f594..48bf1ae 100644 --- a/Codout.Framework.EF/Codout.Framework.EF.csproj +++ b/Codout.Framework.EF/Codout.Framework.EF.csproj @@ -1,7 +1,10 @@  - 6.4.0 + 6.5.0 + + true + 6.4.0 Implementação da interface Codout.Framework.DAL para EntityFrameworkCore Codout;Framework;ORM;DAL;EntityFrameworkCore;Repository;UnitOfWork;Specification true diff --git a/Codout.Framework.EF/EFRepository.cs b/Codout.Framework.EF/EFRepository.cs index f1afe20..22f945a 100644 --- a/Codout.Framework.EF/EFRepository.cs +++ b/Codout.Framework.EF/EFRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -35,10 +35,10 @@ public IQueryable WherePaged(Expression> predicate, out int tot } public T Get(Expression> predicate) => - DbSet.SingleOrDefault(predicate); + DbSet.SingleOrDefault(predicate)!; public T Get(object key) => - DbSet.Find(key); + DbSet.Find(key)!; public T Load(object key) => Get(key); @@ -75,7 +75,7 @@ public T SaveOrUpdate(T entity) // Returns true if no DB lookup is needed (already tracked, no PK metadata, or key unset → Add). // When false, keyValues contains the primary-key values to look up. - private bool TryResolveTrackedOrAdd(T entity, out object[] keyValues) + private bool TryResolveTrackedOrAdd(T entity, out object?[]? keyValues) { keyValues = null; @@ -108,7 +108,7 @@ private bool TryResolveTrackedOrAdd(T entity, out object[] keyValues) return false; } - private void ApplySaveOrUpdate(T entity, T existing) + private void ApplySaveOrUpdate(T entity, T? existing) { if (existing == null) { @@ -130,7 +130,7 @@ private void ApplySaveOrUpdate(T entity, T existing) Context.Entry(existing).CurrentValues.SetValues(entity); } - private static bool IsUnsetKeyValue(object value) + private static bool IsUnsetKeyValue(object? value) { if (value is null) return true; var type = value.GetType(); @@ -171,20 +171,20 @@ public Task RefreshAsync(T entity, CancellationToken cancellationToken) } public async Task GetAsync(Expression> predicate) => - await DbSet.SingleOrDefaultAsync(predicate); + (await DbSet.SingleOrDefaultAsync(predicate))!; public async Task GetAsync(Expression> predicate, CancellationToken cancellationToken) => - await DbSet.SingleOrDefaultAsync(predicate, cancellationToken); + (await DbSet.SingleOrDefaultAsync(predicate, cancellationToken))!; - public async Task GetAsync(object key) => + public async Task GetAsync(object key) => await DbSet.FindAsync(key); - public async Task GetAsync(object key, CancellationToken cancellationToken) => + public async Task GetAsync(object key, CancellationToken cancellationToken) => await DbSet.FindAsync([key], cancellationToken); - public Task LoadAsync(object key) => GetAsync(key); + public Task LoadAsync(object key) => GetAsync(key); - public Task LoadAsync(object key, CancellationToken cancellationToken) => GetAsync(key, cancellationToken); + public Task LoadAsync(object key, CancellationToken cancellationToken) => GetAsync(key, cancellationToken); public Task DeleteAsync(T entity) { @@ -262,7 +262,7 @@ public Task MergeAsync(T entity, CancellationToken cancellationToken) } public async Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken = default) => - await DbSet.FirstOrDefaultAsync(predicate, cancellationToken); + (await DbSet.FirstOrDefaultAsync(predicate, cancellationToken))!; public async Task AnyAsync(Expression> predicate, CancellationToken cancellationToken = default) => await DbSet.AnyAsync(predicate, cancellationToken); diff --git a/Codout.Framework.EF/EFUnitOfWork.cs b/Codout.Framework.EF/EFUnitOfWork.cs index 6e1a3a0..51643e0 100644 --- a/Codout.Framework.EF/EFUnitOfWork.cs +++ b/Codout.Framework.EF/EFUnitOfWork.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Data; using System.Threading; using System.Threading.Tasks; @@ -7,6 +7,10 @@ using Codout.Framework.Data.Entity; using Microsoft.EntityFrameworkCore.Storage; +// CA1510: guard clauses originais mantidos para preservar o comportamento +// byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1510 + namespace Codout.Framework.EF; /// @@ -237,4 +241,4 @@ public async ValueTask DisposeAsync() } #endregion IDisposable Support -} \ No newline at end of file +} diff --git a/Codout.Framework.EF/README.md b/Codout.Framework.EF/README.md index 8e2a0f1..a9d9ba6 100644 --- a/Codout.Framework.EF/README.md +++ b/Codout.Framework.EF/README.md @@ -1,440 +1,43 @@ -# Codout.Framework.EF +# Codout.Framework.EF -Implementação enterprise do padrão Repository e Unit of Work para Entity Framework Core 10. +Implementação dos contratos `IRepository` e `IUnitOfWork` de `Codout.Framework.Data` para Entity Framework Core, com builder fluente de configuração, interceptors de auditoria e soft delete. -## 🚀 Recursos - -- ✅ **Repository Pattern** com suporte async/await completo -- ✅ **Unit of Work Pattern** com transações robustas -- ✅ **Specification Pattern** para queries complexas reutilizáveis -- ✅ **Interceptors** para auditoria e soft delete automáticos -- ✅ **Builder Fluente** para configuração avançada -- ✅ **CancellationToken** em todas operações async -- ✅ **Retry Policies** integradas -- ✅ **IAsyncDisposable** suportado -- ✅ **Nullable Reference Types** habilitado - -## 📦 Instalação +## Instalação ```bash dotnet add package Codout.Framework.EF ``` -## 🔧 Configuração Básica (Legado) - -```csharp -// Program.cs ou Startup.cs -#pragma warning disable CS0618 -services.AddEFCore(configuration); -#pragma warning restore CS0618 -``` - -## 🔧 Configuração Avançada (Recomendado) - -```csharp -services.AddEFCore(configuration) - .WithConnectionStringFromConfiguration("DefaultConnection") - .UseSqlServer() - .EnableAuditing() - .EnableSoftDelete() - .EnableRetryOnFailure(maxRetryCount: 5) - .EnableDetailedErrors() - .Build(); -``` - -### Configuração para Desenvolvimento - -```csharp -#if DEBUG -services.AddEFCore(configuration) - .WithConnectionStringFromConfiguration() - .UseSqlServer() - .EnableSensitiveDataLogging() - .EnableDetailedErrors() - .Build(); -#endif -``` - -### Configuração com Connection String Manual +## Uso -```csharp -services.AddEFCore(configuration) - .WithConnectionString("Server=localhost;Database=MyDb;...") - .UseSqlServer() - .Build(); -``` - -## 💡 Uso Básico - -### Repository +Registre o `DbContext` com o builder fluente `AddEFCore`: ```csharp -public class ProductRepository : EFRepository -{ - public ProductRepository(MyDbContext context) : base(context) - { - } - - public async Task> GetActiveProductsAsync(CancellationToken ct = default) - { - return await ToListAsync(p => p.IsActive, ct); - } - - public async Task HasActiveProductsAsync(CancellationToken ct = default) - { - return await AnyAsync(p => p.IsActive, ct); - } - - public async Task CountActiveProductsAsync(CancellationToken ct = default) - { - return await CountAsync(p => p.IsActive, ct); - } -} -``` - -### Unit of Work - -```csharp -public class MyUnitOfWork : EFUnitOfWork -{ - public MyUnitOfWork(MyDbContext context) : base(context) - { - } -} - -// Uso com async/await -await using var uow = new MyUnitOfWork(context); -await uow.BeginTransactionAsync(ct); - -try -{ - await repository.SaveAsync(product, ct); - await uow.CommitAsync(ct); -} -catch -{ - await uow.RollbackAsync(ct); - throw; -} -``` - -### InTransaction Helper - -```csharp -var product = await uow.InTransactionAsync(async () => -{ - var newProduct = new Product { Name = "Test" }; - await repository.SaveAsync(newProduct); - return newProduct; -}, ct); -``` - -## 🎯 Specification Pattern - -### Criando Specifications - -```csharp -using Codout.Framework.EF.Specifications; -using Codout.Framework.Data.Specifications; - -public class ActiveProductsSpecification : Specification -{ - public ActiveProductsSpecification() - { - AddCriteria(p => p.IsActive && !p.IsDeleted); - ApplyOrderBy(q => q.OrderBy(p => p.Name)); - AddInclude(p => p.Category); - ApplyNoTracking(); - } -} - -public class ProductsByCategorySpecification : Specification -{ - public ProductsByCategorySpecification(int categoryId, int page, int pageSize) - { - AddCriteria(p => p.CategoryId == categoryId); - ApplyOrderBy(q => q.OrderByDescending(p => p.CreatedAt)); - AddInclude(p => p.Category); - AddInclude("Reviews"); // String-based include - ApplyPaging((page - 1) * pageSize, pageSize); - } -} -``` - -### Usando Specifications - -```csharp -var spec = new ActiveProductsSpecification(); -var products = await repository.ListAsync(spec, ct); - -var categorySpec = new ProductsByCategorySpecification(categoryId: 5, page: 1, pageSize: 20); -var pagedProducts = await repository.ListAsync(categorySpec, ct); - -var count = await repository.CountAsync(spec, ct); -var exists = await repository.AnyAsync(spec, ct); -var first = await repository.FirstOrDefaultAsync(spec, ct); -``` - -## 🔍 Auditoria Automática - -### Implementar IAuditable - -```csharp -using Codout.Framework.Data.Auditing; - -public class Product : IEntity, IAuditable -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Auditoria automática - preenchido pelo interceptor - public DateTime CreatedAt { get; set; } - public string? CreatedBy { get; set; } - public DateTime? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } - - public bool IsTransient() => Id == 0; - public IEnumerable GetSignatureProperties() => - new[] { typeof(Product).GetProperty(nameof(Id))! }; -} -``` - -### Configurar Provider de Usuário - -```csharp -using Codout.Framework.Data.Auditing; - -public class HttpContextUserProvider : ICurrentUserProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpContextUserProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public string? GetCurrentUserId() - { - return _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; - } -} +using Codout.Framework.EF; -// Registrar -services.AddHttpContextAccessor(); -services.AddSingleton(); -``` - -## 🗑️ Soft Delete Automático - -```csharp -using Codout.Framework.Data.Auditing; - -public class Product : IEntity, ISoftDeletable -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - - // Soft delete automático - preenchido pelo interceptor - public bool IsDeleted { get; set; } - public DateTime? DeletedAt { get; set; } - public string? DeletedBy { get; set; } - - public bool IsTransient() => Id == 0; - public IEnumerable GetSignatureProperties() => - new[] { typeof(Product).GetProperty(nameof(Id))! }; -} - -// Configurar no DbContext -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - // Query filter global para excluir registros deletados - modelBuilder.Entity() - .HasQueryFilter(p => !p.IsDeleted); -} -``` - -## 🔄 Operações com CancellationToken - -```csharp -var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - -// Métodos auxiliares com CancellationToken -var product = await repository.GetAsync(p => p.Id == 1, cts.Token); -var first = await repository.FirstOrDefaultAsync(p => p.IsActive, cts.Token); -var count = await repository.CountAsync(p => p.IsActive, cts.Token); -var exists = await repository.AnyAsync(p => p.Name == "Test", cts.Token); -var list = await repository.ToListAsync(p => p.Price > 100, cts.Token); - -// Transações com CancellationToken -await uow.BeginTransactionAsync(cts.Token); -await uow.CommitAsync(cts.Token); -await uow.RollbackAsync(cts.Token); -``` - -## 📊 Operações Avançadas - -### Include com Navigation Properties - -```csharp -var products = repository - .IncludeMany( - p => p.Category, - p => p.Reviews - ) - .Where(p => p.IsActive) - .ToList(); - -// String-based includes -var productsWithStrings = repository - .IncludeMany("Category", "Reviews.User") - .Where(p => p.IsActive) - .ToList(); -``` - -### Paginação - -```csharp -var products = repository.WherePaged( - predicate: p => p.IsActive, - out int total, - index: 0, - size: 20 -); - -Console.WriteLine($"Total: {total}, Current Page: {products.Count()}"); -``` - -### Read-Only Queries - -```csharp -// Melhor performance para queries read-only (AsNoTracking automático) -var products = repository - .WhereReadOnly(p => p.CategoryId == 1) - .ToList(); - -var allReadOnly = repository.AllReadOnly(); -``` - -### Operações CRUD Completas - -```csharp -// Save -var saved = await repository.SaveAsync(product, ct); - -// Update -await repository.UpdateAsync(product, ct); - -// SaveOrUpdate (detecta se é novo ou existente) -var savedOrUpdated = await repository.SaveOrUpdateAsync(product, ct); - -// Delete -await repository.DeleteAsync(product, ct); - -// Delete com predicate -await repository.DeleteAsync(p => p.IsActive == false, ct); - -// Merge (reattach detached entity) -var merged = await repository.MergeAsync(product, ct); - -// Refresh (reload from database) -var refreshed = await repository.RefreshAsync(product, ct); -``` - -## 🏗️ Arquitetura Recomendada - -``` -📁 MyProject.Data -├── 📁 Context -│ └── MyDbContext.cs -├── 📁 Repositories -│ ├── IProductRepository.cs -│ └── ProductRepository.cs -├── 📁 Specifications -│ ├── ActiveProductsSpecification.cs -│ └── ProductsByCategorySpecification.cs -├── 📁 UnitOfWork -│ ├── IMyUnitOfWork.cs -│ └── MyUnitOfWork.cs -└── 📁 Entities - └── Product.cs -``` - -## 🎓 Melhores Práticas - -1. **Use CancellationToken** em todas operações async -2. **Use Specifications** para queries complexas reutilizáveis -3. **Evite expor IQueryable** fora da camada de dados -4. **Use AsNoTracking** para queries read-only (via `WhereReadOnly`) -5. **Implemente IAuditable** para auditoria automática -6. **Use ISoftDeletable** em vez de delete físico -7. **Configure retry policies** para resiliência -8. **Use scoped lifetime** para DbContext (padrão) -9. **Configure query filters** no DbContext para soft delete -10. **Use `WithConnectionStringFromConfiguration`** para ambientes diferentes -11. **Use `await using`** para dispose automático do UnitOfWork -12. **Trate exceções** apropriadamente em transações - -## 📝 Breaking Changes - -### Migração do Método Legado - -```csharp -// ❌ Legado (deprecated) -services.AddEFCore(configuration); - -// ✅ Novo (recomendado) -services.AddEFCore(configuration) - .WithConnectionStringFromConfiguration() +builder.Services + .AddEFCore(builder.Configuration) + .WithConnectionStringFromConfiguration("DefaultConnection") .UseSqlServer() + .EnableRetryOnFailure() .Build(); ``` -### Interfaces Movidas +Use `EFRepository` e uma especialização de `EFUnitOfWork` (classe abstrata): ```csharp -// ❌ Antes -using Codout.Framework.EF.Interceptors; +public class MeuUnitOfWork(MeuDbContext context) : EFUnitOfWork(context); -// ✅ Agora (interfaces no projeto base) -using Codout.Framework.Data.Auditing; - -public class Product : IAuditable, ISoftDeletable { } +var repository = new EFRepository(context); +var cliente = await repository.SaveAsync(new Cliente { Nome = "Maria" }, ct); +await unitOfWork.CommitAsync(ct); // chama SaveChanges e confirma a transação ``` -## 🆕 Novidades v10.0 - -### Novos Recursos -- ✨ **Specification Pattern** completo -- ✨ **Métodos auxiliares**: `FirstOrDefaultAsync`, `AnyAsync`, `CountAsync`, `ToListAsync` -- ✨ **IAsyncDisposable** no UnitOfWork -- ✨ **Interfaces de auditoria** no `Codout.Framework.Data` -- ✨ **Builder fluente** (`EFCoreBuilder`) -- ✨ **Interceptors** para auditoria e soft delete - -### Correções Críticas -- 🐛 **Transações corrigidas** - `Commit()` agora requer `BeginTransaction()` explícito -- 🐛 **Dispose do Repository** - Não faz mais dispose do DbContext -- 🐛 **Exception handling** - Não engole mais exceções em transações - -### Melhorias -- ⚡ **Performance** com `AsNoTracking` e `WhereReadOnly` -- 📚 **Documentação XML** completa -- 🔒 **Nullable reference types** habilitado -- 🎯 **CancellationToken** em todas operações async - -## 🔗 Links Relacionados - -- [Codout.Framework.Data](../Codout.Framework.Data/README.md) - Abstrações base -- [Codout.Framework.Mongo](../Codout.Framework.Mongo/README.md) - Implementação MongoDB -- [Codout.Framework.NH](../Codout.Framework.NH/README.md) - Implementação NHibernate - -## 📄 Licença +`EFRepository` recebe um `DbContext` no construtor e expõe `All`, `AllReadOnly` (AsNoTracking), `Where`, `WherePaged`, `Get`, `SaveAsync`, `SaveOrUpdateAsync`, `UpdateAsync`, `DeleteAsync` e `IncludeMany`. -Propriedade da Codout +## Pacotes relacionados ---- +- `Codout.Framework.Data` — contratos implementados por este pacote. +- `Codout.Framework.NH` e `Codout.Framework.Mongo` — implementações alternativas dos mesmos contratos. -**Versão:** 10.0.0 -**Status:** Estável para produção -**Target:** .NET 10 -**EF Core:** 10.0.0 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Mongo/Codout.Framework.Mongo.csproj b/Codout.Framework.Mongo/Codout.Framework.Mongo.csproj index b5d43c5..f278b26 100644 --- a/Codout.Framework.Mongo/Codout.Framework.Mongo.csproj +++ b/Codout.Framework.Mongo/Codout.Framework.Mongo.csproj @@ -1,7 +1,10 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Implementação da interface Codout.Framework.Data para MongoDB Codout;Framework;ORM;DAL;MongoDB;Repository;UnitOfWork; true @@ -13,7 +16,7 @@ - + diff --git a/Codout.Framework.Mongo/MongoRepository.cs b/Codout.Framework.Mongo/MongoRepository.cs index b710d23..70941b2 100644 --- a/Codout.Framework.Mongo/MongoRepository.cs +++ b/Codout.Framework.Mongo/MongoRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -52,7 +52,9 @@ public virtual IQueryable WhereReadOnly(Expression> predicate) /// /// Retorna uma lista de objetos do repositório de acordo com o filtro e com opção de paginação /// +#pragma warning disable CA1725 // Nome do parâmetro preservado para não quebrar chamadas com argumento nomeado. public virtual IQueryable WherePaged(Expression> filter, out int total, int index = 0, int size = 50) +#pragma warning restore CA1725 { var skipCount = index * size; @@ -74,7 +76,7 @@ public virtual IQueryable WherePaged(Expression> filter, out in /// public virtual T Get(Expression> predicate) { - return Where(predicate).SingleOrDefault(); + return Where(predicate).SingleOrDefault()!; } /// @@ -83,7 +85,7 @@ public virtual T Get(Expression> predicate) public virtual T Get(object key) { if (!ObjectId.TryParse(key?.ToString(), out var id)) - return null; + return null!; return _collection.Find(Builders.Filter.Eq("_id", BsonValue.Create(id))).FirstOrDefault(); } @@ -190,12 +192,12 @@ public virtual async Task GetAsync(Expression> predicate, Cance .FirstOrDefaultAsync(cancellationToken); } - public virtual async Task GetAsync(object key) + public virtual async Task GetAsync(object key) { return await GetAsync(key, CancellationToken.None); } - public virtual async Task GetAsync(object key, CancellationToken cancellationToken) + public virtual async Task GetAsync(object key, CancellationToken cancellationToken) { if (!ObjectId.TryParse(key?.ToString(), out var id)) return null; @@ -206,12 +208,12 @@ public virtual async Task GetAsync(object key, CancellationToken cancellation .FirstOrDefaultAsync(cancellationToken); } - public virtual Task LoadAsync(object key) + public virtual Task LoadAsync(object key) { return GetAsync(key); } - public virtual Task LoadAsync(object key, CancellationToken cancellationToken) + public virtual Task LoadAsync(object key, CancellationToken cancellationToken) { return GetAsync(key, cancellationToken); } @@ -336,7 +338,7 @@ public virtual Task RefreshAsync(T entity) public virtual async Task RefreshAsync(T entity, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entity); - return await GetAsync(GetIdValue(entity), cancellationToken); + return (await GetAsync(GetIdValue(entity), cancellationToken))!; } #endregion @@ -383,9 +385,9 @@ protected virtual void Dispose(bool disposing) private static object GetIdValue(T entity) { var key = entity.GetType().GetTypeInfo().GetProperty("Id")?.GetValue(entity); - + if (key == null) - return null; + return null!; if (ObjectId.TryParse(key.ToString(), out var id)) return id; @@ -394,4 +396,4 @@ private static object GetIdValue(T entity) } #endregion -} \ No newline at end of file +} diff --git a/Codout.Framework.Mongo/MongoUnitOfWork.cs b/Codout.Framework.Mongo/MongoUnitOfWork.cs index 8018348..8fca051 100644 --- a/Codout.Framework.Mongo/MongoUnitOfWork.cs +++ b/Codout.Framework.Mongo/MongoUnitOfWork.cs @@ -6,6 +6,10 @@ using Codout.Framework.Data.Entity; using MongoDB.Driver; +// CA1510: guard clauses originais mantidos para preservar o comportamento +// byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1510 + namespace Codout.Framework.Mongo; /// diff --git a/Codout.Framework.Mongo/README.md b/Codout.Framework.Mongo/README.md index 411a884..e599bf7 100644 --- a/Codout.Framework.Mongo/README.md +++ b/Codout.Framework.Mongo/README.md @@ -1,369 +1,40 @@ # Codout.Framework.Mongo -Implementação do padrão Repository e Unit of Work para MongoDB. +Implementação dos contratos `IRepository` e `IUnitOfWork` de `Codout.Framework.Data` para MongoDB, sobre o driver oficial `MongoDB.Driver`. -## ?? Recursos - -- ? **Repository Pattern** com MongoDB -- ? **Unit of Work Pattern** com suporte a transações (replica set) -- ? **Async/await completo** com CancellationToken -- ? **Métodos auxiliares** (FirstOrDefault, Any, Count, ToList) -- ? **IAsyncDisposable** suportado -- ? **Nullable Reference Types** habilitado -- ? **Documentação XML** completa - -## ?? Instalação +## Instalação ```bash dotnet add package Codout.Framework.Mongo ``` -## ?? Requisitos - -### Transações MongoDB -Para usar transações (Unit of Work), você precisa: -- ? MongoDB 4.0+ com **replica set** configurado -- ? Ou MongoDB 4.2+ com **sharded cluster** - -**Nota**: Transações NÃO funcionam em MongoDB standalone. - -## ?? Configuração - -### appsettings.json - -```json -{ - "MongoDB": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "MyDatabase" - } -} -``` - -### Program.cs - -```csharp -using Codout.Framework.Mongo; -using Codout.Framework.Data; -using MongoDB.Driver; - -// Configurar MongoDB Client -services.AddSingleton(sp => -{ - var connectionString = configuration["MongoDB:ConnectionString"]; - return new MongoClient(connectionString); -}); - -// Configurar Database -services.AddScoped(sp => -{ - var client = sp.GetRequiredService(); - var databaseName = configuration["MongoDB:DatabaseName"]; - return client.GetDatabase(databaseName); -}); - -// Configurar Context -services.AddScoped(); - -// Configurar Unit of Work -services.AddScoped(); -``` - -## ?? Uso Básico - -### Entidades - -```csharp -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using Codout.Framework.Data.Entity; -using System.Reflection; - -public class Product : IEntity -{ - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string Id { get; set; } = string.Empty; - - public string Name { get; set; } = string.Empty; - public decimal Price { get; set; } - public bool IsActive { get; set; } - - public bool IsTransient() => string.IsNullOrEmpty(Id); - - public IEnumerable GetSignatureProperties() - { - return new[] { typeof(Product).GetProperty(nameof(Id))! }; - } -} -``` - -### Repository - -```csharp -public class ProductRepository : MongoRepository -{ - public ProductRepository(MongoDbContext context) : base(context) - { - } - - public async Task> GetActiveProductsAsync(CancellationToken ct = default) - { - return await ToListAsync(p => p.IsActive, ct); - } - - public async Task HasActiveProductsAsync(CancellationToken ct = default) - { - return await AnyAsync(p => p.IsActive, ct); - } - - public async Task CountActiveProductsAsync(CancellationToken ct = default) - { - return await CountAsync(p => p.IsActive, ct); - } -} -``` - -### Unit of Work (com Transações) - -```csharp -public class ProductService -{ - private readonly MongoRepository _repository; - private readonly IUnitOfWork _unitOfWork; - - public async Task CreateProductAsync(Product product, CancellationToken ct = default) - { - await _unitOfWork.BeginTransactionAsync(ct); - - try - { - await _repository.SaveAsync(product, ct); - await _unitOfWork.CommitAsync(ct); - return product; - } - catch - { - await _unitOfWork.RollbackAsync(ct); - throw; - } - } -} -``` - -### InTransaction Helper - -```csharp -var product = await _unitOfWork.InTransactionAsync(async () => -{ - var newProduct = new Product { Name = "Test", Price = 100 }; - await _repository.SaveAsync(newProduct, ct); - return newProduct; -}, ct); -``` - -## ?? Operações Disponíveis - -### Query Methods - -```csharp -// Todos os registros -var all = repository.All(); - -// Com filtro -var active = repository.Where(p => p.IsActive); - -// Read-only (mesma performance no MongoDB) -var readOnly = repository.AllReadOnly(); - -// Paginação -var paged = repository.WherePaged(p => p.IsActive, out int total, index: 0, size: 20); - -// Get único -var product = await repository.GetAsync(p => p.Id == id, ct); - -// FirstOrDefault -var first = await repository.FirstOrDefaultAsync(p => p.IsActive, ct); - -// Any -var exists = await repository.AnyAsync(p => p.Name == "Test", ct); - -// Count -var count = await repository.CountAsync(p => p.IsActive, ct); - -// ToList -var list = await repository.ToListAsync(p => p.Price > 100, ct); - -// Load/Get por ID -var byId = await repository.GetAsync(objectId, ct); -var loaded = await repository.LoadAsync(objectId, ct); // Alias de GetAsync -``` - -### Command Methods - -```csharp -// Salvar (Insert) -await repository.SaveAsync(product, ct); - -// Atualizar (Replace) -await repository.UpdateAsync(product, ct); - -// SaveOrUpdate (insert se novo, replace se existente) -await repository.SaveOrUpdateAsync(product, ct); - -// Deletar -await repository.DeleteAsync(product, ct); - -// Deletar com filtro -await repository.DeleteAsync(p => p.IsActive == false, ct); - -// Merge (reattach - MongoDB usa ReplaceOne) -var merged = await repository.MergeAsync(product, ct); - -// Refresh (re-carregar do banco) -var refreshed = await repository.RefreshAsync(product, ct); -``` - -## ?? Limitações do MongoDB - -### 1. Includes (Relacionamentos) +## Uso -MongoDB não suporta `Include` nativo como EF Core: +Registre os serviços com `AddMongoDb`, que configura `IMongoClient`, `IMongoDatabase` e `IRepository` no container de DI: ```csharp -// ? Não funciona como esperado -var products = repository.IncludeMany(p => p.Category); - -// ? Use agregações ou lookups do MongoDB -var collection = context.GetCollection(); -var productsWithCategory = await collection.Aggregate() - .Lookup("categories", "categoryId", "_id", "category") - .ToListAsync(ct); -``` - -### 2. Transações +using Codout.Framework.Mongo.Configuration; -Transações requerem replica set: - -```bash -# Configurar replica set local (desenvolvimento) -mongod --replSet rs0 --port 27017 --dbpath /data/db1 - -# Inicializar replica set -mongosh -> rs.initiate() +builder.Services.AddMongoDb( + connectionString: "mongodb://localhost:27017", + databaseName: "minha-base"); ``` -### 3. IQueryable Limitado - -O driver MongoDB suporta LINQ, mas não todos os operadores: +Injete `IRepository` ou instancie `MongoRepository` diretamente: ```csharp -// ? Funciona -var products = repository.Where(p => p.Price > 100 && p.IsActive); - -// ?? Pode não funcionar -var products = repository.Where(p => p.Name.StartsWith("A") || p.Name.EndsWith("Z")); - -// ? Alternativa: usar filtros do MongoDB -var filter = Builders.Filter.Or( - Builders.Filter.Regex("name", new BsonRegularExpression("^A")), - Builders.Filter.Regex("name", new BsonRegularExpression("Z$")) -); -``` - -## ?? Operações com CancellationToken - -```csharp -var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - -try -{ - var product = await repository.GetAsync(p => p.Id == id, cts.Token); - await repository.UpdateAsync(product, cts.Token); - await unitOfWork.CommitAsync(cts.Token); -} -catch (OperationCanceledException) -{ - Console.WriteLine("Operação cancelada pelo timeout"); -} -``` - -## ??? Arquitetura Recomendada - -``` -?? MyProject.Data.Mongo -??? ?? Context -? ??? MyMongoContext.cs (se precisar estender) -??? ?? Repositories -? ??? IProductRepository.cs -? ??? ProductRepository.cs -??? ?? Entities - ??? Product.cs -``` - -## ?? Melhores Práticas - -1. **Use ObjectId para IDs**: MongoDB funciona melhor com ObjectId -2. **Configure índices**: Use atributos `[BsonIndex]` ou configure via código -3. **Use CancellationToken**: Sempre em operações async -4. **Transações apenas quando necessário**: Têm overhead de performance -5. **Evite queries muito complexas**: Use agregações do MongoDB diretamente -6. **Configure Write Concern**: Para garantir persistência em replica set -7. **Use await using**: Para dispose automático do UnitOfWork -8. **Valide ObjectId**: Antes de fazer queries por ID - -## ?? Índices - -```csharp -// Via código na configuração -var collection = database.GetCollection("products"); - -var indexKeysDefinition = Builders.IndexKeys - .Ascending(p => p.Name) - .Descending(p => p.Price); +using Codout.Framework.Mongo; -await collection.Indexes.CreateOneAsync( - new CreateIndexModel(indexKeysDefinition), - cancellationToken: ct -); +var repository = new MongoRepository(database); // IMongoDatabase +var cliente = await repository.SaveAsync(new Cliente { Nome = "Maria" }, ct); +var ativos = await repository.ToListAsync(c => c.Ativo, ct); ``` -## ?? Novidades v10.0 - -### Novos Recursos -- ? **MongoUnitOfWork** criado do zero com transações -- ? **Métodos auxiliares**: `FirstOrDefaultAsync`, `AnyAsync`, `CountAsync`, `ToListAsync` -- ? **IAsyncDisposable** implementado -- ? **Sobrecargas com CancellationToken** em todos métodos async -- ? **Validações** com `ArgumentNullException.ThrowIfNull` - -### Melhorias -- ? **Performance** otimizada para operações batch -- ?? **Documentação XML** completa -- ?? **Nullable reference types** habilitado -- ?? **Thread-safe** implementation - -### Correções -- ?? **GetIdValue** agora suporta múltiplos tipos de ID -- ?? **Exists** implementado corretamente -- ?? **Load/Refresh** agora funcionam adequadamente - -## ?? Links Relacionados - -- [Codout.Framework.Data](../Codout.Framework.Data/README.md) - Abstrações base -- [Codout.Framework.EF](../Codout.Framework.EF/README.md) - Implementação Entity Framework -- [Codout.Framework.NH](../Codout.Framework.NH/README.md) - Implementação NHibernate -- [MongoDB Driver .NET](https://www.mongodb.com/docs/drivers/csharp/current/) -- [MongoDB Transactions](https://www.mongodb.com/docs/manual/core/transactions/) - -## ?? Licença +Cada entidade é mapeada para uma coleção com o nome do tipo em minúsculas (ex.: `Cliente` -> `cliente`). `MongoUnitOfWork` (recebe `IMongoClient`) dá suporte a transações, que exigem replica set no servidor. -Propriedade da Codout +## Pacotes relacionados ---- +- `Codout.Framework.Data` — contratos implementados por este pacote. +- `Codout.Framework.EF` e `Codout.Framework.NH` — implementações alternativas dos mesmos contratos. -**Versão:** 10.0.0 -**Status:** Estável para produção -**Target:** .NET 10 -**MongoDB Driver:** 3.5.1 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.NH/Codout.Framework.NH.csproj b/Codout.Framework.NH/Codout.Framework.NH.csproj index 08e066d..d282a19 100644 --- a/Codout.Framework.NH/Codout.Framework.NH.csproj +++ b/Codout.Framework.NH/Codout.Framework.NH.csproj @@ -1,7 +1,10 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Implementação da interface Codout.Framework.Data para NHibernate Codout;Framework;ORM;DAL;NHibernate;Repository;UnitOfWork; true diff --git a/Codout.Framework.NH/NHRepository.cs b/Codout.Framework.NH/NHRepository.cs index 34fbfb6..fba14e5 100644 --- a/Codout.Framework.NH/NHRepository.cs +++ b/Codout.Framework.NH/NHRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -51,7 +51,7 @@ public IQueryable WherePaged(Expression> predicate, out int tot } public T Get(Expression> predicate) => - All().SingleOrDefault(predicate); + All().SingleOrDefault(predicate)!; public T Get(object key) => Session.Get(key); @@ -123,25 +123,25 @@ public async Task GetAsync(Expression> predicate, CancellationT return await All().SingleOrDefaultAsync(predicate, cancellationToken); } - public async Task GetAsync(object key) + public async Task GetAsync(object key) { return await GetAsync(key, CancellationToken.None); } - public async Task GetAsync(object key, CancellationToken cancellationToken) + public async Task GetAsync(object key, CancellationToken cancellationToken) { return await Session.GetAsync(key, cancellationToken); } - public Task LoadAsync(object key) + public Task LoadAsync(object key) { return LoadAsync(key, CancellationToken.None); } - public Task LoadAsync(object key, CancellationToken cancellationToken) + public Task LoadAsync(object key, CancellationToken cancellationToken) { // NHibernate Load retorna proxy, não é async - return Task.FromResult(Session.Load(key)); + return Task.FromResult(Session.Load(key)); } public async Task FirstOrDefaultAsync(Expression> predicate, CancellationToken cancellationToken = default) diff --git a/Codout.Framework.NH/NHUnitOfWork.cs b/Codout.Framework.NH/NHUnitOfWork.cs index 673a4ad..b11395f 100644 --- a/Codout.Framework.NH/NHUnitOfWork.cs +++ b/Codout.Framework.NH/NHUnitOfWork.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Data; using System.Threading; using System.Threading.Tasks; @@ -6,6 +6,10 @@ using Codout.Framework.Data.Entity; using NHibernate; +// CA1510: guard clauses originais mantidos para preservar o comportamento +// byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1510 + namespace Codout.Framework.NH; /// diff --git a/Codout.Framework.NH/README.md b/Codout.Framework.NH/README.md index 00eceaa..a46d5ad 100644 --- a/Codout.Framework.NH/README.md +++ b/Codout.Framework.NH/README.md @@ -1,493 +1,40 @@ # Codout.Framework.NH -Implementação do padrão Repository e Unit of Work para NHibernate. +Implementação dos contratos `IRepository` e `IUnitOfWork` de `Codout.Framework.Data` para NHibernate, com registro de `ISessionFactory` e `ISession` no container de DI via FluentNHibernate. -## ?? Recursos - -- ? **Repository Pattern** com NHibernate -- ? **Unit of Work Pattern** com transações -- ? **Async/await completo** com CancellationToken -- ? **Métodos auxiliares** (FirstOrDefault, Any, Count, ToList) -- ? **FluentNHibernate** suportado -- ? **IAsyncDisposable** suportado -- ? **Nullable Reference Types** habilitado -- ? **Documentação XML** completa - -## ?? Instalação +## Instalação ```bash dotnet add package Codout.Framework.NH ``` -## ?? Configuração - -### appsettings.json - -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Server=localhost;Database=MyDb;User Id=sa;Password=***;" - } -} -``` +## Uso -### Program.cs com FluentNHibernate +Registre os serviços com `AddNHibernateServices`, que configura `ISessionFactory` (singleton), `ISession` e `IStatelessSession` (scoped) e o ciclo de vida da fábrica: ```csharp using Codout.Framework.NH; -using Codout.Framework.Data; -using FluentNHibernate.Cfg; -using FluentNHibernate.Cfg.Db; -using NHibernate; -using NHibernate.Tool.hbm2ddl; - -// Configurar NHibernate Session Factory -services.AddSingleton(sp => -{ - var connectionString = configuration.GetConnectionString("DefaultConnection"); - - return Fluently.Configure() - .Database(MsSqlConfiguration.MsSql2012 - .ConnectionString(connectionString) - .ShowSql()) - .Mappings(m => m.FluentMappings - .AddFromAssemblyOf()) - .ExposeConfiguration(cfg => new SchemaUpdate(cfg).Execute(false, true)) - .BuildSessionFactory(); -}); - -// Configurar Session (scoped) -services.AddScoped(sp => -{ - var factory = sp.GetRequiredService(); - return factory.OpenSession(); -}); - -// Configurar Unit of Work -services.AddScoped(); -``` - -## ?? Uso Básico - -### Entidades - -```csharp -using Codout.Framework.Data.Entity; -using System.Reflection; - -public class Product : IEntity -{ - public virtual int Id { get; set; } - public virtual string Name { get; set; } = string.Empty; - public virtual decimal Price { get; set; } - public virtual bool IsActive { get; set; } - public virtual Category? Category { get; set; } - - public bool IsTransient() => Id == 0; - - public IEnumerable GetSignatureProperties() - { - return new[] { typeof(Product).GetProperty(nameof(Id))! }; - } -} -``` - -**Importante**: Propriedades **devem ser virtual** para permitir lazy loading e proxies do NHibernate. - -### Mapeamento FluentNHibernate - -```csharp -using FluentNHibernate.Mapping; - -public class ProductMap : ClassMap -{ - public ProductMap() - { - Table("Products"); - - Id(x => x.Id).GeneratedBy.Identity(); - - Map(x => x.Name).Not.Nullable().Length(200); - Map(x => x.Price).Not.Nullable(); - Map(x => x.IsActive).Not.Nullable(); - - References(x => x.Category) - .Column("CategoryId") - .LazyLoad(); - } -} -``` - -### Repository - -```csharp -public class ProductRepository : NHRepository -{ - public ProductRepository(IUnitOfWork unitOfWork) : base(unitOfWork) - { - } - - public async Task> GetActiveProductsAsync(CancellationToken ct = default) - { - return await ToListAsync(p => p.IsActive, ct); - } - - public async Task HasActiveProductsAsync(CancellationToken ct = default) - { - return await AnyAsync(p => p.IsActive, ct); - } - - public async Task CountActiveProductsAsync(CancellationToken ct = default) - { - return await CountAsync(p => p.IsActive, ct); - } -} -``` - -### Unit of Work (com Transações) - -```csharp -public class ProductService -{ - private readonly NHRepository _repository; - private readonly IUnitOfWork _unitOfWork; - - public async Task CreateProductAsync(Product product, CancellationToken ct = default) - { - await _unitOfWork.BeginTransactionAsync(ct); - - try - { - await _repository.SaveAsync(product, ct); - await _unitOfWork.CommitAsync(ct); - return product; - } - catch - { - await _unitOfWork.RollbackAsync(ct); - throw; - } - } -} -``` - -### InTransaction Helper - -```csharp -var product = await _unitOfWork.InTransactionAsync(async () => -{ - var newProduct = new Product { Name = "Test", Price = 100 }; - await _repository.SaveAsync(newProduct, ct); - return newProduct; -}, ct); -``` - -## ?? Operações Disponíveis - -### Query Methods - -```csharp -// Todos os registros -var all = repository.All(); - -// Read-only (DefaultReadOnly = true) -var readOnly = repository.AllReadOnly(); - -// Com filtro -var active = repository.Where(p => p.IsActive); - -// Read-only com filtro -var activeReadOnly = repository.WhereReadOnly(p => p.IsActive); - -// Paginação -var paged = repository.WherePaged(p => p.IsActive, out int total, index: 0, size: 20); - -// Get único -var product = await repository.GetAsync(p => p.Id == id, ct); - -// FirstOrDefault -var first = await repository.FirstOrDefaultAsync(p => p.IsActive, ct); - -// Any -var exists = await repository.AnyAsync(p => p.Name == "Test", ct); - -// Count -var count = await repository.CountAsync(p => p.IsActive, ct); - -// ToList -var list = await repository.ToListAsync(p => p.Price > 100, ct); - -// Load (retorna proxy lazy - não é async nativo) -var proxy = repository.Load(id); -``` - -### Command Methods - -```csharp -// Salvar -await repository.SaveAsync(product, ct); - -// Atualizar -await repository.UpdateAsync(product, ct); - -// SaveOrUpdate (NHibernate detecta automaticamente) -await repository.SaveOrUpdateAsync(product, ct); - -// Deletar -await repository.DeleteAsync(product, ct); - -// Deletar com filtro -await repository.DeleteAsync(p => p.IsActive == false, ct); - -// Merge (detached -> persistent) -var merged = await repository.MergeAsync(product, ct); -// Refresh (re-carregar do banco) -var refreshed = await repository.RefreshAsync(product, ct); +builder.Services.AddNHibernateServices(builder.Configuration); ``` -## ?? Eager Loading +Os assemblies de mapeamento são lidos da seção de configuração `NHibernate:MappingAssemblies` (array de nomes de assembly). -### NHibernate não suporta Include como EF Core +Use `NHRepository`, que recebe um `ISession` no construtor: ```csharp -// ? Não funciona -var products = repository.IncludeMany(p => p.Category); - -// ? Use Fetch no LINQ -var products = repository.All() - .Fetch(p => p.Category) - .ThenFetch(c => c.Supplier) - .ToList(); - -// ? Ou configure no mapeamento -public class ProductMap : ClassMap -{ - public ProductMap() - { - References(x => x.Category) - .Not.LazyLoad(); // Eager load sempre - } -} - -// ? Ou use FetchMode em queries -var session = ((NHRepository)repository).Session; -var products = session.CreateCriteria() - .SetFetchMode("Category", FetchMode.Eager) - .List(); -``` - -## ??? Mapeamento Avançado - -### Relacionamentos - -```csharp -public class ProductMap : ClassMap -{ - public ProductMap() - { - Table("Products"); - - Id(x => x.Id).GeneratedBy.Identity(); - - // Many-to-One - References(x => x.Category) - .Column("CategoryId") - .LazyLoad() - .Cascade.None(); - - // One-to-Many - HasMany(x => x.Reviews) - .KeyColumn("ProductId") - .Inverse() - .Cascade.AllDeleteOrphan(); - } -} -``` - -### Componentes - -```csharp -public class ProductMap : ClassMap -{ - public ProductMap() - { - Component(x => x.Address, m => - { - m.Map(x => x.Street).Column("Street"); - m.Map(x => x.City).Column("City"); - m.Map(x => x.ZipCode).Column("ZipCode"); - }); - } -} -``` - -## ?? Session Management - -### Acesso direto à Session - -```csharp -public class ProductRepository : NHRepository -{ - public async Task> GetProductsWithCustomQuery(CancellationToken ct = default) - { - return await Session - .CreateQuery("FROM Product p WHERE p.Price > 100") - .ListAsync(ct); - } - - public async Task BulkUpdateAsync(CancellationToken ct = default) - { - await Session - .CreateQuery("UPDATE Product SET IsActive = false WHERE Price < 10") - .ExecuteUpdateAsync(ct); - } -} -``` - -## ?? Considerações NHibernate - -### 1. Propriedades Virtuais - -```csharp -// ? Correto - permite lazy loading -public class Product -{ - public virtual int Id { get; set; } - public virtual string Name { get; set; } - public virtual Category Category { get; set; } -} - -// ? Errado - não permite lazy loading -public class Product -{ - public int Id { get; set; } - public string Name { get; set; } -} -``` - -### 2. Flush vs Commit - -```csharp -// Flush persiste no banco mas não comita a transação -await Session.FlushAsync(ct); - -// Commit faz flush + commit da transação -await unitOfWork.CommitAsync(ct); -``` - -### 3. Detached Entities - -```csharp -// Entity fora do contexto (detached) -var product = new Product { Id = 1, Name = "Updated" }; - -// ? Update falha se detached -await repository.UpdateAsync(product, ct); - -// ? Use Merge para reattach -var merged = await repository.MergeAsync(product, ct); -await repository.UpdateAsync(merged, ct); -``` - -### 4. Load vs Get - -```csharp -// Load - retorna proxy, lança exceção se não existir quando acessado -var proxy = repository.Load(id); // Não acessa o banco ainda -var name = proxy.Name; // Acessa o banco aqui - -// Get - retorna null se não existir -var product = await repository.GetAsync(id, ct); // Acessa o banco imediatamente -if (product == null) { /* não existe */ } -``` - -## ?? Operações com CancellationToken - -```csharp -var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - -try -{ - var product = await repository.GetAsync(p => p.Id == id, cts.Token); - await repository.UpdateAsync(product, cts.Token); - await unitOfWork.CommitAsync(cts.Token); -} -catch (OperationCanceledException) -{ - Console.WriteLine("Operação cancelada pelo timeout"); -} -``` - -## ?? Melhores Práticas - -1. **Use propriedades virtual**: Permite lazy loading e proxies -2. **Configure Cascade corretamente**: Evite orphan deletions acidentais -3. **Use CancellationToken**: Sempre em operações async -4. **Flush antes de queries**: Se precisar dos dados persistidos -5. **Evite N+1**: Use Fetch/FetchMany para eager loading -6. **Configure índices**: No banco ou via atributos -7. **Use Stateless Session**: Para operações batch de alto volume -8. **Use await using**: Para dispose automático do UnitOfWork -9. **Evite lazy loading em loops**: Carregue dados antecipadamente -10. **Configure cache de segundo nível**: Para performance - -## ?? Índices +using Codout.Framework.NH; -```csharp -public class ProductMap : ClassMap -{ - public ProductMap() - { - // Índice único - Map(x => x.Name) - .Not.Nullable() - .Length(200) - .UniqueKey("UK_Product_Name"); - - // Índice composto - Map(x => x.CategoryId).Index("IX_Product_Category_Active"); - Map(x => x.IsActive).Index("IX_Product_Category_Active"); - } -} +var repository = new NHRepository(session); // ISession +var cliente = await repository.SaveAsync(new Cliente { Nome = "Maria" }, ct); +var ativos = await repository.ToListAsync(c => c.Ativo, ct); ``` -## ?? Novidades v10.0 - -### Novos Recursos -- ? **Métodos auxiliares**: `FirstOrDefaultAsync`, `AnyAsync`, `CountAsync`, `ToListAsync` -- ? **IAsyncDisposable** implementado no UnitOfWork -- ? **Sobrecargas com CancellationToken** em todos métodos async -- ? **Validações** com `ArgumentNullException.ThrowIfNull` -- ? **Flush explícito** antes de commit para consistência - -### Melhorias -- ? **Performance** otimizada em batch operations -- ?? **Documentação XML** completa -- ?? **Nullable reference types** habilitado -- ?? **Thread-safe** implementation - -### Correções -- ?? **Transações** agora fazem flush antes de commit -- ?? **Dispose** correto de transações em exceções -- ?? **LoadAsync** implementado corretamente - -## ?? Links Relacionados - -- [Codout.Framework.Data](../Codout.Framework.Data/README.md) - Abstrações base -- [Codout.Framework.EF](../Codout.Framework.EF/README.md) - Implementação Entity Framework -- [Codout.Framework.Mongo](../Codout.Framework.Mongo/README.md) - Implementação MongoDB -- [NHibernate Documentation](https://nhibernate.info/doc/) -- [FluentNHibernate](https://github.com/nhibernate/fluent-nhibernate) - -## ?? Licença +`NHUnitOfWork` implementa `IUnitOfWork` sobre a sessão, com `BeginTransaction`, `CommitAsync`, `RollbackAsync` e `InTransactionAsync`. -Propriedade da Codout +## Pacotes relacionados ---- +- `Codout.Framework.Data` — contratos implementados por este pacote. +- `Codout.Framework.EF` e `Codout.Framework.Mongo` — implementações alternativas dos mesmos contratos. -**Versão:** 10.0.0 -**Status:** Estável para produção -**Target:** .NET 10 -**NHibernate:** 5.6.0 -**FluentNHibernate:** 3.4.1 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.NH/ServiceCollectionExtensions.cs b/Codout.Framework.NH/ServiceCollectionExtensions.cs index 482462b..06dbf1a 100644 --- a/Codout.Framework.NH/ServiceCollectionExtensions.cs +++ b/Codout.Framework.NH/ServiceCollectionExtensions.cs @@ -1,9 +1,13 @@ -using System; +using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using NHibernate; +// CA1510: guard clauses originais mantidos para preservar o comportamento +// byte a byte (política de zero mudança de comportamento). +#pragma warning disable CA1510 + namespace Codout.Framework.NH; public static class ServiceCollectionExtensions @@ -29,4 +33,4 @@ public static IServiceCollection AddNHibernateServices(this IServiceCollection s return services; } -} \ No newline at end of file +} diff --git a/Codout.Framework.NH/SessionFactoryProvider.cs b/Codout.Framework.NH/SessionFactoryProvider.cs index 922957c..e29465d 100644 --- a/Codout.Framework.NH/SessionFactoryProvider.cs +++ b/Codout.Framework.NH/SessionFactoryProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Linq; using System.Reflection; @@ -64,7 +64,7 @@ private ISessionFactory BuildFactory() var fluent = Fluently.Configure(baseCfg); var assemblyNames = _configuration.GetSection("NHibernate:MappingAssemblies").Get(); - var assemblies = assemblyNames.Select(name => Assembly.Load(new AssemblyName(name))).ToArray(); + var assemblies = assemblyNames!.Select(name => Assembly.Load(new AssemblyName(name))).ToArray(); foreach (var assembly in assemblies) fluent.Mappings(m => m.FluentMappings.AddFromAssembly(assembly)); @@ -90,9 +90,11 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } +#pragma warning disable CA1816 // Dispose original mantido sem GC.SuppressFinalize para preservar o comportamento. public void Dispose() { if (_factory.IsValueCreated) _factory.Value.Dispose(); } -} \ No newline at end of file +#pragma warning restore CA1816 +} diff --git a/Codout.Framework.Storage.Azure/Codout.Framework.Storage.Azure.csproj b/Codout.Framework.Storage.Azure/Codout.Framework.Storage.Azure.csproj index 650c1fb..24770c5 100644 --- a/Codout.Framework.Storage.Azure/Codout.Framework.Storage.Azure.csproj +++ b/Codout.Framework.Storage.Azure/Codout.Framework.Storage.Azure.csproj @@ -1,7 +1,10 @@ - 6.3.0 + 6.4.0 + + true + 6.3.0 latest enable Codout Framework Storage Azure diff --git a/Codout.Framework.Storage.Azure/README.md b/Codout.Framework.Storage.Azure/README.md new file mode 100644 index 0000000..9927952 --- /dev/null +++ b/Codout.Framework.Storage.Azure/README.md @@ -0,0 +1,56 @@ +# Codout.Framework.Storage.Azure + +Implementação da interface `IStorage` do Codout.Framework.Storage para Azure Blob Storage, com upload/download, cópia/movimentação entre containers, metadados, listagem e geração de SAS URI. + +## Instalação + +```bash +dotnet add package Codout.Framework.Storage.Azure +``` + +## Uso + +Registre via DI com um dos overloads de `AddAzureStorage` (por `IConfiguration` — lê a connection string `AzureStorage` —, por connection string, por `AzureStorageOptions` ou por `Action`). O serviço é registrado como singleton de `IStorage`: + +```csharp +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(args); + +// usa ConnectionStrings:AzureStorage do appsettings.json +builder.Services.AddAzureStorage(builder.Configuration); + +// ou explicitamente: +builder.Services.AddAzureStorage("UseDevelopmentStorage=true"); +``` + +Consumindo `IStorage`: + +```csharp +using Codout.Framework.Storage; + +public class DocumentoService(IStorage storage) +{ + public async Task SalvarAsync(Stream arquivo, CancellationToken ct) + { + // upload (o content-type é inferido pela extensão do arquivo) + return await storage.UploadAsync(arquivo, "documentos", "contrato.pdf", ct); + } + + public async Task BaixarAsync(CancellationToken ct) + { + return await storage.DownloadAsync("documentos", "contrato.pdf", ct); + } +} +``` + +Também é possível instanciar diretamente: `new AzureStorage(connectionString)` ou `new AzureStorage(new AzureStorageOptions { ConnectionString = "..." })`. Erros do Azure são encapsulados em `StorageException` (de Codout.Framework.Storage). + +## Pacotes relacionados + +- [Codout.Framework.Storage](https://www.nuget.org/packages/Codout.Framework.Storage) — abstração `IStorage`, options e exceções (dependência deste pacote). +- [Codout.Framework.Common](https://www.nuget.org/packages/Codout.Framework.Common) — utilitários comuns do framework. +- [Codout.Framework.Application](https://www.nuget.org/packages/Codout.Framework.Application) — camada de aplicação/serviços. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.Storage/Codout.Framework.Storage.csproj b/Codout.Framework.Storage/Codout.Framework.Storage.csproj index 3cb192d..534792c 100644 --- a/Codout.Framework.Storage/Codout.Framework.Storage.csproj +++ b/Codout.Framework.Storage/Codout.Framework.Storage.csproj @@ -1,7 +1,10 @@ - 6.3.0 + 6.4.0 + + true + 6.3.0 latest enable Codout Framework Storage diff --git a/Codout.Framework.Storage/README.md b/Codout.Framework.Storage/README.md index 3112031..b8f767f 100644 --- a/Codout.Framework.Storage/README.md +++ b/Codout.Framework.Storage/README.md @@ -1,319 +1,43 @@ # Codout.Framework.Storage -Abstração cloud-agnostic para operações de armazenamento de arquivos (Azure, AWS S3, File System). +Abstração para armazenamento de arquivos em nuvem: define o contrato `IStorage` (upload, download, listagem, metadados e URIs SAS) e os modelos `StorageItem`, `StorageMetadata` e `StorageOptions`, independentes do provedor. -## ?? Recursos - -- ? **Interface unificada** para múltiplos provedores -- ? **Async/await** com CancellationToken -- ? **Metadata support** para arquivos -- ? **SAS tokens** para acesso temporário -- ? **List operations** para descoberta de arquivos -- ? **Batch operations** para deletar múltiplos arquivos -- ? **Progress reporting** para uploads -- ? **Exceções customizadas** para tratamento específico -- ? **CDN support** para URLs otimizadas -- ? **Thread-safe** implementations - -## ?? Instalação +## Instalação ```bash -# Abstrações dotnet add package Codout.Framework.Storage - -# Azure Blob Storage -dotnet add package Codout.Framework.Storage.Azure - -# AWS S3 (em desenvolvimento) -dotnet add package Codout.Framework.Storage.AWS - -# File System (em desenvolvimento) -dotnet add package Codout.Framework.Storage.FileSystem -``` - -## ?? Configuração - -### Azure Blob Storage - -```csharp -// appsettings.json -{ - "ConnectionStrings": { - "AzureStorage": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net" - } -} - -// Program.cs - Básico -services.AddAzureStorage(configuration); - -// Program.cs - Avançado -services.AddAzureStorage(options => -{ - options.ConnectionString = configuration.GetConnectionString("AzureStorage"); - options.DefaultContainer = "uploads"; - options.AutoCreateContainer = true; - options.EnableCdn = true; - options.CdnEndpoint = "https://mycdn.azureedge.net"; - options.MaxRetryAttempts = 3; - options.PublicAccessType = "Blob"; -}); -``` - -## ?? Uso Básico - -### Upload - -```csharp -public class FileService -{ - private readonly IStorage _storage; - - public FileService(IStorage storage) - { - _storage = storage; - } - - public async Task UploadFileAsync(Stream file, string fileName, CancellationToken ct = default) - { - return await _storage.UploadAsync(file, "documents", fileName, ct); - } - - // Com metadata - public async Task UploadWithMetadataAsync(Stream file, string fileName, CancellationToken ct = default) - { - var metadata = new Dictionary - { - ["userId"] = "123", - ["uploadDate"] = DateTime.UtcNow.ToString("O") - }; - - return await _storage.UploadAsync(file, "documents", fileName, metadata, ct); - } - - // Com progress - public async Task UploadWithProgressAsync(Stream file, string fileName, IProgress progress, CancellationToken ct = default) - { - return await _storage.UploadAsync(file, "documents", fileName, progress, ct); - } -} -``` - -### Download - -```csharp -public async Task DownloadFileAsync(string fileName, CancellationToken ct = default) -{ - return await _storage.DownloadAsync("documents", fileName, ct); -} - -// Stream somente leitura (mais eficiente) -public async Task GetStreamAsync(string fileName, CancellationToken ct = default) -{ - return await _storage.GetStreamAsync("documents", fileName, ct); -} -``` - -### Delete - -```csharp -// Deletar um arquivo -await _storage.DeleteAsync("documents", "file.pdf", ct); - -// Deletar múltiplos arquivos -var files = new[] { "file1.pdf", "file2.pdf", "file3.pdf" }; -await _storage.DeleteManyAsync("documents", files, ct); -``` - -### Copy e Move - -```csharp -// Copiar -var newUri = await _storage.CopyToAsync("source-container", "dest-container", "file.pdf", ct); - -// Mover -var movedUri = await _storage.MoveToAsync("source-container", "dest-container", "file.pdf", ct); -``` - -### List Files - -```csharp -// Listar todos -var files = await _storage.ListAsync("documents", ct); - -foreach (var file in files) -{ - Console.WriteLine($"{file.Name} - {file.Size} bytes - {file.LastModified}"); -} - -// Listar com prefixo -var pdfs = await _storage.ListAsync("documents", "invoices/", ct); -``` - -### Metadata - -```csharp -// Obter metadata -var metadata = await _storage.GetMetadataAsync("documents", "file.pdf", ct); -Console.WriteLine($"Type: {metadata.ContentType}, Size: {metadata.Size}"); - -// Setar metadata customizada -var customData = new Dictionary -{ - ["category"] = "invoice", - ["year"] = "2024" -}; -await _storage.SetMetadataAsync("documents", "file.pdf", customData, ct); ``` -### SAS Tokens (Acesso Temporário) - -```csharp -// Gerar URL com acesso temporário de 1 hora -var sasUri = await _storage.GetSasUriAsync("documents", "file.pdf", TimeSpan.FromHours(1), ct); - -// Enviar ao cliente -return Ok(new { downloadUrl = sasUri.ToString() }); -``` +## Uso -### Verificar Existência +Dependa apenas de `IStorage` e deixe o provedor concreto para um pacote de implementação: ```csharp -if (await _storage.ExistsAsync("documents", "file.pdf", ct)) -{ - Console.WriteLine("File exists!"); -} -``` - -## ?? Operações Avançadas +using Codout.Framework.Storage; -### Upload com Progress Bar - -```csharp -public async Task UploadWithProgressBarAsync(Stream file, string fileName) +public class ArquivoService(IStorage storage) { - var progress = new Progress(bytesUploaded => + public async Task EnviarAsync(Stream arquivo, CancellationToken ct) { - var percentage = (bytesUploaded * 100) / file.Length; - Console.WriteLine($"Uploaded: {percentage}%"); - }); - - return await _storage.UploadAsync(file, "documents", fileName, progress); -} -``` - -### Tratamento de Erros Específicos - -```csharp -try -{ - await _storage.DownloadAsync("documents", "file.pdf", ct); -} -catch (StorageNotFoundException ex) -{ - // Arquivo não encontrado - return NotFound($"File not found: {ex.FileName}"); -} -catch (StorageContainerException ex) -{ - // Erro no container - return BadRequest($"Container error: {ex.Message}"); -} -catch (StorageException ex) -{ - // Erro genérico de storage - return StatusCode(500, $"Storage error: {ex.Message}"); -} -``` - -### Múltiplos Provedores - -```csharp -// Registrar múltiplos storage providers -services.AddAzureStorage(azureOptions); -services.AddKeyedSingleton("aws", new AwsStorage(awsOptions)); -services.AddKeyedSingleton("local", new FileSystemStorage(fsOptions)); - -// Usar específico -public class FileService -{ - private readonly IStorage _primaryStorage; - private readonly IStorage _backupStorage; - - public FileService( - IStorage primaryStorage, - [FromKeyedServices("aws")] IStorage backupStorage) - { - _primaryStorage = primaryStorage; - _backupStorage = backupStorage; + return await storage.UploadAsync(arquivo, "documentos", "contrato.pdf", ct); } -} -``` - -## ?? Modelos - -### StorageItem -```csharp -public class StorageItem -{ - public string Name { get; set; } - public Uri Uri { get; set; } - public string ContentType { get; set; } - public long Size { get; set; } - public DateTimeOffset LastModified { get; set; } - public string? ETag { get; set; } - public bool IsDirectory { get; set; } + public Task BaixarAsync(CancellationToken ct) => + storage.DownloadAsync("documentos", "contrato.pdf", ct); } ``` -### StorageMetadata +Outras operações do contrato: ```csharp -public class StorageMetadata -{ - public string ContentType { get; set; } - public long Size { get; set; } - public DateTimeOffset LastModified { get; set; } - public string? ETag { get; set; } - public IDictionary CustomMetadata { get; set; } - public string? ContentEncoding { get; set; } - public string? CacheControl { get; set; } -} +bool existe = await storage.ExistsAsync("documentos", "contrato.pdf", ct); +IEnumerable itens = await storage.ListAsync("documentos", ct); +Uri sas = await storage.GetSasUriAsync("documentos", "contrato.pdf", TimeSpan.FromHours(1), ct); +await storage.DeleteAsync("documentos", "contrato.pdf", ct); ``` -## ?? Melhores Práticas - -1. **Use CancellationToken** em todas operações async -2. **Use GetStreamAsync** em vez de DownloadAsync para arquivos grandes (streaming) -3. **Implemente retry logic** para operações críticas -4. **Use SAS tokens** para acesso temporário em vez de URLs públicas -5. **Configure CDN** para melhor performance de leitura -6. **Use metadata** para armazenar informações contextuais -7. **Valide nomes de arquivos** antes do upload -8. **Implemente progress reporting** para uploads grandes -9. **Use batch operations** para deletar múltiplos arquivos -10. **Trate exceções específicas** (`StorageNotFoundException`, etc.) - -## ?? Novidades v10.0 - -- ? **Nova interface IStorage** com naming convention async -- ? **Metadata support** completo -- ? **SAS tokens** para acesso temporário -- ? **List operations** com prefixo -- ? **Batch delete** para múltiplos arquivos -- ? **Progress reporting** em uploads -- ? **Exceções customizadas** tipadas -- ? **CDN support** integrado -- ? **Thread-safe** lazy loading -- ? **Extensões DI** fluentes -- ? **Nullable reference types** - -## ?? Licença - -Propriedade da Codout +## Pacotes relacionados ---- +- `Codout.Framework.Storage.Azure` — implementação de `IStorage` para Azure Blob Storage. -**Versão:** 10.0.0 -**Status:** Estável para produção -**Target:** .NET 10 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Framework.sln b/Codout.Framework.sln index 4ef8528..a51e790 100644 --- a/Codout.Framework.sln +++ b/Codout.Framework.sln @@ -68,100 +68,564 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Bcrypt", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Scrypt", "src\Security\Codout.Security.Scrypt\Codout.Security.Scrypt.csproj", "{EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Api.Dto", "Codout.Framework.Api.Dto\Codout.Framework.Api.Dto.csproj", "{26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Codout.Multitenancy", "Codout.Multitenancy", "{99E702AB-714F-56EC-2FD9-16223EED3D31}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Softprime.Multitenancy", "Codout.Multitenancy\Softprime.Multitenancy.csproj", "{F5E486C4-6B92-4CEE-903F-54B3E7BE1406}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.EF.Tests", "tests\Codout.Framework.EF.Tests\Codout.Framework.EF.Tests.csproj", "{A3AE642A-CB84-43DC-B1C3-583918B97173}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Common.Tests", "tests\Codout.Framework.Common.Tests\Codout.Framework.Common.Tests.csproj", "{F6F7B69B-6463-42FE-9A69-24B955A41B01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Security.Tests", "tests\Codout.Security.Tests\Codout.Security.Tests.csproj", "{EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Image.Extensions.Tests", "tests\Codout.Image.Extensions.Tests\Codout.Image.Extensions.Tests.csproj", "{83C5C172-E450-404B-A906-804B7BE2705C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Data.Tests", "tests\Codout.Framework.Data.Tests\Codout.Framework.Data.Tests.csproj", "{7A223A32-04EC-4F53-BE85-E6F1379E11FE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Domain.Tests", "tests\Codout.Framework.Domain.Tests\Codout.Framework.Domain.Tests.csproj", "{4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Mailer.Tests", "tests\Codout.Mailer.Tests\Codout.Mailer.Tests.csproj", "{56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Mailer.Razor.Tests", "tests\Codout.Mailer.Razor.Tests\Codout.Mailer.Razor.Tests.csproj", "{3C9B2F21-27B8-4E51-BAC6-D3401179FB51}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Storage.Tests", "tests\Codout.Framework.Storage.Tests\Codout.Framework.Storage.Tests.csproj", "{6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Storage.Azure.Tests", "tests\Codout.Framework.Storage.Azure.Tests\Codout.Framework.Storage.Azure.Tests.csproj", "{C5338C76-F592-4DA9-B20F-2C931749326C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.NH.Tests", "tests\Codout.Framework.NH.Tests\Codout.Framework.NH.Tests.csproj", "{515AAD98-5249-4323-AECC-CC3F4BBBCE54}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Mongo.Tests", "tests\Codout.Framework.Mongo.Tests\Codout.Framework.Mongo.Tests.csproj", "{1C133E13-D1E7-41F8-8464-B814EFA94156}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.DynamicLinq.Tests", "tests\Codout.DynamicLinq.Tests\Codout.DynamicLinq.Tests.csproj", "{3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Api.Dto.Tests", "tests\Codout.Framework.Api.Dto.Tests\Codout.Framework.Api.Dto.Tests.csproj", "{5822C359-1E9D-49CF-816A-3037B2AAC936}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Application.Tests", "tests\Codout.Framework.Application.Tests\Codout.Framework.Application.Tests.csproj", "{2F83D082-E897-4F47-9E6B-73F7032DE238}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Api.Client.Tests", "tests\Codout.Framework.Api.Client.Tests\Codout.Framework.Api.Client.Tests.csproj", "{8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Framework.Api.Tests", "tests\Codout.Framework.Api.Tests\Codout.Framework.Api.Tests.csproj", "{5B4C315E-1212-477B-9697-31B41042A1D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codout.Multitenancy.Tests", "tests\Codout.Multitenancy.Tests\Codout.Multitenancy.Tests.csproj", "{9A0A936E-3C87-4911-B7F0-91B2C6286061}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {281F92E8-F224-4036-858E-F04203D9107B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {281F92E8-F224-4036-858E-F04203D9107B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Debug|x64.ActiveCfg = Debug|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Debug|x64.Build.0 = Debug|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Debug|x86.ActiveCfg = Debug|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Debug|x86.Build.0 = Debug|Any CPU {281F92E8-F224-4036-858E-F04203D9107B}.Release|Any CPU.ActiveCfg = Release|Any CPU {281F92E8-F224-4036-858E-F04203D9107B}.Release|Any CPU.Build.0 = Release|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Release|x64.ActiveCfg = Release|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Release|x64.Build.0 = Release|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Release|x86.ActiveCfg = Release|Any CPU + {281F92E8-F224-4036-858E-F04203D9107B}.Release|x86.Build.0 = Release|Any CPU {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|x64.Build.0 = Debug|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Debug|x86.Build.0 = Debug|Any CPU {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|Any CPU.ActiveCfg = Release|Any CPU {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|Any CPU.Build.0 = Release|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|x64.ActiveCfg = Release|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|x64.Build.0 = Release|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|x86.ActiveCfg = Release|Any CPU + {C1B0EF6F-49FD-4199-8BA3-7FD7BA3DD4BA}.Release|x86.Build.0 = Release|Any CPU {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|x64.Build.0 = Debug|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Debug|x86.Build.0 = Debug|Any CPU {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|Any CPU.Build.0 = Release|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|x64.ActiveCfg = Release|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|x64.Build.0 = Release|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|x86.ActiveCfg = Release|Any CPU + {9F190AFF-FADE-4E54-96D8-FFD6F14ECBEA}.Release|x86.Build.0 = Release|Any CPU {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|x64.Build.0 = Debug|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Debug|x86.Build.0 = Debug|Any CPU {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|Any CPU.Build.0 = Release|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|x64.ActiveCfg = Release|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|x64.Build.0 = Release|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|x86.ActiveCfg = Release|Any CPU + {C2EEFA12-A0DD-4EC4-BCFE-00B1DADC5797}.Release|x86.Build.0 = Release|Any CPU {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|Any CPU.Build.0 = Debug|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|x64.ActiveCfg = Debug|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|x64.Build.0 = Debug|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|x86.ActiveCfg = Debug|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Debug|x86.Build.0 = Debug|Any CPU {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|Any CPU.ActiveCfg = Release|Any CPU {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|Any CPU.Build.0 = Release|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|x64.ActiveCfg = Release|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|x64.Build.0 = Release|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|x86.ActiveCfg = Release|Any CPU + {269065CB-D3A4-4EFE-8F05-E4ED417B7083}.Release|x86.Build.0 = Release|Any CPU {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|x64.ActiveCfg = Debug|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|x64.Build.0 = Debug|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|x86.ActiveCfg = Debug|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Debug|x86.Build.0 = Debug|Any CPU {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|Any CPU.Build.0 = Release|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|x64.ActiveCfg = Release|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|x64.Build.0 = Release|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|x86.ActiveCfg = Release|Any CPU + {777EEB29-52E1-4585-BB10-ECF95563EBD7}.Release|x86.Build.0 = Release|Any CPU {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|x64.ActiveCfg = Debug|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|x64.Build.0 = Debug|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|x86.ActiveCfg = Debug|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Debug|x86.Build.0 = Debug|Any CPU {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|Any CPU.ActiveCfg = Release|Any CPU {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|Any CPU.Build.0 = Release|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|x64.ActiveCfg = Release|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|x64.Build.0 = Release|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|x86.ActiveCfg = Release|Any CPU + {4459FDE2-3D9F-408A-8D6B-863B1D05B865}.Release|x86.Build.0 = Release|Any CPU {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|x64.Build.0 = Debug|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Debug|x86.Build.0 = Debug|Any CPU {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|Any CPU.Build.0 = Release|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|x64.ActiveCfg = Release|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|x64.Build.0 = Release|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|x86.ActiveCfg = Release|Any CPU + {DA7E9861-F5C1-45DF-8509-EC1D76F0EA9C}.Release|x86.Build.0 = Release|Any CPU {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|x64.Build.0 = Debug|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Debug|x86.Build.0 = Debug|Any CPU {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|Any CPU.ActiveCfg = Release|Any CPU {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|Any CPU.Build.0 = Release|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|x64.ActiveCfg = Release|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|x64.Build.0 = Release|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|x86.ActiveCfg = Release|Any CPU + {1B24E7A5-880C-431E-B859-C77037F4CF00}.Release|x86.Build.0 = Release|Any CPU {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|x64.Build.0 = Debug|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Debug|x86.Build.0 = Debug|Any CPU {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|Any CPU.ActiveCfg = Release|Any CPU {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|Any CPU.Build.0 = Release|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|x64.ActiveCfg = Release|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|x64.Build.0 = Release|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|x86.ActiveCfg = Release|Any CPU + {5D77842D-DD96-4927-976A-FFFD8207ED70}.Release|x86.Build.0 = Release|Any CPU {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|x64.ActiveCfg = Debug|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|x64.Build.0 = Debug|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|x86.ActiveCfg = Debug|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Debug|x86.Build.0 = Debug|Any CPU {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|Any CPU.ActiveCfg = Release|Any CPU {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|Any CPU.Build.0 = Release|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|x64.ActiveCfg = Release|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|x64.Build.0 = Release|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|x86.ActiveCfg = Release|Any CPU + {A51B2EDB-B261-41B2-B86F-BBA84AE46288}.Release|x86.Build.0 = Release|Any CPU {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|x64.Build.0 = Debug|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Debug|x86.Build.0 = Debug|Any CPU {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|Any CPU.ActiveCfg = Release|Any CPU {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|Any CPU.Build.0 = Release|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|x64.ActiveCfg = Release|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|x64.Build.0 = Release|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|x86.ActiveCfg = Release|Any CPU + {B4D2F699-D825-4CC3-90D1-000E158D0450}.Release|x86.Build.0 = Release|Any CPU {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|x64.Build.0 = Debug|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Debug|x86.Build.0 = Debug|Any CPU {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|Any CPU.Build.0 = Release|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|x64.ActiveCfg = Release|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|x64.Build.0 = Release|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|x86.ActiveCfg = Release|Any CPU + {1C645251-DB97-405B-9899-173FEB02E5B4}.Release|x86.Build.0 = Release|Any CPU {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|x64.Build.0 = Debug|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Debug|x86.Build.0 = Debug|Any CPU {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|Any CPU.ActiveCfg = Release|Any CPU {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|Any CPU.Build.0 = Release|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|x64.ActiveCfg = Release|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|x64.Build.0 = Release|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|x86.ActiveCfg = Release|Any CPU + {A7759C7F-BC27-4BE5-A9D2-F94D0B3CB2F4}.Release|x86.Build.0 = Release|Any CPU {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|x64.Build.0 = Debug|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|x86.ActiveCfg = Debug|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Debug|x86.Build.0 = Debug|Any CPU {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|Any CPU.ActiveCfg = Release|Any CPU {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|Any CPU.Build.0 = Release|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|x64.ActiveCfg = Release|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|x64.Build.0 = Release|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|x86.ActiveCfg = Release|Any CPU + {FA89B3EA-E38C-4F35-A3DA-828D1274956F}.Release|x86.Build.0 = Release|Any CPU {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|x64.Build.0 = Debug|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Debug|x86.Build.0 = Debug|Any CPU {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|Any CPU.ActiveCfg = Release|Any CPU {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|Any CPU.Build.0 = Release|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|x64.ActiveCfg = Release|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|x64.Build.0 = Release|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|x86.ActiveCfg = Release|Any CPU + {7A5F8194-FBD0-47C0-A8F8-1EB68662BE1E}.Release|x86.Build.0 = Release|Any CPU {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|x64.Build.0 = Debug|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Debug|x86.Build.0 = Debug|Any CPU {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|Any CPU.Build.0 = Release|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|x64.ActiveCfg = Release|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|x64.Build.0 = Release|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|x86.ActiveCfg = Release|Any CPU + {52DE6A78-4383-E3EC-3109-2410414F8EEE}.Release|x86.Build.0 = Release|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|x64.ActiveCfg = Debug|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|x64.Build.0 = Debug|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|x86.ActiveCfg = Debug|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Debug|x86.Build.0 = Debug|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|Any CPU.Build.0 = Release|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|x64.ActiveCfg = Release|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|x64.Build.0 = Release|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|x86.ActiveCfg = Release|Any CPU + {B42C234C-CEB5-76D7-752D-DE71B2F1E9F7}.Release|x86.Build.0 = Release|Any CPU {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|x64.Build.0 = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Debug|x86.Build.0 = Debug|Any CPU {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|Any CPU.Build.0 = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|x64.ActiveCfg = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|x64.Build.0 = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|x86.ActiveCfg = Release|Any CPU + {6764EB74-2D58-45E8-8F5B-942B4FB1C47D}.Release|x86.Build.0 = Release|Any CPU {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|x64.ActiveCfg = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|x64.Build.0 = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|x86.ActiveCfg = Debug|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Debug|x86.Build.0 = Debug|Any CPU {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|Any CPU.ActiveCfg = Release|Any CPU {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|Any CPU.Build.0 = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|x64.ActiveCfg = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|x64.Build.0 = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|x86.ActiveCfg = Release|Any CPU + {3D7FBB69-1515-4A0E-BAED-D87C08C921EA}.Release|x86.Build.0 = Release|Any CPU {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|x64.ActiveCfg = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|x64.Build.0 = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|x86.ActiveCfg = Debug|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Debug|x86.Build.0 = Debug|Any CPU {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|Any CPU.ActiveCfg = Release|Any CPU {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|Any CPU.Build.0 = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|x64.ActiveCfg = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|x64.Build.0 = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|x86.ActiveCfg = Release|Any CPU + {732C166B-24BF-4A84-8C5F-4EDB6340CE45}.Release|x86.Build.0 = Release|Any CPU {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|x64.Build.0 = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Debug|x86.Build.0 = Debug|Any CPU {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|Any CPU.ActiveCfg = Release|Any CPU {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|Any CPU.Build.0 = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|x64.ActiveCfg = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|x64.Build.0 = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|x86.ActiveCfg = Release|Any CPU + {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6}.Release|x86.Build.0 = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|x64.ActiveCfg = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|x64.Build.0 = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|x86.ActiveCfg = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Debug|x86.Build.0 = Debug|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|Any CPU.Build.0 = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|x64.ActiveCfg = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|x64.Build.0 = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|x86.ActiveCfg = Release|Any CPU + {26168F0A-B6BC-4401-9AB0-4C902EC2B0E1}.Release|x86.Build.0 = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|x64.Build.0 = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Debug|x86.Build.0 = Debug|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|Any CPU.Build.0 = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|x64.ActiveCfg = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|x64.Build.0 = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|x86.ActiveCfg = Release|Any CPU + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406}.Release|x86.Build.0 = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|x64.Build.0 = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Debug|x86.Build.0 = Debug|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|Any CPU.Build.0 = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|x64.ActiveCfg = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|x64.Build.0 = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|x86.ActiveCfg = Release|Any CPU + {A3AE642A-CB84-43DC-B1C3-583918B97173}.Release|x86.Build.0 = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|x64.Build.0 = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Debug|x86.Build.0 = Debug|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|Any CPU.Build.0 = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|x64.ActiveCfg = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|x64.Build.0 = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|x86.ActiveCfg = Release|Any CPU + {F6F7B69B-6463-42FE-9A69-24B955A41B01}.Release|x86.Build.0 = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|x64.ActiveCfg = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|x64.Build.0 = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Debug|x86.Build.0 = Debug|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|Any CPU.Build.0 = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|x64.ActiveCfg = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|x64.Build.0 = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|x86.ActiveCfg = Release|Any CPU + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769}.Release|x86.Build.0 = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|x64.ActiveCfg = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|x64.Build.0 = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|x86.ActiveCfg = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Debug|x86.Build.0 = Debug|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|Any CPU.Build.0 = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|x64.ActiveCfg = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|x64.Build.0 = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|x86.ActiveCfg = Release|Any CPU + {83C5C172-E450-404B-A906-804B7BE2705C}.Release|x86.Build.0 = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|x64.Build.0 = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Debug|x86.Build.0 = Debug|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|Any CPU.Build.0 = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|x64.ActiveCfg = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|x64.Build.0 = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|x86.ActiveCfg = Release|Any CPU + {7A223A32-04EC-4F53-BE85-E6F1379E11FE}.Release|x86.Build.0 = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|x64.Build.0 = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Debug|x86.Build.0 = Debug|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|Any CPU.Build.0 = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|x64.ActiveCfg = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|x64.Build.0 = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|x86.ActiveCfg = Release|Any CPU + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A}.Release|x86.Build.0 = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|x64.ActiveCfg = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|x64.Build.0 = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Debug|x86.Build.0 = Debug|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|Any CPU.Build.0 = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|x64.ActiveCfg = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|x64.Build.0 = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|x86.ActiveCfg = Release|Any CPU + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B}.Release|x86.Build.0 = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|x64.ActiveCfg = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|x64.Build.0 = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Debug|x86.Build.0 = Debug|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|Any CPU.Build.0 = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|x64.ActiveCfg = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|x64.Build.0 = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|x86.ActiveCfg = Release|Any CPU + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51}.Release|x86.Build.0 = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|x64.ActiveCfg = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|x64.Build.0 = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|x86.ActiveCfg = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Debug|x86.Build.0 = Debug|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|Any CPU.Build.0 = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|x64.ActiveCfg = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|x64.Build.0 = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|x86.ActiveCfg = Release|Any CPU + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC}.Release|x86.Build.0 = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|x64.ActiveCfg = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|x64.Build.0 = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Debug|x86.Build.0 = Debug|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|Any CPU.Build.0 = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|x64.ActiveCfg = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|x64.Build.0 = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|x86.ActiveCfg = Release|Any CPU + {C5338C76-F592-4DA9-B20F-2C931749326C}.Release|x86.Build.0 = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|x64.ActiveCfg = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|x64.Build.0 = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|x86.ActiveCfg = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Debug|x86.Build.0 = Debug|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|Any CPU.Build.0 = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|x64.ActiveCfg = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|x64.Build.0 = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|x86.ActiveCfg = Release|Any CPU + {515AAD98-5249-4323-AECC-CC3F4BBBCE54}.Release|x86.Build.0 = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|x64.ActiveCfg = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|x64.Build.0 = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Debug|x86.Build.0 = Debug|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|Any CPU.Build.0 = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|x64.ActiveCfg = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|x64.Build.0 = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|x86.ActiveCfg = Release|Any CPU + {1C133E13-D1E7-41F8-8464-B814EFA94156}.Release|x86.Build.0 = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|x64.Build.0 = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Debug|x86.Build.0 = Debug|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|x64.ActiveCfg = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|x64.Build.0 = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|x86.ActiveCfg = Release|Any CPU + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F}.Release|x86.Build.0 = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|x64.ActiveCfg = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|x64.Build.0 = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|x86.ActiveCfg = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Debug|x86.Build.0 = Debug|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|Any CPU.Build.0 = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|x64.ActiveCfg = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|x64.Build.0 = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|x86.ActiveCfg = Release|Any CPU + {5822C359-1E9D-49CF-816A-3037B2AAC936}.Release|x86.Build.0 = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|x64.Build.0 = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Debug|x86.Build.0 = Debug|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|Any CPU.Build.0 = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|x64.ActiveCfg = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|x64.Build.0 = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|x86.ActiveCfg = Release|Any CPU + {2F83D082-E897-4F47-9E6B-73F7032DE238}.Release|x86.Build.0 = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|x64.Build.0 = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Debug|x86.Build.0 = Debug|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|Any CPU.Build.0 = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|x64.ActiveCfg = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|x64.Build.0 = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|x86.ActiveCfg = Release|Any CPU + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C}.Release|x86.Build.0 = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|x64.Build.0 = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Debug|x86.Build.0 = Debug|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|Any CPU.Build.0 = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|x64.ActiveCfg = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|x64.Build.0 = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|x86.ActiveCfg = Release|Any CPU + {5B4C315E-1212-477B-9697-31B41042A1D0}.Release|x86.Build.0 = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|x64.ActiveCfg = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|x64.Build.0 = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Debug|x86.Build.0 = Debug|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|Any CPU.Build.0 = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|x64.ActiveCfg = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|x64.Build.0 = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|x86.ActiveCfg = Release|Any CPU + {9A0A936E-3C87-4911-B7F0-91B2C6286061}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -191,6 +655,25 @@ Global {3D7FBB69-1515-4A0E-BAED-D87C08C921EA} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} {732C166B-24BF-4A84-8C5F-4EDB6340CE45} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} {EF60A915-F3C8-4ABE-A6B3-5086F7093FB6} = {F65D869E-54A1-41D2-A6C3-EAD78678ADC4} + {F5E486C4-6B92-4CEE-903F-54B3E7BE1406} = {99E702AB-714F-56EC-2FD9-16223EED3D31} + {A3AE642A-CB84-43DC-B1C3-583918B97173} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F6F7B69B-6463-42FE-9A69-24B955A41B01} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EC2CEEE3-07A2-4EA5-ABD7-9827F0C30769} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {83C5C172-E450-404B-A906-804B7BE2705C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {7A223A32-04EC-4F53-BE85-E6F1379E11FE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {4E61BF3A-F824-4BDE-9997-21FC53BBDD7A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {56D21A7D-F8A5-436A-B52D-B8427DBE5E3B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {3C9B2F21-27B8-4E51-BAC6-D3401179FB51} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {6EB333E6-A4CF-4D5D-99F4-9E9B00D1DECC} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {C5338C76-F592-4DA9-B20F-2C931749326C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {515AAD98-5249-4323-AECC-CC3F4BBBCE54} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {1C133E13-D1E7-41F8-8464-B814EFA94156} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {3BE862BA-46A2-4393-A2B9-7D52D2F6F05F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5822C359-1E9D-49CF-816A-3037B2AAC936} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2F83D082-E897-4F47-9E6B-73F7032DE238} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8C36A15F-8DD8-4101-A0FE-ADFB7BF4905C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5B4C315E-1212-477B-9697-31B41042A1D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9A0A936E-3C87-4911-B7F0-91B2C6286061} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {F077B358-8F0D-4791-8E85-DDED3550C27A} diff --git a/Codout.Image.Extensions/Codout.Image.Extensions.csproj b/Codout.Image.Extensions/Codout.Image.Extensions.csproj index 7248e52..e3d5be8 100644 --- a/Codout.Image.Extensions/Codout.Image.Extensions.csproj +++ b/Codout.Image.Extensions/Codout.Image.Extensions.csproj @@ -1,9 +1,12 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Biblioteca de funções para imagens - Codout;Framework;Images; + Codout;Imaging;ImageSharp;Crop;Extensions diff --git a/Codout.Image.Extensions/README.md b/Codout.Image.Extensions/README.md new file mode 100644 index 0000000..6d136ba --- /dev/null +++ b/Codout.Image.Extensions/README.md @@ -0,0 +1,57 @@ +# Codout.Image.Extensions + +Extensões para imagens baseadas em [SixLabors.ImageSharp](https://www.nuget.org/packages/SixLabors.ImageSharp): desenho de retângulos e pontos sobre imagens (ex.: marcação de faces e landmarks detectados) e extração/recorte de regiões com redimensionamento. + +## Instalação + +```bash +dotnet add package Codout.Image.Extensions +``` + +## Uso + +A classe `ImageExtensions` expõe métodos de extensão sobre `SixLabors.ImageSharp.Image`. + +Desenhar retângulos (mutação da imagem original) e pontos (retorna uma cópia): + +```csharp +using Codout.Image.Extensions; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; + +using var image = Image.Load("foto.jpg"); +var brush = Brushes.Solid(Color.Red); + +// Muta a imagem, desenhando os retângulos (espessura automática se omitida) +image.DrawRectangles(brush, new[] { new Rectangle(50, 40, 120, 120) }); + +// Retorna uma cópia com os pontos desenhados +using var comPontos = image.DrawPoints(brush, new[] { new Point(80, 90), new Point(140, 95) }); +comPontos.Save("foto-marcada.jpg"); +``` + +Extrair uma região da imagem, limitando o tamanho da maior aresta: + +```csharp +using Codout.Image.Extensions; +using SixLabors.ImageSharp; + +using var image = Image.Load("foto.jpg"); + +// Recorte simples +using var recorte = image.Extract(new Rectangle(50, 40, 300, 300)); + +// Recorte com redução para no máximo 128px na maior aresta +using var thumb = image.Extract(new Rectangle(50, 40, 300, 300), extractedMaxEdgeSize: 128); +thumb.Save("thumb.jpg"); +``` + +Há ainda `DrawRectanglesAndPoints` para desenhar retângulos (`RectangleF`) e pontos (`PointF`) de uma só vez, retornando uma cópia da imagem. + +## Pacotes relacionados + +- [Codout.Framework.Storage](https://www.nuget.org/packages/Codout.Framework.Storage) — abstração de storage do ecossistema Codout, útil para persistir as imagens processadas. +- [Codout.Framework.Storage.Azure](https://www.nuget.org/packages/Codout.Framework.Storage.Azure) — implementação para Azure Blob Storage. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Mailer.AWS/AWSDispatcher.cs b/Codout.Mailer.AWS/AWSDispatcher.cs index b6ccda8..eb37b8b 100644 --- a/Codout.Mailer.AWS/AWSDispatcher.cs +++ b/Codout.Mailer.AWS/AWSDispatcher.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Net.Mail; @@ -24,8 +24,8 @@ public async Task Send(MailAddress from, MailAddress to, string subject, string htmlContent, - string plainTextContent = null, - System.Net.Mail.Attachment[] attachments = null) + string? plainTextContent = null, + System.Net.Mail.Attachment[]? attachments = null) { if (string.IsNullOrWhiteSpace(_settings.AccessKey)) @@ -61,7 +61,7 @@ public async Task Send(MailAddress from, { using var memoryStream = new MemoryStream(); await attachment.ContentStream.CopyToAsync(memoryStream); - bodyBuilder.Attachments.Add(attachment.Name, memoryStream.ToArray(), ContentType.Parse(attachment.ContentType.MediaType)); + bodyBuilder.Attachments.Add(attachment.Name!, memoryStream.ToArray(), ContentType.Parse(attachment.ContentType.MediaType)); } } diff --git a/Codout.Mailer.AWS/Codout.Mailer.AWS.csproj b/Codout.Mailer.AWS/Codout.Mailer.AWS.csproj index decbff6..2a274e6 100644 --- a/Codout.Mailer.AWS/Codout.Mailer.AWS.csproj +++ b/Codout.Mailer.AWS/Codout.Mailer.AWS.csproj @@ -1,9 +1,12 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Sender da Amazon Simple Email Service para envio de E-mails utilizando a biblioteca Codout.Mailer - Codout;Framework;Mail;Email;Mailer;SendGrid; + Codout;Framework;Mail;Email;Mailer;AWS;SES diff --git a/Codout.Mailer.AWS/Configuration/AWSSettings.cs b/Codout.Mailer.AWS/Configuration/AWSSettings.cs index bd88d40..82e0d19 100644 --- a/Codout.Mailer.AWS/Configuration/AWSSettings.cs +++ b/Codout.Mailer.AWS/Configuration/AWSSettings.cs @@ -1,12 +1,12 @@ -namespace Codout.Mailer.AWS.Configuration; +namespace Codout.Mailer.AWS.Configuration; public class AWSSettings { public const string SectionName = "AWSSettings"; - public string RegionEndpoint { get; set; } + public string RegionEndpoint { get; set; } = null!; - public string AccessKey { get; set; } + public string AccessKey { get; set; } = null!; - public string SecretKey { get; set; } -} \ No newline at end of file + public string SecretKey { get; set; } = null!; +} diff --git a/Codout.Mailer.AWS/README.md b/Codout.Mailer.AWS/README.md new file mode 100644 index 0000000..cee8edc --- /dev/null +++ b/Codout.Mailer.AWS/README.md @@ -0,0 +1,50 @@ +# Codout.Mailer.AWS + +Dispatcher de e-mails via Amazon SES (Simple Email Service v2) para a biblioteca [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer), implementando `IMailerDispatcher` com a classe `AWSDispatcher`. + +## Instalação + +```bash +dotnet add package Codout.Mailer.AWS +``` + +Requer .NET 10 (`net10.0`). + +## Uso + +Registre o mailer com o dispatcher do SES usando a extensão `AddMailerWithAws` (que já chama `AddMailer` internamente e registra `AWSDispatcher` como `IMailerDispatcher`): + +```csharp +using Codout.Mailer.AWS.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMailerWithAws(builder.Configuration); +``` + +Configure as credenciais no `appsettings.json`, na seção `AWSSettings` (classe `AWSSettings`, propriedades `RegionEndpoint`, `AccessKey` e `SecretKey` — todas obrigatórias): + +```json +{ + "MailerSettings": { + "DefaultFromName": "Minha Empresa", + "DefaultFromEmail": "noreply@minhaempresa.com" + }, + "AWSSettings": { + "RegionEndpoint": "us-east-1", + "AccessKey": "sua-access-key", + "SecretKey": "sua-secret-key" + } +} +``` + +O envio normalmente é feito por um serviço que herda de `MailerServiceBase` (veja o README do Codout.Mailer), mas o dispatcher também pode ser injetado e usado diretamente via `IMailerDispatcher.Send(from, to, subject, htmlContent, plainTextContent, attachments)`, que retorna um `MailerResponse` com `Sent` e `ErrorMessages`. Anexos (`System.Net.Mail.Attachment[]`) são montados via MimeKit e enviados como mensagem raw ao SES. + +## Pacotes relacionados + +- [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer) — núcleo (`IMailerService`, `IMailerDispatcher`, `ITemplateEngine`, `MailerServiceBase`). +- [Codout.Mailer.Razor](https://www.nuget.org/packages/Codout.Mailer.Razor) — template engine Razor para renderizar os e-mails. +- [Codout.Mailer.SendGrid](https://www.nuget.org/packages/Codout.Mailer.SendGrid) — dispatcher alternativo via SendGrid. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Mailer.Razor/Codout.Mailer.Razor.csproj b/Codout.Mailer.Razor/Codout.Mailer.Razor.csproj index d1adeeb..4457402 100644 --- a/Codout.Mailer.Razor/Codout.Mailer.Razor.csproj +++ b/Codout.Mailer.Razor/Codout.Mailer.Razor.csproj @@ -1,7 +1,10 @@ - 6.3.0 + 6.4.0 + + true + 6.3.0 Implementação do template engine para Codout.Mailer utilizando a engine Razor nativa do ASP.NET Core Codout;Framework;Mail;Email;Mailer;Razor; diff --git a/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs b/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs index 60a90be..de58e7b 100644 --- a/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs +++ b/Codout.Mailer.Razor/Configuration/RazorMailerOptions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Reflection; namespace Codout.Mailer.Razor.Configuration; @@ -11,12 +11,12 @@ public class RazorMailerOptions /// /// Assembly que contém os templates Razor embarcados como recursos /// - public Assembly TemplateAssembly { get; set; } + public Assembly TemplateAssembly { get; set; } = null!; /// /// Namespace raiz dos templates embarcados no assembly /// - public string RootNamespace { get; set; } + public string RootNamespace { get; set; } = null!; /// /// Habilita o cache de templates compilados em memória diff --git a/Codout.Mailer.Razor/README.md b/Codout.Mailer.Razor/README.md new file mode 100644 index 0000000..62c48e7 --- /dev/null +++ b/Codout.Mailer.Razor/README.md @@ -0,0 +1,60 @@ +# Codout.Mailer.Razor + +Implementação de `ITemplateEngine` para a biblioteca [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer) usando a engine Razor nativa do ASP.NET Core (classe `RazorViewTemplateEngine`), com templates embarcados como recursos do assembly. + +## Instalação + +```bash +dotnet add package Codout.Mailer.Razor +``` + +Requer .NET 10 (`net10.0`) e o framework `Microsoft.AspNetCore.App`. + +## Uso + +Registre o template engine com a extensão `AddMailerRazor`, depois de `AddMailer()` (ou de `AddMailerWithSendGrid`/`AddMailerWithAws`). As opções `TemplateAssembly` e `RootNamespace` de `RazorMailerOptions` são obrigatórias: + +```csharp +using Codout.Mailer.Razor.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMailerWithSendGrid(builder.Configuration); + +builder.Services.AddMailerRazor(options => +{ + options.TemplateAssembly = typeof(Program).Assembly; + options.RootNamespace = "MeuProjeto.Templates"; + options.EnableCache = true; // padrão: true +}); +``` + +Os templates `.cshtml` devem ser embarcados no assembly indicado: + +```xml + + + +``` + +A renderização é feita por `RenderAsync(string templateKey, T model)` — chamada automaticamente por `MailerServiceBase.Send`, mas também utilizável de forma direta: + +```csharp +using Codout.Mailer.Interfaces; + +public class PreviaService(ITemplateEngine templateEngine) +{ + public Task GerarHtmlAsync(BoasVindasModel model) => + templateEngine.RenderAsync("BoasVindas", model); +} +``` +Se o template não for encontrado, é lançada `InvalidOperationException` listando os locais pesquisados. + +## Pacotes relacionados + +- [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer) — núcleo (`IMailerService`, `IMailerDispatcher`, `ITemplateEngine`, `MailerServiceBase`). +- [Codout.Mailer.SendGrid](https://www.nuget.org/packages/Codout.Mailer.SendGrid) — dispatcher via SendGrid. +- [Codout.Mailer.AWS](https://www.nuget.org/packages/Codout.Mailer.AWS) — dispatcher via Amazon SES. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Mailer.SendGrid/Codout.Mailer.SendGrid.csproj b/Codout.Mailer.SendGrid/Codout.Mailer.SendGrid.csproj index 1df58e8..55b9e8e 100644 --- a/Codout.Mailer.SendGrid/Codout.Mailer.SendGrid.csproj +++ b/Codout.Mailer.SendGrid/Codout.Mailer.SendGrid.csproj @@ -1,7 +1,10 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Sender da SendGrid para envio de E-mails utilizando a biblioteca Codout.Mailer Codout;Framework;Mail;Email;Mailer;SendGrid; diff --git a/Codout.Mailer.SendGrid/Configuration/SendGridSettings.cs b/Codout.Mailer.SendGrid/Configuration/SendGridSettings.cs index 0ec373f..e9fad00 100644 --- a/Codout.Mailer.SendGrid/Configuration/SendGridSettings.cs +++ b/Codout.Mailer.SendGrid/Configuration/SendGridSettings.cs @@ -1,10 +1,10 @@ -namespace Codout.Mailer.SendGrid.Configuration; +namespace Codout.Mailer.SendGrid.Configuration; public class SendGridSettings { public const string SectionName = "SendGridSettings"; - public string ApiKey { get; set; } + public string ApiKey { get; set; } = null!; public bool StandBox { get; set; } -} \ No newline at end of file +} diff --git a/Codout.Mailer.SendGrid/README.md b/Codout.Mailer.SendGrid/README.md new file mode 100644 index 0000000..4e0459e --- /dev/null +++ b/Codout.Mailer.SendGrid/README.md @@ -0,0 +1,48 @@ +# Codout.Mailer.SendGrid + +Dispatcher de e-mails via SendGrid para a biblioteca [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer), implementando `IMailerDispatcher` com a classe `SendGridDispatcher`. + +## Instalação + +```bash +dotnet add package Codout.Mailer.SendGrid +``` + +Requer .NET 10 (`net10.0`). + +## Uso + +Registre o mailer com o dispatcher do SendGrid usando a extensão `AddMailerWithSendGrid` (que já chama `AddMailer` internamente e registra `SendGridDispatcher` como `IMailerDispatcher`): + +```csharp +using Codout.Mailer.SendGrid.Configuration; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMailerWithSendGrid(builder.Configuration); +``` + +Configure a chave de API no `appsettings.json`, na seção `SendGridSettings` (classe `SendGridSettings`, propriedade `ApiKey`): + +```json +{ + "MailerSettings": { + "DefaultFromName": "Minha Empresa", + "DefaultFromEmail": "noreply@minhaempresa.com" + }, + "SendGridSettings": { + "ApiKey": "SG.sua-api-key" + } +} +``` + +O envio normalmente é feito por um serviço que herda de `MailerServiceBase` (veja o README do Codout.Mailer), mas o dispatcher também pode ser injetado e usado diretamente via `IMailerDispatcher.Send(from, to, subject, htmlContent, plainTextContent, attachments)`, que retorna um `MailerResponse` com `Sent` (verdadeiro quando o SendGrid responde `202 Accepted`) e `ErrorMessages`. Anexos (`System.Net.Mail.Attachment[]`) são convertidos para Base64 e adicionados à mensagem. + +## Pacotes relacionados + +- [Codout.Mailer](https://www.nuget.org/packages/Codout.Mailer) — núcleo (`IMailerService`, `IMailerDispatcher`, `ITemplateEngine`, `MailerServiceBase`). +- [Codout.Mailer.Razor](https://www.nuget.org/packages/Codout.Mailer.Razor) — template engine Razor para renderizar os e-mails. +- [Codout.Mailer.AWS](https://www.nuget.org/packages/Codout.Mailer.AWS) — dispatcher alternativo via Amazon SES. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Mailer.SendGrid/SendGridDispatcher.cs b/Codout.Mailer.SendGrid/SendGridDispatcher.cs index 8723011..7dd1038 100644 --- a/Codout.Mailer.SendGrid/SendGridDispatcher.cs +++ b/Codout.Mailer.SendGrid/SendGridDispatcher.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using System.Net.Mail; @@ -19,7 +19,7 @@ public class SendGridDispatcher(IOptions sendGridSettings) : I private readonly SendGridSettings _sendGridSettings = sendGridSettings.Value; public async Task Send(MailAddress from, MailAddress to, string subject, string htmlContent, - string plainTextContent = null, Attachment[] attachments = null) + string? plainTextContent = null, Attachment[]? attachments = null) { var client = new SendGridClient(_sendGridSettings.ApiKey); @@ -56,4 +56,4 @@ public async Task Send(MailAddress from, MailAddress to, string }; } } -} \ No newline at end of file +} diff --git a/Codout.Mailer/Codout.Mailer.csproj b/Codout.Mailer/Codout.Mailer.csproj index 245955b..7c3fe88 100644 --- a/Codout.Mailer/Codout.Mailer.csproj +++ b/Codout.Mailer/Codout.Mailer.csproj @@ -1,7 +1,10 @@  - 6.3.0 + 6.4.0 + + true + 6.3.0 Biblioteca extensível para envio de e-mails com suporte a múltiplos providers (SendGrid, AWS SES), health checks e observabilidade. O template engine é plugável via ITemplateEngine (veja Codout.Mailer.Razor). Codout;Framework;Mail;Email;Mailer; diff --git a/Codout.Mailer/Configuration/MailerSettings.cs b/Codout.Mailer/Configuration/MailerSettings.cs index c2a72f5..3791269 100644 --- a/Codout.Mailer/Configuration/MailerSettings.cs +++ b/Codout.Mailer/Configuration/MailerSettings.cs @@ -4,7 +4,7 @@ public class MailerSettings { public const string SectionName = "MailerSettings"; - public string DefaultFromName { get; set; } + public string DefaultFromName { get; set; } = null!; - public string DefaultFromEmail { get; set; } + public string DefaultFromEmail { get; set; } = null!; } \ No newline at end of file diff --git a/Codout.Mailer/Interfaces/IMailerDispatcher.cs b/Codout.Mailer/Interfaces/IMailerDispatcher.cs index b96bde4..06da867 100644 --- a/Codout.Mailer/Interfaces/IMailerDispatcher.cs +++ b/Codout.Mailer/Interfaces/IMailerDispatcher.cs @@ -7,5 +7,5 @@ namespace Codout.Mailer.Interfaces; public interface IMailerDispatcher { Task Send(MailAddress from, MailAddress to, string subject, string htmlContent, - string plainTextContent = null, Attachment[] attachments = null); + string? plainTextContent = null, Attachment[]? attachments = null); } \ No newline at end of file diff --git a/Codout.Mailer/Interfaces/IMailerService.cs b/Codout.Mailer/Interfaces/IMailerService.cs index 8ef7913..6a89107 100644 --- a/Codout.Mailer/Interfaces/IMailerService.cs +++ b/Codout.Mailer/Interfaces/IMailerService.cs @@ -6,6 +6,6 @@ namespace Codout.Mailer.Interfaces; public interface IMailerService { - Task Send(string templateKey, T model, string subject, Attachment[] attachments = null) + Task Send(string templateKey, T model, string subject, Attachment[]? attachments = null) where T : MailerModelBase; } \ No newline at end of file diff --git a/Codout.Mailer/Models/MailerModelBase.cs b/Codout.Mailer/Models/MailerModelBase.cs index 13c5188..0200a63 100644 --- a/Codout.Mailer/Models/MailerModelBase.cs +++ b/Codout.Mailer/Models/MailerModelBase.cs @@ -4,5 +4,5 @@ namespace Codout.Mailer.Models; public class MailerModelBase { - public MailAddress To { get; set; } + public MailAddress To { get; set; } = null!; } \ No newline at end of file diff --git a/Codout.Mailer/Models/MailerResponse.cs b/Codout.Mailer/Models/MailerResponse.cs index 8de74e0..a5639a1 100644 --- a/Codout.Mailer/Models/MailerResponse.cs +++ b/Codout.Mailer/Models/MailerResponse.cs @@ -6,5 +6,5 @@ public class MailerResponse { public bool Sent { get; set; } - public IList ErrorMessages { get; set; } + public IList ErrorMessages { get; set; } = null!; } \ No newline at end of file diff --git a/Codout.Mailer/README.md b/Codout.Mailer/README.md index 8f6007c..e415721 100644 --- a/Codout.Mailer/README.md +++ b/Codout.Mailer/README.md @@ -1,700 +1,50 @@ -# 📧 Codout.Mailer +# Codout.Mailer -Robust and extensible library for sending emails in .NET 10 applications, with support for Razor templates, multiple providers (SendGrid, AWS SES), and complete observability. +Núcleo de envio de e-mails para .NET: define os contratos `IMailerService`, `IMailerDispatcher` e `ITemplateEngine`, a classe base `MailerServiceBase` e o registro de configuração e health check via `AddMailer`. -## 🚀 Features - -- ✅ **Razor Templates**: HTML email rendering using ASP.NET Core's native Razor engine -- ✅ **Multiple Providers**: Support for SendGrid and AWS SES -- ✅ **Dependency Injection**: Native integration with ASP.NET Core DI -- ✅ **Observability**: Distributed tracing and metrics with OpenTelemetry -- ✅ **Health Checks**: Service health monitoring -- ✅ **Smart Caching**: Compiled template caching for performance -- ✅ **Retry Policy**: Automatic retries with exponential backoff -- ✅ **Flexible Configuration**: Configuration via appsettings.json and code +## Instalação -## 📦 Installation - -### Main Package -``` +```bash dotnet add package Codout.Mailer ``` -### Razor Template Engine -``` -dotnet add package Codout.Mailer.Razor -``` +## Uso -### Specific Providers -``` -# For SendGrid -dotnet add package Codout.Mailer.SendGrid - -# For AWS SES -dotnet add package Codout.Mailer.AWS -``` - -## ⚙️ Configuration - -### 1. **Dependency Injection in Program.cs** +Registre as configurações com `AddMailer` (lê a seção `MailerSettings`, com `DefaultFromName` e `DefaultFromEmail`): ```csharp -var builder = WebApplication.CreateBuilder(args); +using Codout.Mailer.Configuration; -// Basic mailer configuration builder.Services.AddMailer(builder.Configuration); - -// Razor template engine (uses ASP.NET Core's native Razor engine) -builder.Services.AddMailerRazor(options => -{ - options.TemplateAssembly = typeof(Program).Assembly; - options.RootNamespace = "YourProject.Templates"; -}); - -// Choose email provider -builder.Services.AddMailerWithSendGrid(builder.Configuration); -// OR -// builder.Services.AddMailerWithAws(builder.Configuration); - -// Register your custom email service -builder.Services.AddScoped(); - -var app = builder.Build(); -``` - -### 2. **Settings in appsettings.json** - -```json -{ - "MailerSettings": { - "DefaultFromName": "Email System", - "DefaultFromEmail": "noreply@example.com" - }, - "SendGridSettings": { - "ApiKey": "SG.your-api-key-here", - "SandboxMode": false - }, - "AWSSettings": { - "RegionEndpoint": "us-east-1", - "AccessKey": "your-access-key", - "SecretKey": "your-secret-key" - } -} ``` -## 🏗️ Implementation in Your Project - -### 1. **Creating Your EmailService Class** - -Create a class that inherits from `MailerServiceBase` in your project: +Crie seu serviço herdando de `MailerServiceBase`, que renderiza o template via `ITemplateEngine`, converte o HTML em texto plano e envia pelo `IMailerDispatcher` registrado: ```csharp -// Services/EmailService.cs -using System.Net.Mail; -using System.Threading.Tasks; -using Codout.Mailer.Configuration; -using Codout.Mailer.Interfaces; using Codout.Mailer.Models; using Codout.Mailer.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using YourProject.Models.Email; - -public class EmailService : MailerServiceBase -{ - public EmailService( - IOptions mailerSettings, - IMailerDispatcher dispatcher, - ITemplateEngine templateEngine, - ILogger logger) - : base(mailerSettings, dispatcher, templateEngine, logger) - { - } - - /// - /// Sends welcome email to new users - /// - public async Task Welcome(WelcomeModel model) - { - return await Send("Welcome", model, "Welcome to our platform!"); - } - - /// - /// Sends password reset email - /// - public async Task PasswordReset(PasswordResetModel model) - { - return await Send("PasswordReset", model, "Password Recovery"); - } - - /// - /// Sends registration confirmation email - /// - public async Task ConfirmRegistration(ConfirmRegistrationModel model) - { - return await Send("ConfirmRegistration", model, "Confirm your registration"); - } - - /// - /// Sends order notification email - /// - public async Task OrderNotification(OrderNotificationModel model) - { - return await Send("OrderNotification", model, $"Order #{model.OrderNumber} - Status Updated"); - } - - /// - /// Sends monthly report with attachment - /// - public async Task MonthlyReport(MonthlyReportModel model, Attachment[] attachments = null) - { - return await Send("MonthlyReport", model, $"Monthly Report - {model.Month:MMMM yyyy}", attachments); - } -} -``` - -### 2. **Creating Email Models** - -Create a `Models/Email` folder and add models for each email type: - -```csharp -// Models/Email/WelcomeModel.cs -using System.Net.Mail; -using Codout.Mailer.Models; - -namespace YourProject.Models.Email; - -public class WelcomeModel : MailerModelBase -{ - public string Name { get; set; } - public string ActivationLink { get; set; } - public string CompanyName { get; set; } = "My Company"; - public string SupportEmail { get; set; } = "support@mycompany.com"; -} -``` - -```csharp -// Models/Email/PasswordResetModel.cs -using System.Net.Mail; -using Codout.Mailer.Models; - -namespace YourProject.Models.Email; - -public class PasswordResetModel : MailerModelBase -{ - public string Name { get; set; } - public string ResetLink { get; set; } - public DateTime ExpiresAt { get; set; } - public string IpAddress { get; set; } -} -``` - -```csharp -// Models/Email/ConfirmRegistrationModel.cs -using System.Net.Mail; -using Codout.Mailer.Models; - -namespace YourProject.Models.Email; - -public class ConfirmRegistrationModel : MailerModelBase -{ - public string Name { get; set; } - public string ConfirmationLink { get; set; } - public string Username { get; set; } -} -``` - -```csharp -// Models/Email/OrderNotificationModel.cs -using System.Net.Mail; -using Codout.Mailer.Models; - -namespace YourProject.Models.Email; - -public class OrderNotificationModel : MailerModelBase -{ - public string CustomerName { get; set; } - public string OrderNumber { get; set; } - public string Status { get; set; } - public decimal TotalAmount { get; set; } - public DateTime OrderDate { get; set; } - public List Items { get; set; } = new(); - public string TrackingUrl { get; set; } -} - -public class OrderItemModel -{ - public string ProductName { get; set; } - public int Quantity { get; set; } - public decimal Price { get; set; } -} -``` - -```csharp -// Models/Email/MonthlyReportModel.cs -using System.Net.Mail; -using Codout.Mailer.Models; - -namespace YourProject.Models.Email; - -public class MonthlyReportModel : MailerModelBase -{ - public string Name { get; set; } - public DateTime Month { get; set; } - public int TotalOrders { get; set; } - public decimal TotalRevenue { get; set; } - public string ReportType { get; set; } - public List Highlights { get; set; } = new(); -} -``` - -## 📝 Creating Email Templates - -### 1. **Folder Structure** - -Create the following structure in your project: - -``` -YourProject/ -├── Templates/ -│ ├── _Layout.cshtml -│ ├── Welcome.cshtml -│ ├── PasswordReset.cshtml -│ ├── ConfirmRegistration.cshtml -│ ├── OrderNotification.cshtml -│ └── MonthlyReport.cshtml -``` - -### 2. **Base Template (_Layout.cshtml)** - -```html - - - - - - @ViewBag.Title - - - -
-
-

My Company

-
- - @RenderBody() - - -
- - -``` - -### 3. **Welcome Template (Welcome.cshtml)** - -```html -@model YourProject.Models.Email.WelcomeModel -@{ - Layout = "_Layout"; - ViewBag.Title = "Welcome"; -} - -

Welcome, @Model.Name!

- -

Thank you for signing up for our platform. We're very happy to have you with us!

- -

To start using all features, click the button below to activate your account:

- - - -

If you can't click the button, copy and paste the link below into your browser:

-

@Model.ActivationLink

- -
- -

If you have any questions, our support team is always ready to help:

-

📧 Email: @Model.SupportEmail

- -

- Best regards,
- @Model.CompanyName Team -

-``` - -### 4. **Password Reset Template (PasswordReset.cshtml)** - -```html -@model YourProject.Models.Email.PasswordResetModel -@{ - Layout = "_Layout"; - ViewBag.Title = "Password Recovery"; -} - -

Hello, @Model.Name!

- -

We received a request to reset your account password.

- -
- ⚠️ Important information: -
    -
  • This link expires on: @Model.ExpiresAt.ToString("MM/dd/yyyy HH:mm")
  • -
  • Request made from IP: @Model.IpAddress
  • -
-
- -

If you made this request, click the button below to reset your password:

- - - -

If you didn't request this change, you can safely ignore this email. Your password will remain unchanged.

- -

- For security, this link can only be used once and expires automatically. -

-``` - -### 5. **Order Template (OrderNotification.cshtml)** -```html -@model YourProject.Models.Email.OrderNotificationModel -@{ - Layout = "_Layout"; - ViewBag.Title = "Order Status"; -} - -

Hello, @Model.CustomerName!

- -

We have an update about your order:

- -
-

Order #@Model.OrderNumber

-

Status: @Model.Status

-

Order Date: @Model.OrderDate.ToString("MM/dd/yyyy")

-

Total: @Model.TotalAmount.ToString("C")

-
- -

Order Items:

- - - - - - - - - - @foreach (var item in Model.Items) - { - - - - - - } - -
ProductQtyPrice
@item.ProductName@item.Quantity@item.Price.ToString("C")
- -@if (!string.IsNullOrEmpty(Model.TrackingUrl)) +public class BoasVindasModel : MailerModelBase { - + public string Nome { get; set; } } -

Thank you for choosing our store!

-``` - -## 🎯 Using in Controllers - -### 1. **Controller Injection** - -```csharp -[ApiController] -[Route("api/[controller]")] -public class UserController : ControllerBase -{ - private readonly EmailService _emailService; - private readonly ILogger _logger; +public class MeuMailerService( + IOptions settings, + IMailerDispatcher dispatcher, + ITemplateEngine templateEngine, + ILogger logger) + : MailerServiceBase(settings, dispatcher, templateEngine, logger); - public UserController(EmailService emailService, ILogger logger) - { - _emailService = emailService; - _logger = logger; - } -} -``` - -### 2. **Sending Emails** - -```csharp -[HttpPost("register")] -public async Task Register([FromBody] RegisterRequest request) -{ - // User registration logic... - - try - { - var activationLink = Url.Action("ActivateAccount", "User", - new { token = user.ActivationToken }, Request.Scheme); - - var response = await _emailService.Welcome(new WelcomeModel - { - Name = request.Name, - To = new MailAddress(request.Email, request.Name), - ActivationLink = activationLink, - CompanyName = "My Company", - SupportEmail = "support@mycompany.com" - }); - - if (response.Sent) - { - _logger.LogInformation("Welcome email sent to {Email}", request.Email); - return Ok(new { message = "User registered! Check your email to activate your account." }); - } - else - { - _logger.LogWarning("Failed to send email to {Email}: {Errors}", - request.Email, string.Join(", ", response.ErrorMessages)); - return Ok(new { message = "User registered, but there was an issue sending the email." }); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error sending welcome email to {Email}", request.Email); - return Ok(new { message = "User registered successfully!" }); - } -} - -[HttpPost("forgot-password")] -public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) -{ - // Password recovery logic... - - var resetLink = Url.Action("ResetPassword", "User", - new { token = resetToken }, Request.Scheme); - - var response = await _emailService.PasswordReset(new PasswordResetModel - { - Name = user.Name, - To = new MailAddress(user.Email, user.Name), - ResetLink = resetLink, - ExpiresAt = DateTime.UtcNow.AddHours(2), - IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() - }); - - return Ok(new { message = "If the email exists, you will receive instructions to reset your password." }); -} -``` - -## 📋 Configuring Templates as Embedded Resources - -For templates to be found by the Razor engine, add to your `.csproj`: - -```xml - - - - net10.0 - - - - - - - +MailerResponse resposta = await mailerService.Send("BoasVindas", model, "Bem-vindo!"); ``` -## 🚀 Complete Usage Example - -```csharp -// Program.cs -var builder = WebApplication.CreateBuilder(args); - -// Mailer configuration -builder.Services.AddMailer(builder.Configuration); - -// Razor template engine (ASP.NET Core native) -builder.Services.AddMailerRazor(options => -{ - options.TemplateAssembly = typeof(Program).Assembly; - options.RootNamespace = "YourProject.Templates"; - options.EnableCache = true; -}); - -// Email provider -builder.Services.AddMailerWithSendGrid(builder.Configuration); - -// Your custom service -builder.Services.AddScoped(); - -// Other services... -builder.Services.AddControllers(); - -var app = builder.Build(); - -// Pipeline... -app.MapControllers(); -app.Run(); -``` - -## 🔧 Advanced Configurations - -### **Razor Template Engine** -```csharp -builder.Services.AddMailerRazor(options => -{ - options.TemplateAssembly = typeof(Program).Assembly; - options.RootNamespace = "YourProject.Templates"; - options.EnableCache = true; // Enables compiled template caching (default: true) -}); -``` - -### **Custom Template Engine** - -You can implement `ITemplateEngine` to use any template engine of your choice: - -```csharp -public class MyCustomTemplateEngine : ITemplateEngine -{ - public async Task RenderAsync(string templateKey, T model) - { - // Your custom rendering logic - } -} - -// Register in DI -builder.Services.AddScoped(); -``` - -## 📊 Monitoring and Observability - -### **Health Checks** -```csharp -// Automatically added when using AddMailer() -app.MapHealthChecks("/health"); -``` - -### **Metrics and Tracing** -The library automatically generates: -- **Traces**: For each email send operation -- **Metrics**: Counters for sent/failed emails -- **Structured logs**: For debugging and auditing - -## 🚨 Error Handling - -```csharp -try -{ - var response = await _emailService.Welcome(model); - - if (!response.Sent) - { - // Email was not sent - _logger.LogWarning("Send failure: {Errors}", - string.Join(", ", response.ErrorMessages)); - } -} -catch (TemplateNotFoundException ex) -{ - _logger.LogError("Template not found: {Template}", ex.TemplateName); -} -catch (EmailProviderException ex) -{ - _logger.LogError("Email provider error: {Error}", ex.Message); -} -catch (Exception ex) -{ - _logger.LogError(ex, "Unexpected error sending email"); -} -``` - -## 📋 Available Configurations - -### MailerSettings (appsettings.json) - -| Configuration | Description | Default | -|-------------|-----------|--------| -| `DefaultFromName` | Default sender name | `null` | -| `DefaultFromEmail` | Default sender email | `null` | - -### RazorMailerOptions (Codout.Mailer.Razor) - -| Configuration | Description | Default | -|-------------|-----------|--------| -| `TemplateAssembly` | Assembly containing embedded Razor templates | `null` | -| `RootNamespace` | Root namespace of embedded templates | `null` | -| `EnableCache` | Enable compiled template caching | `true` | - -## 🔧 Tips and Best Practices - -### ✅ **Do's** -- ✅ Always inherit from `MailerServiceBase` for your custom class -- ✅ Create specific models inheriting from `MailerModelBase` -- ✅ Use Razor templates organized in folders -- ✅ Register a template engine via `AddMailerRazor()` or a custom `ITemplateEngine` -- ✅ Handle exceptions when sending emails -- ✅ Use logging for auditing - -### ❌ **Don'ts** -- ❌ Don't use `IMailerService` directly in controllers -- ❌ Don't forget to configure templates as Embedded Resources -- ❌ Don't expose send errors to end users -- ❌ Don't send emails without data validation - -## 📚 Next Steps +Este pacote não envia e-mails sozinho: é necessário registrar uma implementação de `IMailerDispatcher` (provedor) e de `ITemplateEngine` (renderização de templates). -1. **Advanced Templates**: Create responsive layouts -2. **Internationalization**: Support for multiple languages -3. **Email Queue**: Integration with background services -4. **Analytics**: Open and click tracking +## Pacotes relacionados ---- +- `Codout.Mailer.SendGrid` — dispatcher para SendGrid. +- `Codout.Mailer.AWS` — dispatcher para Amazon SES. +- `Codout.Mailer.Razor` — template engine Razor para os e-mails. -Now you have everything you need to implement a robust email sending system! 🎉 +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Mailer/Services/MailerHealthCheck.cs b/Codout.Mailer/Services/MailerHealthCheck.cs index 77f3545..55b63f5 100644 --- a/Codout.Mailer/Services/MailerHealthCheck.cs +++ b/Codout.Mailer/Services/MailerHealthCheck.cs @@ -4,10 +4,15 @@ using Codout.Mailer.Interfaces; using Microsoft.Extensions.Diagnostics.HealthChecks; +#pragma warning disable CA1050 // Tipo mantido fora de namespace para preservar a API pública atual. public class MailerHealthCheck : IHealthCheck +#pragma warning restore CA1050 { - private readonly IMailerDispatcher _dispatcher; - +#pragma warning disable CS0169 // Campo reservado para verificação de conectividade futura. + private readonly IMailerDispatcher? _dispatcher; +#pragma warning restore CS0169 + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { try diff --git a/Codout.Mailer/Services/MailerServiceBase.cs b/Codout.Mailer/Services/MailerServiceBase.cs index 8da9817..aa28944 100644 --- a/Codout.Mailer/Services/MailerServiceBase.cs +++ b/Codout.Mailer/Services/MailerServiceBase.cs @@ -20,7 +20,7 @@ public abstract class MailerServiceBase( { private readonly MailerSettings _mailerSettings = mailerSettings.Value; - public virtual async Task Send(string templateKey, T model, string subject, Attachment[] attachments = null) where T : MailerModelBase + public virtual async Task Send(string templateKey, T model, string subject, Attachment[]? attachments = null) where T : MailerModelBase { using var activity = MailerActivitySource.StartActivity("MailerService.Send"); logger.LogInformation("Sending email with template {TemplateKey} to {Recipient}", templateKey, model.To.Address); diff --git a/Codout.Multitenancy/Codout.Multitenancy.csproj b/Codout.Multitenancy/Codout.Multitenancy.csproj index 3239b41..c31820a 100644 --- a/Codout.Multitenancy/Codout.Multitenancy.csproj +++ b/Codout.Multitenancy/Codout.Multitenancy.csproj @@ -1,15 +1,16 @@ - 6.3.0 + 6.4.0 + + true + 6.3.0 Modulo Multitenancy da Codout para aplicações Asp.net Core - Codout;Framework + Codout;Multitenancy;MultiTenant;SaaS;AspNetCore - - diff --git a/Codout.Multitenancy/ITenantResolver.cs b/Codout.Multitenancy/ITenantResolver.cs index 83e07d2..92d9000 100644 --- a/Codout.Multitenancy/ITenantResolver.cs +++ b/Codout.Multitenancy/ITenantResolver.cs @@ -1,9 +1,14 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; namespace Codout.Multitenancy; public interface ITenantResolver { + // Contexto nullable-oblivious: implementações históricas tanto devolvem + // TenantContext quanto null; manter o membro fora do contexto anotado + // preserva a compatibilidade nas duas direções. +#nullable disable Task ResolveAsync(HttpContext context); -} \ No newline at end of file +#nullable restore +} diff --git a/Codout.Multitenancy/Internal/PrimaryHostnameRedirectMiddleware.cs b/Codout.Multitenancy/Internal/PrimaryHostnameRedirectMiddleware.cs index 2d06204..cf5b483 100644 --- a/Codout.Multitenancy/Internal/PrimaryHostnameRedirectMiddleware.cs +++ b/Codout.Multitenancy/Internal/PrimaryHostnameRedirectMiddleware.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -30,7 +30,7 @@ public async Task Invoke(HttpContext context) var primaryHostname = _primaryHostnameAccessor((TTenant)tenantContext.Tenant); if (!string.IsNullOrWhiteSpace(primaryHostname)) - if (!context.Request.Host.Value.Equals(primaryHostname, StringComparison.OrdinalIgnoreCase)) + if (!context.Request.Host.Value!.Equals(primaryHostname, StringComparison.OrdinalIgnoreCase)) { Redirect(context, primaryHostname); return; diff --git a/Codout.Multitenancy/MemoryCacheTenantResolver.cs b/Codout.Multitenancy/MemoryCacheTenantResolver.cs index af46fee..8d4e4fb 100644 --- a/Codout.Multitenancy/MemoryCacheTenantResolver.cs +++ b/Codout.Multitenancy/MemoryCacheTenantResolver.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -27,7 +27,7 @@ async Task ITenantResolver.ResolveAsync(HttpContext context) { var cacheKey = GetContextIdentifier(context); - if (cacheKey == null) return null; + if (cacheKey == null) return null!; var tenantContext = Cache.Get(cacheKey) as TenantContext; @@ -48,7 +48,7 @@ async Task ITenantResolver.ResolveAsync(HttpContext context) } } - return tenantContext; + return tenantContext!; } protected virtual MemoryCacheEntryOptions CreateCacheEntryOptions() @@ -56,13 +56,13 @@ protected virtual MemoryCacheEntryOptions CreateCacheEntryOptions() return new MemoryCacheEntryOptions().SetSlidingExpiration(new TimeSpan(1, 0, 0)); } - protected virtual void DisposeTenantContext(object cacheKey, TenantContext tenantContext) + protected virtual void DisposeTenantContext(object cacheKey, TenantContext? tenantContext) { tenantContext?.Dispose(); } - protected abstract string GetContextIdentifier(HttpContext context); - protected abstract string GetTenantIdentifier(TenantContext context); + protected abstract string? GetContextIdentifier(HttpContext context); + protected abstract string? GetTenantIdentifier(TenantContext context); protected abstract Task ResolveAsync(HttpContext context); private MemoryCacheEntryOptions GetCacheEntryOptions() diff --git a/Codout.Multitenancy/MultitenancyHttpContextExtensions.cs b/Codout.Multitenancy/MultitenancyHttpContextExtensions.cs index a76da9a..aa34448 100644 --- a/Codout.Multitenancy/MultitenancyHttpContextExtensions.cs +++ b/Codout.Multitenancy/MultitenancyHttpContextExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; namespace Codout.Multitenancy; @@ -14,7 +14,7 @@ public static void SetTenantContext(this HttpContext context, TenantContext tena context.Items[TenantContextKey] = tenantContext; } - public static TenantContext GetTenantContext(this HttpContext context) + public static TenantContext? GetTenantContext(this HttpContext context) { if (context.Items.TryGetValue(TenantContextKey, out var tenantContext)) return tenantContext as TenantContext; @@ -22,9 +22,9 @@ public static TenantContext GetTenantContext(this HttpContext context) return null; } - public static TTenant GetTenant(this HttpContext context) where TTenant : IAppTenant + public static TTenant? GetTenant(this HttpContext context) where TTenant : IAppTenant { var tenantContext = GetTenantContext(context); - return (TTenant)(tenantContext != null ? tenantContext.Tenant : default(TTenant)); + return tenantContext != null ? (TTenant)tenantContext.Tenant : default; } } \ No newline at end of file diff --git a/Codout.Multitenancy/MultitenancyOptions.cs b/Codout.Multitenancy/MultitenancyOptions.cs index 7afba8a..9fb72e0 100644 --- a/Codout.Multitenancy/MultitenancyOptions.cs +++ b/Codout.Multitenancy/MultitenancyOptions.cs @@ -1,8 +1,8 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; namespace Codout.Multitenancy; public class MultitenancyOptions where TTenant : IAppTenant { - public Collection Tenants { get; set; } + public Collection Tenants { get; set; } = null!; } \ No newline at end of file diff --git a/Codout.Multitenancy/MultitenancyServiceCollectionExtensions.cs b/Codout.Multitenancy/MultitenancyServiceCollectionExtensions.cs index c5ea932..c7c456e 100644 --- a/Codout.Multitenancy/MultitenancyServiceCollectionExtensions.cs +++ b/Codout.Multitenancy/MultitenancyServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,9 +11,9 @@ public static IServiceCollection AddMultitenancy(this IServiceCollect { services.AddScoped(); services.TryAddSingleton(); - services.AddScoped(prov => prov.GetService()?.HttpContext?.GetTenantContext()); - services.AddScoped(prov => prov.GetService()?.Tenant); - services.AddScoped>(prov => new TenantWrapper(prov.GetService())); + services.AddScoped(prov => prov.GetService()?.HttpContext?.GetTenantContext()!); + services.AddScoped(prov => prov.GetService()?.Tenant!); + services.AddScoped>(prov => new TenantWrapper(prov.GetService()!)); var resolverType = typeof(TResolver); if (typeof(MemoryCacheTenantResolver).IsAssignableFrom(resolverType)) diff --git a/Codout.Multitenancy/README.md b/Codout.Multitenancy/README.md new file mode 100644 index 0000000..ad49796 --- /dev/null +++ b/Codout.Multitenancy/README.md @@ -0,0 +1,57 @@ +# Codout.Multitenancy + +Módulo de multitenancy para aplicações ASP.NET Core: resolve o tenant por requisição via middleware e expõe `IAppTenant`/`TenantContext` no container de DI com escopo de request. + +> **Softprime.Multitenancy**: a mesma pasta gera também o pacote `Softprime.Multitenancy`, um build `netstandard2.0` de compatibilidade com exatamente o mesmo código-fonte (o pacote principal `Codout.Multitenancy` tem target `net10.0`). + +## Instalação + +```bash +dotnet add package Codout.Multitenancy +``` + +## Uso + +Implemente `ITenantResolver` (ou herde de `MemoryCacheTenantResolver` para ter cache em memória). O tenant resolvido deve implementar `IAppTenant` (`TenantKey`, `DataBaseType`, `ConnectionString`): + +```csharp +using Codout.Multitenancy; +using Microsoft.AspNetCore.Http; + +public class AppTenant : IAppTenant +{ + public string TenantKey { get; set; } + public DataBaseType DataBaseType { get; set; } + public string ConnectionString { get; set; } +} + +public class HostTenantResolver : ITenantResolver +{ + public Task ResolveAsync(HttpContext context) + { + var tenant = new AppTenant { TenantKey = context.Request.Host.Host }; + return Task.FromResult(new TenantContext(tenant)); + } +} +``` + +Registre com `AddMultitenancy()` e ative o middleware com `UseMultitenancy()`: + +```csharp +builder.Services.AddMultitenancy(); + +var app = builder.Build(); +app.UseMultitenancy(); // antes dos endpoints que dependem do tenant +``` + +A partir daí, injete `IAppTenant` (ou `ITenant`/`TenantContext`) em serviços scoped, ou leia direto do request com `HttpContext.GetTenantContext()` / `HttpContext.GetTenant()`. + +## Pacotes relacionados + +- [Codout.Framework.Data](https://www.nuget.org/packages/Codout.Framework.Data) — abstrações `IRepository` / `IUnitOfWork`. +- [Codout.Framework.EF](https://www.nuget.org/packages/Codout.Framework.EF) — persistência com Entity Framework Core (connection string por tenant). +- [Codout.Framework.NH](https://www.nuget.org/packages/Codout.Framework.NH) — persistência com NHibernate. +- [Codout.Framework.Application](https://www.nuget.org/packages/Codout.Framework.Application) — camada de aplicação/serviços. + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/Codout.Multitenancy/Softprime.Multitenancy.csproj b/Codout.Multitenancy/Softprime.Multitenancy.csproj index 25e04fe..22310bb 100644 --- a/Codout.Multitenancy/Softprime.Multitenancy.csproj +++ b/Codout.Multitenancy/Softprime.Multitenancy.csproj @@ -1,8 +1,26 @@ - + - 6.3.0 + + obj\softprime\ + obj\softprime\ + bin\softprime\ + + + + + + 6.4.0 + Build netstandard2.0 de compatibilidade do Codout.Multitenancy (resolução de tenant por request para ASP.NET Core 2.x). Para projetos novos use Codout.Multitenancy. + Codout;Multitenancy;MultiTenant;SaaS + + true + 6.3.0 netstandard2.0 + + $(DefaultItemExcludes);obj/**;bin/** @@ -14,4 +32,6 @@ + + diff --git a/Codout.Multitenancy/TenantContext.cs b/Codout.Multitenancy/TenantContext.cs index 7e8f4c4..562300b 100644 --- a/Codout.Multitenancy/TenantContext.cs +++ b/Codout.Multitenancy/TenantContext.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace Codout.Multitenancy; @@ -37,7 +37,7 @@ protected virtual void Dispose(bool disposing) _disposed = true; } - private void TryDisposeProperty(IDisposable obj) + private void TryDisposeProperty(IDisposable? obj) { if (obj == null) return; diff --git a/Codout.Multitenancy/TenantPipelineBuilderContext.cs b/Codout.Multitenancy/TenantPipelineBuilderContext.cs index a074c08..bdf4f4b 100644 --- a/Codout.Multitenancy/TenantPipelineBuilderContext.cs +++ b/Codout.Multitenancy/TenantPipelineBuilderContext.cs @@ -1,7 +1,7 @@ -namespace Codout.Multitenancy; +namespace Codout.Multitenancy; public class TenantPipelineBuilderContext where TTenant : IAppTenant { - public TenantContext TenantContext { get; set; } - public TTenant Tenant { get; set; } + public TenantContext TenantContext { get; set; } = null!; + public TTenant Tenant { get; set; } = default!; } \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 4f0b757..121a003 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,9 +14,26 @@ net10.0 git false - - + MIT logo-nuget.png + + + enable + true + true + latest-recommended + + true + $(NoWarn);CS1591 + + + + true + true + true + true + snupkg @@ -25,4 +42,8 @@ - \ No newline at end of file + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..585b68a --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,13 @@ + + + + README.md + true + + + + + diff --git a/NetCore/Codout.Framework.Commom/Codout.Framework.Commom.csproj b/NetCore/Codout.Framework.Commom/Codout.Framework.Commom.csproj deleted file mode 100644 index e3ecec9..0000000 --- a/NetCore/Codout.Framework.Commom/Codout.Framework.Commom.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - netcoreapp2.2 - - - - Miscellaneous utility library package (CPF / CNPJ Validation, Range of Dates, Converters and etc) for .Net Core development - - - - - - - - - \ No newline at end of file diff --git a/NetCore/Codout.Framework.Data/Codout.Framework.NetCore.Data.csproj b/NetCore/Codout.Framework.Data/Codout.Framework.NetCore.Data.csproj deleted file mode 100644 index 32c981d..0000000 --- a/NetCore/Codout.Framework.Data/Codout.Framework.NetCore.Data.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - netcoreapp2.0 - - - - - - - \ No newline at end of file diff --git a/NetCore/Codout.Framework.Data/DatabaseCore.cs b/NetCore/Codout.Framework.Data/DatabaseCore.cs deleted file mode 100644 index 62e82f2..0000000 --- a/NetCore/Codout.Framework.Data/DatabaseCore.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace Codout.Framework.NetCore.Data -{ - - public abstract class DatabaseCore - { - /// - /// Executa um comando SQL SELECT no banco de dados conectado. - /// ATENÇÃO na superclasse DB ela não faz nada!!! - /// SOMENTE UTILIZAR QUANDO O OBJETO FOR DO TIPO DBCon - /// - /// Query a ser executada - public virtual void ExecutaConsulta(string sql) - { - } - - /// - /// Executa um comando SQL SELECT no banco de dados conectado. - /// ATENÇÃO na superclasse DB ela não faz nada!!! - /// SOMENTE UTILIZAR QUANDO O OBJETO FOR DO TIPO DBCon - /// - /// Query a ser executada - /// se true, converte para maiúsculo. - public virtual void ExecutaConsulta(string sql, bool upperCase) - { - } - - /// - /// Move para o próximo registro. - /// ATENÇÃO na superclasse DB não faz nada!!! - /// - /// Retorna true se obtiver sucesso - public virtual bool MoveProximo() - { - return true; - } - - /// - /// Obtem o valor da coluna indicada, através do número da mesma (inicando em zero). - /// ATENÇÃO na superclasse DB não faz nada!!! - /// - /// Número da coluna - /// Retorna um objeto com o valor da coluna - public virtual object Valor(int numero) - { - return null; - } - - /// - /// Obtem o valor da coluna indicada, através do nome da coluna. - /// ATENÇÃO na superclasse DB não faz nada!!! - /// - /// Nome da coluna - /// Retorna um objeto com o valor da coluna - public virtual object Valor(string nomeColuna) - { - return null; - } - - /// - /// Obtem o valor da coluna indicada, através do nome da coluna. - /// ATENÇÃO na superclasse DB não faz nada!!! - /// - /// Nome da coluna - /// Retorna um byte[] com o valor da coluna - public virtual byte[] ValorBytes(string nomeColuna) - { - return null; - } - - /// - /// Obtem o valor da coluna indicada, através do nome da coluna. - /// ATENÇÃO na superclasse DB não faz nada!!! - /// - /// Numero coluna - /// Retorna um byte[] com o valor da coluna - public virtual byte[] ValorBytes(int numeroColuna) - { - return null; - } - } -} diff --git a/NetCore/Codout.Framework.Data/DatabaseUtil.cs b/NetCore/Codout.Framework.Data/DatabaseUtil.cs deleted file mode 100644 index 78263cf..0000000 --- a/NetCore/Codout.Framework.Data/DatabaseUtil.cs +++ /dev/null @@ -1,761 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; - -namespace Codout.Framework.NetCore.Data -{ - public abstract class DatabaseUtil : DatabaseCore - { - - #region Atributos locais - /// - /// Nome da classe para identificação - /// - private readonly string _className = "Codout.Framework.NetCore.Data.DatabaseUtil."; - - /// - /// indica se o reader está aberto ou não - /// - private bool _readerAberto; - - /// - /// indicador para controlar a primeira execução de um comando preparado - /// - private bool _primeiroExecutaComandoPrep = true; - - private DbCommand _comando; - private DbDataReader _reader; - private DbTransaction _transacao; - #endregion - - #region Construtor / Destrutor - /// - /// Construtor com conexão - /// - /// Objeto defidamente tipado da conexão - protected DatabaseUtil(DbConnection objDbConnection) - { - - Conexao = objDbConnection; - } - - /// - /// Destrutor, libera a conexão com o banco de dados caso ainda não esteja feito. - /// - ~DatabaseUtil() - { - Desconecta(); - } - - public DataTable GetSchema() - { - return Conexao.GetSchema(); - } - #endregion - - #region Conexão / Desconexão - /// - /// Efetua a conexão com o banco de dados, já disponiblizando os métodos de consultas através de reader e comandos (comand). - /// - /// Indica a string da conexão a ser utilizada para conectar no banco - public void Conecta(string stringConexao) - { - try - { - - if (Conectado) - Conexao.Close(); - Conexao.ConnectionString = stringConexao; - Conexao.Open(); - _comando = Conexao.CreateCommand(); - Conectado = true; - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "Conecta()", ex); - } - - } - - /// - /// Realiza a desconexão com o banco de dados. - /// - public void Desconecta() - { - try - { - if (Conectado) - Conexao.Close(); - Conexao.Dispose(); - _comando.Dispose(); - Conectado = false; - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "Desconecta()", ex); - } - } - #endregion - - #region Controle de Transações - /// - /// Inicia uma transação na conexão atual. - /// - public void IniciaTransacao() - { - try - { - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - - if (Conectado) - { - _transacao = Conexao.BeginTransaction(IsolationLevel.ReadCommitted); - _comando.Transaction = _transacao; - } - else - { - throw new Exception("Transação não pode ser iniciada, sem a conexão com o banco, utilize antes DbCon.Conecta()"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "IniciaTransacao()", ex); - } - } - - /// - /// Efetua o commit (confirmando toda transação) no banco de dados, dos comandos executados após o início da transação com o método IniciaTransacao. - /// - public void ConfirmaTransacao() - { - try - { - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - if (_transacao != null) - _transacao.Commit(); - else - { - throw new Exception("Transação não pode ser confimada, provavelmente não foi iniciada."); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ConfirmaTransacao()", ex); - } - } - - /// - /// Efetua o rollback nos comandos realizados após o início da transação com o método IniciaTransacao. - /// - public void CancelaTransacao() - { - try - { - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - if (_transacao != null) - _transacao.Rollback(); - else - { - throw new Exception("Transação não pode ser cancelada, provavelmente não foi iniciada."); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "CancelaTransacao()", ex); - } - } - #endregion - - #region Execução de comandos (INSERT, UPDATE e DELETE) - /// - /// Executa um comando SQL no banco de dados conectado - /// Somente comandos (INSERT, UPDATE, DELETE). - /// - /// Comando SQL a ser executado - /// returna o número de linhas afetadas pelo commando SQL - public int ExecutaComando(string sql) - { - return ExecutaComando(sql, true); - } - - /// - /// Executa um comando SQL no banco de dados conectado - /// Somente comandos (INSERT, UPDATE, DELETE). - /// - /// Comando SQL a ser executado - /// true se deseja converter a query para maiúsculas - /// returna o número de linhas afetadas pelo commando SQL - public int ExecutaComando(string sql, bool upperCase) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - - int linhas; - try - { - _comando.CommandText = (upperCase) ? sql.ToUpper() : sql; - _comando.Parameters.Clear(); - linhas = _comando.ExecuteNonQuery(); - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ExecutaComando()", ex); - } - - return linhas; - } - - /// - /// Pepara o objeto command para executar querys parametrizadas, deve ser utilizada antes do método ExecutaComandoPrep, pois inicializa os parametros. - /// - /// Comando SQL a ser executado - /// /// Lista de camos com nome(chave) e tipo(valor) do campo - public void PreparaComando(string sql, List> camposKeyValuePair) - { - PreparaComando(sql, camposKeyValuePair, true); - } - - /// - /// Pepara o objeto command para executar querys parametrizadas, deve ser utilizada antes do método ExecutaComandoPrep, pois inicializa os parametros. - /// - /// Comando SQL a ser executado - /// Lista de camos com nome(chave) e tipo(valor) do campo - /// se true, converte a query para maiúsculos - public void PreparaComando(string sql, List> camposKeyValuePair, bool upperCase) - { - - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - - _comando.CommandText = (upperCase) ? sql.ToUpper() : sql; - _comando.Parameters.Clear(); - - foreach (var campo in camposKeyValuePair) - { - var parametro = _comando.CreateParameter(); - parametro.ParameterName = campo.Key; - parametro.DbType = campo.Value; - _comando.Parameters.Add(parametro); - } - - _primeiroExecutaComandoPrep = true; - - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "PreparaComando()", ex); - } - } - - /// - /// Executa comandos (INSERT, UPDATE e DELETE) parametrizados. - /// os valores devem estar na seqüência utilizada na chamada ao método PreparaComando(). - /// - /// Valores para serem utilizados na execução do comando parametrizado - public void ExecutaComandoPrep(List valores) - { - ExecutaComandoPrep(valores, true); - } - - /// - /// Executa comandos (INSERT, UPDATE e DELETE) parametrizados. - /// os valores devem estar na seqüência utilizada na chamada ao método PreparaComando(). - /// - /// Valores para serem utilizados na execução do comando parametrizado - /// se true, converte os valores para maiúsculos conforme o caso. - public void ExecutaComandoPrep(List valores, bool upperCase) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - var i = 0; - foreach (var valor in valores) - { - //se o valor estiver nulo, então vamos setar o valor nulo também - _comando.Parameters[i].Value = valor.Equals(null) ? DBNull.Value : valor; - i++; - } - - //se for a primeira vez então devemos executar o Prepare antes de tudo - if (_primeiroExecutaComandoPrep) - { - _primeiroExecutaComandoPrep = false; - - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - - _comando.Prepare(); - } - - _comando.ExecuteNonQuery(); - - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ExecutaComandoPrep()", ex); - } - } - #endregion - - #region Execução de comandos com PREPARE e EXECUÇÃO DIRETAMENTE - /// - /// Executa comandos (INSERT, UPDATE e DELETE) parametrizados. - /// Já executa o prepare e o comando diretamente, não precisa utilizar o método PreparaComando antes, - /// até porque não tem efeito nenhum :) - /// - /// Query a ser executada, com parametro sinalizados com '@' - /// Vetor de chave(nome campo)/valor(dbType do banco) de parametros da query, NÃO inserir '@' antes de cada nome de campo - /// Valores para serem utilizados na execução do comando parametrizado - public void ExecutaComandoParametrizado(string sql, List> camposKeyValuePair, List valores) - { - ExecutaComandoParametrizado(sql, camposKeyValuePair, valores, true); - } - - /// - /// Executa comandos (INSERT, UPDATE e DELETE) parametrizados. - /// Já executa o prepare e o comando diretamente, não precisa utilizar o método PreparaComando antes, - /// até porque não tem efeito nenhum :) - /// - /// Query a ser executada, com parametro sinalizados com '@' - /// Vetor de chave(nome campo)/valor(dbType do banco) de parametros da query, NÃO inserir '@' antes de cada nome de campo - /// Valores para serem utilizados na execução do comando parametrizado - /// indica se coloca o sql em uppercase (maíusculas) - public void ExecutaComandoParametrizado(string sql, List> camposKeyValuePair, List valores, bool upperCase) - { - ExecutaComandoParametrizado(sql, camposKeyValuePair, valores, upperCase, false); - } - - /// - /// Executa comandos (INSERT, UPDATE e DELETE) parametrizados. - /// Já executa o prepare e o comando diretamente, não precisa utilizar o método PreparaComando antes, - /// até porque não tem efeito nenhum :) - /// - /// Query a ser executada, com parametro sinalizados com '@' - /// Vetor de chave(nome campo)/valor(dbType do banco) de parametros da query, NÃO inserir '@' antes de cada nome de campo - /// Valores para serem utilizados na execução do comando parametrizado - /// se true, converte a query para maiúsculas - /// indica se é uma consulta (SELECT) - internal void ExecutaComandoParametrizado(string sql, List> camposKeyValuePair, List valores, bool upperCase, bool isConsultaSql) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - - var fullName = Conexao.GetType().FullName; - var simboloParametro = "@"; - //quandor for Oracle ou PostGreesql, trocamos @ por : - if (fullName.Contains("Oracle") || fullName.Contains("Npgsql")) - { - simboloParametro = ":"; - sql = sql.Replace("@", simboloParametro); - } - - _comando.CommandText = (upperCase) ? sql.ToUpper() : sql; - _comando.Parameters.Clear(); - - var i = 0; - foreach (var campo in camposKeyValuePair) - { - var parametro = _comando.CreateParameter(); - parametro.ParameterName = simboloParametro + campo.Key; - parametro.DbType = campo.Value; - parametro.Value = valores[i].Equals(null) ? DBNull.Value : valores[i]; - - _comando.Parameters.Add(parametro); - i++; - } - - //se for a primeira vez então devemos executar o Prepare antes de tudo - //se o reader estiver aberto é preciso fechar, se não dá erro - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - - _comando.Prepare(); - - if (isConsultaSql) - { - _reader = _comando.ExecuteReader(); - _readerAberto = true; - } - else - _comando.ExecuteNonQuery(); - - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ExecutaComandoParametrizado()", ex); - } - } - #endregion - - #region Execução de Consultas - /// - /// Executa um comando SQL SELECT COUNT no banco de dados conectado. - /// Retorna um valor através do ExecuteScalar, que é mais rápido pois busca apenas a primeira linha/coluna do cursor retornado pelo banco. - /// - /// Query a ser executada - /// Retorna o resultado da contagem - public int ExecutaCount(string sql) - { - return ExecutaCount(sql, true); - } - - /// - /// Executa um comando SQL SELECT COUNT no banco de dados conectado. - /// Retorna um valor através do ExecuteScalar, que é mais rápido pois busca apenas a primeira linha/coluna do cursor retornado pelo banco. - /// - /// Query a ser executada - /// se true, converte a query para maiúsculas - /// Retorna o resultado da contagem - public int ExecutaCount(string sql, bool upperCase) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - _comando.Parameters.Clear(); - _comando.CommandText = sql.ToUpper(); - return (int)_comando.ExecuteScalar(); - } - catch (Exception ex) - { - _readerAberto = false; - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ExecutaCount()", ex); - } - } - - /// - /// Executa um comando SQL SELECT no banco de dados conectado e prepara um cursor (DataReader) para ser lido. - /// Vale lembrar que o datareader somente permite leitura para frente. - /// - /// Query a ser executada - public override void ExecutaConsulta(string sql) - { - ExecutaConsulta(sql, true); - } - - /// - /// Executa um comando SQL SELECT no banco de dados conectado e prepara um cursor (DataReader) para ser lido. - /// Vale lembrar que o datareader somente permite leitura para frente. - /// - /// Query a ser executada - /// se true, converte a query para maiúsculas - public override void ExecutaConsulta(string sql, bool upperCase) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - _comando.Parameters.Clear(); - _comando.CommandText = (upperCase) ? sql.ToUpper() : sql; - _reader = _comando.ExecuteReader(); - _readerAberto = true; - } - catch (Exception ex) - { - _readerAberto = false; - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ExecutaConsulta()", ex); - } - } - - /// - /// Executa uma SELECT com parâmetros indicados por @, exemplo: SELECT * FROM TESTE WHERE DATA = @DATAFILTRO - /// - /// Query a ser executada, com parametro sinalizados com '@' - /// Vetor de chave(nome campo)/valor(dbType do banco) de parametros da query, NÃO inserir '@' antes de cada nome de campo - /// Valores para serem utilizados na execução do comando parametrizado - public void ExecutaConsultaParametrizada(string sql, List> camposKeyValuePair, List valores) - { - ExecutaComandoParametrizado(sql, camposKeyValuePair, valores, true, true); - } - - /// - /// Libera o datareader caso esteja aberto - /// - internal void LiberaDataReader() - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - { - if (!_reader.IsClosed) _reader = null; - _readerAberto = false; - } - } - catch (Exception ex) - { - _readerAberto = false; - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "LiberaDataReader()", ex); - } - } - #endregion - - #region Navegação e busca de dados - /// - /// Move o datareader para o próximo registro - /// - /// Retorna true se obtiver sucesso - public override bool MoveProximo() - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - if (_reader.Read()) - return true; - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "MoveProximo()", ex); - } - - return false; - } - - /// - /// Obtem o valor da coluna indicada, através do número da mesma (inicando em zero). - /// - /// Número da coluna - /// Retorna um objeto com o valor da coluna - public override object Valor(int numero) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - return _reader.GetValue(numero); - else - { - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "Valor()", ex); - } - } - - /// - /// Obtem um vetor de bytes do conteúdo da coluna, através do nome da coluna. - /// - /// Nome da coluna - /// Retorna um vetor de bytes com o valor da coluna - public override byte[] ValorBytes(string nomeColuna) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - { - return ValorBytes(_reader.GetOrdinal(nomeColuna)); - } - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ValorBytes()", ex); - } - } - - - /// - /// Obtem um vetor de bytes do conteúdo da coluna, através do número da mesma (inicando em zero). - /// - /// Número da coluna - /// Retorna um vetor de bytes com o valor da coluna - public override byte[] ValorBytes(int numeroColuna) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - { - return (byte[])_reader.GetValue(numeroColuna); - } - else - { - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ValorBytes()", ex); - } - } - - /// - /// Obtem o valor da coluna indicada, através do nome da coluna. - /// - /// Nome da coluna - /// Retorna um objeto com o valor da coluna - public override object Valor(string nomeColuna) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - return _reader.GetValue(_reader.GetOrdinal(nomeColuna)); - else - { - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "Valor()", ex); - } - } - - /// - /// Obtem o número de campos retornados no DataReader atual. - /// - public int NumeroCampos() - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - return _reader.FieldCount; - else - { - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "NumeroCampos()", ex); - } - } - - /// - /// Obtem o tipo de dados da coluna indicada, no DataReader atual. - /// - /// Nome da coluna a ser verificada - /// System.Type do campo - public Type ObtemTipoCampo(string nomeColuna) - { - if (!Conectado) - { - throw new Exception("Comando não pode ser executado sem antes conectar ao banco, use DbCon.Conecta!"); - } - - try - { - if (_readerAberto) - return _reader.GetFieldType(_reader.GetOrdinal(nomeColuna)); - else - { - throw new Exception("O reader não está aberto, provavelmente não foi executado o método ExecutaConsulta"); - } - } - catch (Exception ex) - { - throw new Exception("Erro: " + ex.Message + Environment.NewLine + "Origem->" + _className + "ObtemTipoCampo()", ex); - } - } - #endregion - - #region Propriedades - /// - /// Propriedade que indica se está conectado no banco de dados. - /// - public bool Conectado { get; private set; } - - /// - /// Propriedade que retorna o objeto DbConnection utilizado no momento. - /// - public DbConnection Conexao { get; } - #endregion - - } -} diff --git a/NetCore/Codout.Framework.NetCore.Tests/Codout.Framework.NetCore.Tests.csproj b/NetCore/Codout.Framework.NetCore.Tests/Codout.Framework.NetCore.Tests.csproj deleted file mode 100644 index 513cc7a..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/Codout.Framework.NetCore.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - netstandard2.0 - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/NetCore/Codout.Framework.NetCore.Tests/DomainTest.cs b/NetCore/Codout.Framework.NetCore.Tests/DomainTest.cs deleted file mode 100644 index b8a40bd..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/DomainTest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Codout.Framework.Domain.DAL; - -namespace Codout.Framework.NetCore.Tests -{ - public abstract class Entity : EntityWithTypedId - { - } - - public class Customer : Entity - { - public string Nome { get; set; } - } - - -} diff --git a/NetCore/Codout.Framework.NetCore.Tests/RepositoryTest.cs b/NetCore/Codout.Framework.NetCore.Tests/RepositoryTest.cs deleted file mode 100644 index da6fa61..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/RepositoryTest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Codout.Framework.DAL.Repository; -using Codout.Framework.EF; -using Codout.Framework.Mongo; -using Codout.Framework.NetCore.Repository.Mongo; - -namespace Codout.Framework.NetCore.Tests -{ - - public interface ICustomerRepository : IRepository - { } - - - public class RepositoryCustomerEF : EFRepository, ICustomerRepository - { - public RepositoryCustomerEF(DbContext context) : base(context) - { - } - } - - public class RepositoryCustomerMongo : MongoRepository, ICustomerRepository - { - public RepositoryCustomerMongo(MongoDbContext context) : base(context) - { - } - } -} diff --git a/NetCore/Codout.Framework.NetCore.Tests/UnitOfWorkTest.cs b/NetCore/Codout.Framework.NetCore.Tests/UnitOfWorkTest.cs deleted file mode 100644 index 26d86cc..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/UnitOfWorkTest.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using Codout.Framework.DAL; -using Codout.Framework.EF; -using Codout.Framework.Mongo; - -namespace Codout.Framework.NetCore.Tests -{ - public interface IUnitOfWorkTest : IUnitOfWork - { - ICustomerRepository Customers { get; } - } - - #region UnitOfWorkTestEF - public class UnitOfWorkTestEF : EFUnitOfWork, IUnitOfWorkTest - { - private ICustomerRepository _customers; - - public UnitOfWorkTestEF(UnitTesteContextEF instance) - : base(instance) - { - } - - public ICustomerRepository Customers => _customers ?? (_customers = new RepositoryCustomerEF(DbContext)); - - public new void Dispose() - { - _customers?.Dispose(); - GC.SuppressFinalize(this); - } - } - #endregion - - #region UnitOfWorkTestMongo - public class UnitOfWorkTestMongo : MongoUnitOfWork, IUnitOfWorkTest - { - - private ICustomerRepository _customers; - - public UnitOfWorkTestMongo(MongoDbContext instance) - : base(instance) - { - } - - public ICustomerRepository Customers => _customers ?? (_customers = new RepositoryCustomerMongo(MongoDbContext)); - - public new void Dispose() - { - _customers?.Dispose(); - GC.SuppressFinalize(this); - } - } - #endregion - -} - diff --git a/NetCore/Codout.Framework.NetCore.Tests/UnitTesteContextEF.cs b/NetCore/Codout.Framework.NetCore.Tests/UnitTesteContextEF.cs deleted file mode 100644 index 8592240..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/UnitTesteContextEF.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace Codout.Framework.NetCore.Tests -{ - public class UnitTesteContextEF : DbContext - { - public UnitTesteContextEF(DbContextOptions options) - : base(options) - { - } - - public DbSet Customers { get; set; } - } -} diff --git a/NetCore/Codout.Framework.NetCore.Tests/UnitTesteORMs.cs b/NetCore/Codout.Framework.NetCore.Tests/UnitTesteORMs.cs deleted file mode 100644 index f96c414..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/UnitTesteORMs.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.IO; -using Codout.Framework.Mongo; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; - -namespace Codout.Framework.NetCore.Tests -{ - [TestClass] - public class UnitTesteORMs - { - [TestMethod] - public void TestaInclusaoELeituraDBSQLEF() - { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json"); - var config = configuration.Build(); - - var builder = new DbContextOptionsBuilder(); - builder.UseSqlServer(config.GetConnectionString("EF")); - - IUnitOfWorkTest unitOfWorkTest = new UnitOfWorkTestEF(new UnitTesteContextEF(builder.Options)); - - var guid = Guid.Parse("97A7513F-7D57-4171-96B5-AE02E2A9C6CE"); - - var cliente = unitOfWorkTest.Customers.Get(guid); - - if (cliente == null) - { - cliente = new Customer { Nome = "José da Silva" }; - cliente.SetId((guid)); - unitOfWorkTest.Customers.Save(cliente); - } - else - { - cliente.Nome = $"José da Silva + {DateTime.Now.ToShortDateString()} + {DateTime.Now.ToLongTimeString()}"; - unitOfWorkTest.Customers.Update(cliente); - } - - unitOfWorkTest.SaveChanges(); - - var obj = unitOfWorkTest.Customers.Get(guid); - - Assert.AreEqual(guid, obj.Id); - } - - [TestMethod] - public void TestaInclusaoELeituraMongoDB() - { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json"); - var config = configuration.Build(); - - var mongoDBOptions = new MongoDBOptions - { - ConnectionString = config.GetConnectionString("MongoDB"), - DatabaseName = config["MongoDBDatabaseName"] - }; - - //**** CHECAR A STRING DE CONEXÃO NO ARQUIVO appsettings.json - IUnitOfWorkTest unitOfWorkTest = new UnitOfWorkTestMongo(new MongoDbContext(mongoDBOptions)); - - Guid? guid = Guid.Parse("97A7513F-7D57-4171-96B5-AE02E2A9C6CE"); - - Customer cliente = unitOfWorkTest.Customers.Get(guid); - if (cliente == null) - { - cliente = new Customer { Nome = "José da Silva" }; - cliente.SetId((guid)); - unitOfWorkTest.Customers.Save(cliente); - } - else - { - cliente.Nome = $"José da Silva + {DateTime.Now.ToShortDateString()} + {DateTime.Now.ToLongTimeString()}"; - unitOfWorkTest.Customers.Update(cliente); - } - unitOfWorkTest.SaveChanges(); - - var obj = unitOfWorkTest.Customers.Get(guid); - - Assert.AreEqual(guid, obj.Id); - } - } -} diff --git a/NetCore/Codout.Framework.NetCore.Tests/appsettings.json b/NetCore/Codout.Framework.NetCore.Tests/appsettings.json deleted file mode 100644 index 59d57c4..0000000 --- a/NetCore/Codout.Framework.NetCore.Tests/appsettings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "MongoDBDatabaseName": "FrameworkTeste", - "ConnectionStrings": { - "MongoDB": "mongodb://localhost:32771", - "EF": "Server=.\\SQL2014;Database=FrameworkTeste;Integrated Security=true;MultipleActiveResultSets=true" - } -} diff --git a/NetFull/Codout.Framework.Commom/Codout.Framework.Commom.csproj b/NetFull/Codout.Framework.Commom/Codout.Framework.Commom.csproj deleted file mode 100644 index 1b29571..0000000 --- a/NetFull/Codout.Framework.Commom/Codout.Framework.Commom.csproj +++ /dev/null @@ -1,54 +0,0 @@ - - - - - Debug - AnyCPU - {503E31F1-E4EF-4FA5-825C-9381ED6930CA} - Library - Properties - Codout.Framework.Commom - Codout.Framework.Commom - v4.6.2 - 512 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.Commom/Extensions/Images.cs b/NetFull/Codout.Framework.Commom/Extensions/Images.cs deleted file mode 100644 index 682431e..0000000 --- a/NetFull/Codout.Framework.Commom/Extensions/Images.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Drawing; -using System.Drawing.Drawing2D; -using System.Drawing.Imaging; - -namespace Codout.Framework.Commom.Extensions -{ - /// - /// Extensões comuns para tipos relacionadas a imagens. - /// - public static class Images - { - #region CreateThumbnail - /// - /// Cria uma miniatura da imagem - /// - /// Imagem original - /// A (máxima) largura. - /// Retorna a imagem - public static Image CreateThumbnail(this Image image, int width) - { - //overload for just maximum width - return image.CreateThumbnail(width, 0); - } - #endregion - - #region CreateThumbnail - /// - /// Cria uma miniatura da imagem - /// - /// Imagem original - /// A (máxima) largura. - /// A (máxima) altura. - /// Retorna a imagem - public static Image CreateThumbnail(this Image image, int maxWidth, int maxHeight) - { - double centerWidth = 0; - double centerHeight = 0; - double width = Convert.ToDouble(image.Width); - double height = Convert.ToDouble(image.Height); - double widthFinal; - double heightFinal; - double newWidth = width; - double newHeight = height; - - double ratioWidth = maxWidth > 0 ? maxWidth / width : 0; - double ratioHeight = maxHeight > 0 ? (maxHeight / height) : 0; - double ratio = Math.Max(ratioWidth, ratioHeight); - - // Se a imagem é maior que o permitido, encolhe ela! - if (ratio < 1) - { - newWidth = Math.Floor(Convert.ToDouble(ratio * width)); - newHeight = Math.Floor(Convert.ToDouble(ratio * height)); - widthFinal = (maxWidth != 0 && newWidth > maxWidth) ? maxWidth : newWidth; - heightFinal = (maxHeight != 0 && newHeight > maxHeight) ? maxHeight : newHeight; - if (maxWidth != 0 && (newWidth - maxWidth) != 0) centerWidth = (maxWidth - newWidth) / 2; - if (maxHeight != 0 && (newHeight - maxHeight) != 0) centerHeight = (maxHeight - newHeight) / 2; - } - else - { - widthFinal = newWidth; - heightFinal = newHeight; - centerWidth = 0; - centerHeight = 0; - } - - //Criando a imagem no tamanho passado pelo parametro com o mesmo formado de pixel da imagem original - Image oImgFinal = new Bitmap(Convert.ToInt32(widthFinal), Convert.ToInt32(heightFinal), image.PixelFormat); - - //Transformando o fundo em um Gráfico - Graphics graphic = Graphics.FromImage(oImgFinal); - // Alteramos algumas propriedades do objeto oGraphic para melhorar a qualidade final da imagem. - graphic.CompositingQuality = CompositingQuality.HighQuality; - graphic.SmoothingMode = SmoothingMode.HighQuality; - graphic.InterpolationMode = InterpolationMode.HighQualityBicubic; - graphic.FillRectangle(Brushes.White, 0, 0, Convert.ToInt32(widthFinal), Convert.ToInt32(heightFinal)); - - //Redimencionando Imagem original - var finalSize = new Size(Convert.ToInt32(newWidth), Convert.ToInt32(newHeight)); - //Criando um fundo para colocar a imagem original - var rectangle = new Rectangle(Convert.ToInt32(centerWidth), Convert.ToInt32(centerHeight), finalSize.Width, finalSize.Height); - graphic.FillRectangle(Brushes.White, rectangle); - - // redimencionando a imagem original e o fundo dentro da imagem nova. - graphic.DrawImage(image, rectangle); - - //E por fim produzimos a saída da página como uma imagem JPG. - image.Dispose(); - - return oImgFinal; - } - #endregion - - #region Crop - /// - /// Método de corte de uma imagem - /// - /// Imagem a ser recortada - /// Altura - /// Largura - /// Coordenada X - /// Coordenada Y - /// - public static Image Crop(this Image image, int width, int height, int x, int y) - { - Image bmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); - - Graphics graphic = Graphics.FromImage(bmp); - graphic.CompositingQuality = CompositingQuality.HighQuality; - graphic.SmoothingMode = SmoothingMode.HighQuality; - graphic.InterpolationMode = InterpolationMode.HighQualityBicubic; - graphic.FillRectangle(Brushes.White, 0, 0, Convert.ToInt32(width), Convert.ToInt32(height)); - - graphic.DrawImage(image, new Rectangle(0, 0, width, height), x, y, width, height, GraphicsUnit.Pixel); - - image.Dispose(); - graphic.Dispose(); - - return bmp; - } - #endregion - - #region ToImage - /// - /// Converte um byte[] em uma Imagem - /// - /// Byte array contendo uma imagem. - /// Uma imagem se a conversão ocorrer com sucesso. - public static Image ToImage(this byte[] image) - { - if (image == null) - return new Bitmap(1, 1); - - return new ImageConverter().ConvertFrom(image) as Image; - } - #endregion - } -} diff --git a/NetFull/Codout.Framework.Commom/Helpers/Http.cs b/NetFull/Codout.Framework.Commom/Helpers/Http.cs deleted file mode 100644 index bd5313c..0000000 --- a/NetFull/Codout.Framework.Commom/Helpers/Http.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Web; - -namespace Codout.Framework.Commom.Helpers -{ - public static class Http - { - /// - /// Obtem o endereço IP de uma requisção Web - /// - /// Retorna o endereço IP - public static string GetClientIp() - { - if (HttpContext.Current == null) - return string.Empty; - - string result; - var ip = HttpContext.Current.Request.ServerVariables["HTTP_X_FORWARDED_FOR"]; - if (!string.IsNullOrEmpty(ip)) - { - string[] ipRange = ip.Split(','); - int le = ipRange.Length - 1; - result = ipRange[0]; - } - else - { - result = HttpContext.Current.Request.ServerVariables["REMOTE_ADDR"]; - } - - return result; - } - } -} diff --git a/NetFull/Codout.Framework.Commom/Helpers/ILocalData.cs b/NetFull/Codout.Framework.Commom/Helpers/ILocalData.cs deleted file mode 100644 index c22075d..0000000 --- a/NetFull/Codout.Framework.Commom/Helpers/ILocalData.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Codout.Framework.Commom.Helpers -{ - public interface ILocalData - { - object this[object key] { get; set; } - int Count { get; } - void Clear(); - } -} \ No newline at end of file diff --git a/NetFull/Codout.Framework.Commom/Helpers/Local.cs b/NetFull/Codout.Framework.Commom/Helpers/Local.cs deleted file mode 100644 index e4cd22f..0000000 --- a/NetFull/Codout.Framework.Commom/Helpers/Local.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections; -using System.Web; - -namespace Codout.Framework.Commom.Helpers -{ - public class Local - { - public static object Obj = new object(); - - public static ILocalData Data { get; } = new LocalData(); - - #region Nested type: LocalData - - private class LocalData : ILocalData - { - [ThreadStatic] - private static Hashtable _localData; - - private static readonly object LocalDataHashtableKey = new object(); - - private static Hashtable LocalHashtable - { - get - { - if (!RunningInWeb) - { - lock (Obj) - { - return _localData ?? (_localData = new Hashtable()); - } - } - - var webHashtable = HttpContext.Current.Items[LocalDataHashtableKey] as Hashtable; - - if (webHashtable == null) - { - lock (Obj) - { - webHashtable = new Hashtable(); - HttpContext.Current.Items[LocalDataHashtableKey] = webHashtable; - } - } - - return webHashtable; - } - } - - private static bool RunningInWeb - { - get { return HttpContext.Current != null; } - } - - #region ILocalData Members - - public object this[object key] - { - get { return LocalHashtable[key]; } - set { LocalHashtable[key] = value; } - } - - public int Count - { - get { return LocalHashtable.Count; } - } - - public void Clear() - { - LocalHashtable.Clear(); - } - - #endregion - } - - #endregion - } -} \ No newline at end of file diff --git a/NetFull/Codout.Framework.Commom/Logger/ExceptionLogger.cs b/NetFull/Codout.Framework.Commom/Logger/ExceptionLogger.cs deleted file mode 100644 index b08916f..0000000 --- a/NetFull/Codout.Framework.Commom/Logger/ExceptionLogger.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using System.Web; - -namespace Codout.Framework.Commom.Logger -{ - public class ExceptionLogger - { - private static ExceptionLogger _instance; - - private ExceptionLogger() - { - - } - - public static ExceptionLogger Get { get { return _instance ?? (_instance = new ExceptionLogger()); } } - - private string GetExceptionTypeStack(Exception e) - { - if (e.InnerException != null) - { - var message = new StringBuilder(); - message.AppendLine(GetExceptionTypeStack(e.InnerException)); - message.AppendLine(" " + e.GetType()); - return (message.ToString()); - } - return " " + e.GetType(); - } - - private string GetExceptionMessageStack(Exception e) - { - if (e.InnerException != null) - { - var message = new StringBuilder(); - message.AppendLine(GetExceptionMessageStack(e.InnerException)); - message.AppendLine(" " + e.Message); - return (message.ToString()); - } - return " " + e.Message; - } - - private string GetExceptionCallStack(Exception e) - { - if (e.InnerException != null) - { - var message = new StringBuilder(); - message.AppendLine(GetExceptionCallStack(e.InnerException)); - message.AppendLine("--- Next Call Stack:"); - message.AppendLine(e.StackTrace); - return (message.ToString()); - } - return e.StackTrace; - } - - private static TimeSpan GetSystemUpTime() - { - var upTime = new PerformanceCounter("System", "System Up Time"); - upTime.NextValue(); - return TimeSpan.FromSeconds(upTime.NextValue()); - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] - private class MEMORYSTATUSEX - { - public uint dwLength; - public uint dwMemoryLoad; - public ulong ullTotalPhys; - public ulong ullAvailPhys; - public ulong ullTotalPageFile; - public ulong ullAvailPageFile; - public ulong ullTotalVirtual; - public ulong ullAvailVirtual; - public ulong ullAvailExtendedVirtual; - - public MEMORYSTATUSEX() - { - this.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)); - } - } - - [return: MarshalAs(UnmanagedType.Bool)] - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - static extern bool GlobalMemoryStatusEx([In, Out] MEMORYSTATUSEX lpBuffer); - - public string GetExceptionDetails(Exception exception) - { - var error = new List(); - - error.Add(DateTime.Now.ToString("dd/MM/yyyy HH:mm:ss")); - - var memStatus = new MEMORYSTATUSEX(); - if (GlobalMemoryStatusEx(memStatus)) - { - error.Add(memStatus.ullTotalPhys / (1024 * 1024) + "Mb"); - error.Add(memStatus.ullAvailPhys / (1024 * 1024) + "Mb"); - } - - if (HttpContext.Current != null) - { - error.Add(HttpContext.Current.Request.UserAgent); - error.Add(HttpContext.Current.Request.Browser.Version); - error.Add(HttpContext.Current.Request.UserLanguages != null ? string.Join(";", HttpContext.Current.Request.UserLanguages) : ""); - error.Add(HttpContext.Current.Request.UserHostAddress); - error.Add(HttpContext.Current.Request.Url.AbsoluteUri); - error.Add( - $"{HttpContext.Current.Request.Browser.ScreenPixelsWidth}x{HttpContext.Current.Request.Browser.ScreenPixelsHeight}"); - - var vars = new List(); - foreach (string key in HttpContext.Current.Request.Form.Keys) - vars.Add($"{key}: {HttpContext.Current.Request.Form[key]}"); - - error.Add(string.Join(", ", vars.ToArray())); - } - - error.Add(GetExceptionTypeStack(exception)); - error.Add(GetExceptionMessageStack(exception)); - error.Add(GetExceptionCallStack(exception)); - var thisProcess = Process.GetCurrentProcess(); - foreach (ProcessModule module in thisProcess.Modules) - error.Add(module.FileName + " " + module.FileVersionInfo.FileVersion); - - return string.Join(";", error.ToArray()); - - } - } -} diff --git a/NetFull/Codout.Framework.Commom/Properties/AssemblyInfo.cs b/NetFull/Codout.Framework.Commom/Properties/AssemblyInfo.cs deleted file mode 100644 index 150d63e..0000000 --- a/NetFull/Codout.Framework.Commom/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Codout.Framework.Commom")] -[assembly: AssemblyDescription("Miscellaneous utility library package (CPF / CNPJ Validation, Range of Dates, Converters and etc) for .Net Full Framework development")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Codout")] -[assembly: AssemblyProduct("Codout.Framework.Commom")] -[assembly: AssemblyCopyright("Copyright Codout © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("503e31f1-e4ef-4fa5-825c-9381ed6930ca")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/NetFull/Codout.Framework.Data/AdoDatabaseUtil.cs b/NetFull/Codout.Framework.Data/AdoDatabaseUtil.cs deleted file mode 100644 index 9a1ed54..0000000 --- a/NetFull/Codout.Framework.Data/AdoDatabaseUtil.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Data.Common; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Codout.Framework.NetFull.Data -{ - /// - /// - /// - public class AdoDatabaseUtil - { - - private NetStandard.Data.DatabaseUtil databaseUtil; - - public AdoDatabaseUtil(string nomeProvider) - { - try - { - var dbCon = DbProviderFactories.GetFactory(nomeProvider); - - databaseUtil = new NetStandard.Data.DatabaseUtil(dbCon.CreateConnection()); - } - catch (Exception ex) - { - throw new Exception(ex.Message, ex); - } - } - - public static DataTable FactoryDatabase(string nomeProvider) - { - try - { - var lista = DbProviderFactories.GetFactoryClasses(); - return lista; - } - catch (Exception ex) - { - throw new Exception(ex.Message, ex); - } - } - - public NetStandard.Data.DatabaseUtil DBUtil { get { return databaseUtil; } } - } -} diff --git a/NetFull/Codout.Framework.Data/Codout.Framework.NetFull.Data.csproj b/NetFull/Codout.Framework.Data/Codout.Framework.NetFull.Data.csproj deleted file mode 100644 index 8e61701..0000000 --- a/NetFull/Codout.Framework.Data/Codout.Framework.NetFull.Data.csproj +++ /dev/null @@ -1,47 +0,0 @@ - - - - - Debug - AnyCPU - {644016F5-0FB0-4DA2-A02C-A113DDD56198} - Library - Properties - Codout.Framework.NetFull.Data - Codout.Framework.NetFull.Data - v4.6.2 - 512 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.Data/Properties/AssemblyInfo.cs b/NetFull/Codout.Framework.Data/Properties/AssemblyInfo.cs deleted file mode 100644 index 437282e..0000000 --- a/NetFull/Codout.Framework.Data/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Codout.Framework.NetFull.Data")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Codout.Framework.NetFull.Data")] -[assembly: AssemblyCopyright("Copyright © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("644016f5-0fb0-4da2-a02c-a113ddd56198")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/NetFull/Codout.Framework.NetFull.Tests/App.config b/NetFull/Codout.Framework.NetFull.Tests/App.config deleted file mode 100644 index 6ed2182..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/App.config +++ /dev/null @@ -1,17 +0,0 @@ - - - - -
- - - - - - - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.NetFull.Tests/Codout.Framework.NetFull.Tests.csproj b/NetFull/Codout.Framework.NetFull.Tests/Codout.Framework.NetFull.Tests.csproj deleted file mode 100644 index a5831a5..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/Codout.Framework.NetFull.Tests.csproj +++ /dev/null @@ -1,96 +0,0 @@ - - - - - Debug - AnyCPU - {5683B394-932E-4E7A-95E1-D77E30A5FFCC} - Library - Properties - Codout.Framework.NetFull.Tests - Codout.Framework.NetFull.Tests - v4.6.2 - 512 - {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - 15.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages - False - UnitTest - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.dll - - - ..\..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.SqlServer.dll - - - ..\..\packages\MSTest.TestFramework.1.2.1\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll - - - ..\..\packages\MSTest.TestFramework.1.2.1\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll - - - - - - - - - - - - - - - - - - - {decef05f-6f17-448e-a850-38a0977db5bc} - Codout.Framework.DAL - - - {c1b0ef6f-49fd-4199-8ba3-7fd7ba3dd4ba} - Codout.Framework.Domain - - - {c2eefa12-a0dd-4ec4-bcfe-00b1dadc5797} - Codout.Framework.Mongo - - - {e13a6faa-e08e-455c-a487-3103496c91b4} - Codout.Framework.EF6 - - - - - - - Este projeto faz referência a pacotes do NuGet que não estão presentes neste computador. Use a Restauração de Pacotes do NuGet para baixá-los. Para obter mais informações, consulte http://go.microsoft.com/fwlink/?LinkID=322105. O arquivo ausente é {0}. - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.NetFull.Tests/DomainTest.cs b/NetFull/Codout.Framework.NetFull.Tests/DomainTest.cs deleted file mode 100644 index 688565f..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/DomainTest.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Codout.Framework.Domain.DAL; -using System; - -namespace Codout.Framework.NetFull.Tests -{ - public abstract class Entity : EntityWithTypedId - { - } - - public class Customer : Entity - { - public string Nome { get; set; } - } -} diff --git a/NetFull/Codout.Framework.NetFull.Tests/Properties/AssemblyInfo.cs b/NetFull/Codout.Framework.NetFull.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index ea4abee..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("Codout.Framework.NetFull.Tests")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Codout.Framework.NetFull.Tests")] -[assembly: AssemblyCopyright("Copyright © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -[assembly: ComVisible(false)] - -[assembly: Guid("5683b394-932e-4e7a-95e1-d77e30a5ffcc")] - -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/NetFull/Codout.Framework.NetFull.Tests/RepositoryTest.cs b/NetFull/Codout.Framework.NetFull.Tests/RepositoryTest.cs deleted file mode 100644 index 26530b5..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/RepositoryTest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Data.Entity; -using Codout.Framework.EF6; - -namespace Codout.Framework.NetFull.Tests -{ - public class RepositoryCustomer : EFRepository - { - public RepositoryCustomer(DbContext context) : base(context) - { - } - } -} diff --git a/NetFull/Codout.Framework.NetFull.Tests/UnitOfWorkTest.cs b/NetFull/Codout.Framework.NetFull.Tests/UnitOfWorkTest.cs deleted file mode 100644 index 9a5a91c..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/UnitOfWorkTest.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Data.Entity; -using Codout.Framework.EF6; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Codout.Framework.NetFull.Tests -{ - public class UnitOfWorkTest : EFUnitOfWork - { - - private RepositoryCustomer _customers; - - public RepositoryCustomer Customers => _customers ?? (_customers = new RepositoryCustomer(DbContext)); - - public new void Dispose() - { - _customers?.Dispose(); - GC.SuppressFinalize(this); - } - } - - public class UnitTesteContext : DbContext - { - // string conexão LocalDB - //data source=(LocalDb)\MSSQLLocalDB;initial catalog=CodoutFameworkTeste;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework - public UnitTesteContext() : base("data source=(LocalDb)\\MSSQLLocalDB;initial catalog=CodoutFameworkTeste;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework") - { - } - - public DbSet Customers { get; set; } - } -} diff --git a/NetFull/Codout.Framework.NetFull.Tests/UnitTestEF_Full.cs b/NetFull/Codout.Framework.NetFull.Tests/UnitTestEF_Full.cs deleted file mode 100644 index a69c819..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/UnitTestEF_Full.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; - -namespace Codout.Framework.NetFull.Tests -{ - [TestClass] - public class UnitTestEF_Full - { - [TestMethod] - public void TestaInclusaoELeituraDBSQLEF_Full() - { - //**** CHECAR A STRING DE CONEXÃO NO ARQUIVO UnitOfWorkTest.cs - UnitOfWorkTest unitOfWorkTest = new UnitOfWorkTest(); - - Guid? guid = Guid.Parse("97A7513F-7D57-4171-96B5-AE02E2A9C6CE"); - - Customer cliente = unitOfWorkTest.Customers.Get(guid); - if (cliente == null) - { - cliente = new Customer { Nome = "José da Silva" }; - cliente.SetId((guid)); - unitOfWorkTest.Customers.Save(cliente); - } - else - { - cliente.Nome = $"José da Silva + {DateTime.Now.ToShortDateString()} + {DateTime.Now.ToLongTimeString()}"; - unitOfWorkTest.Customers.Update(cliente); - } - unitOfWorkTest.SaveChanges(); - - var obj = unitOfWorkTest.Customers.Get(guid); - - Assert.AreEqual(guid, obj.Id); - } - } -} diff --git a/NetFull/Codout.Framework.NetFull.Tests/packages.config b/NetFull/Codout.Framework.NetFull.Tests/packages.config deleted file mode 100644 index 82b80e8..0000000 --- a/NetFull/Codout.Framework.NetFull.Tests/packages.config +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.Repository.EF6/App.config b/NetFull/Codout.Framework.Repository.EF6/App.config deleted file mode 100644 index 13ecece..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/App.config +++ /dev/null @@ -1,17 +0,0 @@ - - - - -
- - - - - - - - - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.Repository.EF6/Codout.Framework.EF6.csproj b/NetFull/Codout.Framework.Repository.EF6/Codout.Framework.EF6.csproj deleted file mode 100644 index d4dbb23..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/Codout.Framework.EF6.csproj +++ /dev/null @@ -1,65 +0,0 @@ - - - - - Debug - AnyCPU - {E13A6FAA-E08E-455C-A487-3103496C91B4} - Library - Properties - Codout.Framework.EF6 - Codout.Framework.EF6 - v4.6.2 - 512 - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - - ..\..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.dll - - - ..\..\packages\EntityFramework.6.2.0\lib\net45\EntityFramework.SqlServer.dll - - - - - - - - - - - - - - - - - - - - - - - {decef05f-6f17-448e-a850-38a0977db5bc} - Codout.Framework.DAL - - - - \ No newline at end of file diff --git a/NetFull/Codout.Framework.Repository.EF6/EFRepository.cs b/NetFull/Codout.Framework.Repository.EF6/EFRepository.cs deleted file mode 100644 index 5f3493d..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/EFRepository.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System; -using System.Data.Entity; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using Codout.Framework.DAL.Entity; -using Codout.Framework.DAL.Repository; - -namespace Codout.Framework.EF6 -{ - - /// - /// Repositório genérico de dados para EntityFramework Full (6.1.3) - /// - /// Classe que define o tipo do repositório - public class EFRepository : IRepository - where T : class, IEntity - { - protected DbContext Context = null; - - public EFRepository(DbContext context) - { - Context = context; - } - - protected DbSet DbSet => Context.Set(); - - /// - /// Retorna todos os objetos do repositório (pode ser lento) - /// - /// Lista de objetos - public IQueryable All() - { - return DbSet.AsQueryable(); - } - - /// - /// Retorna uma lista de objetos do repositório de acordo com o filtro apresentado - /// - /// Lista de objetos - /// - public IQueryable Find(Expression> predicate) - { - return DbSet.Where(predicate).AsQueryable(); - } - - /// - /// Retorna uma lista de objetos do repositório de acordo com o filtro e com opção de paginação - /// - /// Filtro de bojetos - /// Retorna o todal de objetos - /// Indica o índice da paginação - /// Tamanho da página - /// Lista de objetos - public IQueryable Find(Expression> filter, out int total, int index = 0, int size = 50 ) - { - var skipCount = index * size; - - var resetSet = filter != null - ? DbSet.Where(filter).AsQueryable() - : DbSet.AsQueryable(); - - resetSet = skipCount == 0 - ? resetSet.Take(size) - : resetSet.Skip(skipCount).Take(size); - - total = resetSet.Count(); - - return resetSet.AsQueryable(); - } - - /// - /// Retorna um objeto do repositório de acordo com o filtro (não usar para ID nullabe) - /// - /// Filtro - /// objeto - public T Get(Expression> predicate) - { - return DbSet.Find(predicate); - } - - /// - /// Retorna um objeto de acordo com a Key - /// - /// Key do objeto - /// objeto - public T Get(object key) - { - return DbSet.Find(new[] { key }); - } - - /// - /// Efetua a carga do objeto conforme a key - /// - /// Key do objeto - /// Objeto - public T Load(object keys) - { - return Get(keys); - } - - /// - /// Delete o objeto indicado do repositório de dados - /// - /// Objeto a ser deletado - public void Delete(T entity) - { - DbSet.Remove(entity); - } - - /// - /// Deletra uma lista de objetos confrome o filtro - /// - /// Filtro de objetos a serem deletados - public void Delete(Expression> predicate) - { - DbSet.RemoveRange(DbSet.Where(predicate)); - } - - /// - /// Salva o objeto no repositório - /// - /// Objeto a ser salvo - /// Retorna o mesmo objeto, para o caso de retornar algum Id gerado - public T Save(T entity) - { - if (entity == null) - { - throw new ArgumentNullException("entity"); - } - return DbSet.Add(entity); - } - - /// - /// Salva ou atualiza o objeto em questão (USAR SOMENTE SE O ID NÃO FOI SETADO) - /// - /// Objeto a ser salvo/atualizado - /// Retorna o mesmo objeto, para o caso de retornar algum Id gerado - public T SaveOrUpdate(T entity) - { - if (entity.IsTransient()) - DbSet.Add(entity); - else - Context.Entry(entity).State = EntityState.Modified; - - return entity; - } - - /// - /// Atualiza o objeto no repositório - /// - /// Objeto a ser atulizado - public void Update(T entity) - { - Context.Entry(entity).State = EntityState.Modified; - } - - /// - /// Efetua o Merge do objeto no repositório - /// - /// Objeto a ser mesclado - /// Retorna o mesmo objeto, para o caso de retornar algum Id gerado - public T Merge(T entity) - { - Context.Entry(entity).State = EntityState.Modified; - return entity; - } - - private bool _disposed; - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - // Libera os componentes - } - } - _disposed = true; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public Task> AllAsync() - { - throw new NotImplementedException(); - } - - public Task> FindAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task> FindAsync(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public Task GetAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task GetAsync(object key) - { - throw new NotImplementedException(); - } - - public Task LoadAsync(object key) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task SaveAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task SaveOrUpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public Task MergeAsync(T entity) - { - throw new NotImplementedException(); - } - } -} diff --git a/NetFull/Codout.Framework.Repository.EF6/EFUnitOfWork.cs b/NetFull/Codout.Framework.Repository.EF6/EFUnitOfWork.cs deleted file mode 100644 index 1d2a7af..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/EFUnitOfWork.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.Entity; -using Codout.Framework.DAL; -using Codout.Framework.DAL.Entity; -using Codout.Framework.DAL.Repository; - -namespace Codout.Framework.EF6 -{ - /// - /// Unit of Work para repositório genérico com EntityFramework Full (6.1.3) - /// - public abstract class EFUnitOfWork : IUnitOfWork where T : DbContext, new() - { - private readonly IDictionary _repositories = new Dictionary(); - - protected EFUnitOfWork() - { - DbContext = new T(); - } - - private bool _disposed; - - /// - /// Conexto do EntityFrameworkCore - /// - public DbContext DbContext { get; } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - DbContext.Dispose(); - } - } - _disposed = true; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Efetua o SaveChanges do contexto (sessão) em questão - /// - public void SaveChanges() - { - DbContext.SaveChanges(); - } - - /// - /// Repositório Genérico que será controlado - /// - /// Tipo do objeto - /// Repositório concreto - public IRepository Repository() where TEntity : class, IEntity - { - if (!_repositories.ContainsKey(typeof(TEntity))) - _repositories.Add(typeof(TEntity), new EFRepository(DbContext)); - return _repositories[typeof(TEntity)] as IRepository; - } - } -} diff --git a/NetFull/Codout.Framework.Repository.EF6/Properties/AssemblyInfo.cs b/NetFull/Codout.Framework.Repository.EF6/Properties/AssemblyInfo.cs deleted file mode 100644 index 1460504..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Codout.Framework.EF6")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Codout.Framework.EF6")] -[assembly: AssemblyCopyright("Copyright © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("e13a6faa-e08e-455c-a487-3103496c91b4")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/NetFull/Codout.Framework.Repository.EF6/packages.config b/NetFull/Codout.Framework.Repository.EF6/packages.config deleted file mode 100644 index 02a4d9b..0000000 --- a/NetFull/Codout.Framework.Repository.EF6/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/README.md b/README.md index 38403f6..3f6e27e 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@

- - Build Status + + Build Status NuGet Version - NuGet Version + NuGet Downloads License @@ -31,37 +31,55 @@ ## 🎯 Visão Geral -O **Codout.Framework** é um *starter kit* de Clean Architecture para aplicações .NET, projetado para fornecer: +O **Codout.Framework** é um conjunto de pacotes NuGet para aplicações .NET em +Clean Architecture, projetado para fornecer: -* **Modularidade**: Selecione apenas os módulos que você precisa (EF Core, MongoDB, NHibernate, Multi-Tenancy, etc.). -* **Escalabilidade**: Arquitetura preparada para crescer junto com seu produto. -* **Manutenibilidade**: Código limpo, separação de responsabilidades e padrões de projeto consolidados. +* **Modularidade**: instale apenas os pacotes que você precisa (EF Core, MongoDB, NHibernate, Multi-Tenancy, Storage, Mailer, Security, etc.). +* **Escalabilidade**: abstrações de repositório/Unit of Work independentes de ORM. +* **Manutenibilidade**: separação de responsabilidades e padrões consolidados (DDD, Repository, UoW). -Use-o como base para APIs RESTful, microsserviços, backends de aplicações corporativas e mais. +Use-o como base para APIs RESTful, microsserviços e backends corporativos. --- -## 📦 Estrutura de Módulos - -| Projeto | Responsabilidade | -| --------------------- | ------------------------------------------------------------------------------ | -| **Domain** | Entidades, objetos de valor, interfaces de repositório e regras de negócio. | -| **Common** | Helpers, extensões, logging, validações e abstrações de uso geral. | -| **DAL** | Interfaces de Unit of Work e repositórios gerais. | -| **EF** | Implementação de repositórios com Entity Framework Core. | -| **Mongo** | Implementação de Unit of Work e repositórios para MongoDB. | -| **NH** | Repositórios baseados em NHibernate. | -| **Api** | Projeto ASP.NET Core com controllers, middleware, autenticação e configuração. | -| **Api.Dto** | Data Transfer Objects para requests e responses da API. | -| **Api.Client** | Cliente HTTP tipado para consumo de APIs (interna/externa). | -| **Kendo.DynamicLinq** | Extensões para consultas dinâmicas LINQ com componentes Kendo UI. | -| **Multitenancy** | Suporte para aplicações multi-tenant, resolução de conexão por cliente. | -| **Mailer** | Abstrações e implementações de envio de e-mail (AWS SES, SendGrid). | -| **Zenvia** | Integração com Zenvia para envio de SMS e notificações. | -| **DP** | Data Processors genéricos para pipelines de transformação de dados. | -| **Shared** | Código compartilhado entre .NET Core e .NET Framework Full. | -| **UML** | Diagramas de pacotes e classes para referência arquitetural. | -| **Tests** | Projetos de teste unitário e de integração para cada módulo. | +## 📦 Pacotes + +### Núcleo + +| Pacote | Responsabilidade | +| ------ | ---------------- | +| `Codout.Framework.Domain` | Entidades DDD, value objects, identidade client-generated, auditoria. | +| `Codout.Framework.Data` | Abstrações de `IRepository` / `IUnitOfWork` independentes de ORM. | +| `Codout.Framework.Common` | Extensões, helpers, validações (CPF/CNPJ, e-mail), criptografia utilitária. | + +### Persistência (escolha uma implementação) + +| Pacote | Responsabilidade | +| ------ | ---------------- | +| `Codout.Framework.EF` | Repositórios e Unit of Work com Entity Framework Core. | +| `Codout.Framework.Mongo` | Repositórios e Unit of Work para MongoDB. | +| `Codout.Framework.NH` | Repositórios e Unit of Work com NHibernate/FluentNHibernate. | + +### Camada de API + +| Pacote | Responsabilidade | +| ------ | ---------------- | +| `Codout.Framework.Api` | Base de controllers, middleware e configuração ASP.NET Core. | +| `Codout.Framework.Api.Dto` | DTOs de request/response. | +| `Codout.Framework.Api.Client` | Cliente HTTP tipado para consumo de APIs. | +| `Codout.Framework.Application` | Serviços de aplicação (CRUD genérico sobre repositórios). | +| `Codout.DynamicLinq` | Consultas dinâmicas LINQ (filtro/ordenação/paginação/agregação). | + +### Transversais + +| Pacote | Responsabilidade | +| ------ | ---------------- | +| `Codout.Multitenancy` | Resolução de tenant por request, cache e pipeline per-tenant. (`Softprime.Multitenancy` é o build netstandard2.0 de compatibilidade.) | +| `Codout.Mailer` (+ `.AWS`, `.SendGrid`, `.Razor`) | Abstração de envio de e-mail, dispatchers SES/SendGrid e templates Razor. | +| `Codout.Framework.Storage` (+ `.Azure`) | Abstração de storage de arquivos e implementação Azure Blob. | +| `Codout.Security.Core` (+ `.Argon2`, `.Bcrypt`, `.Scrypt`) | Hash de senhas com upgrade incremental de algoritmo/parâmetros. | +| `Codout.Image.Extensions` | Extração/manipulação de regiões de imagens. | +| `Codout.Framework.Mcp` | Servidor MCP com o conhecimento do framework para agentes de IA. | --- @@ -69,68 +87,72 @@ Use-o como base para APIs RESTful, microsserviços, backends de aplicações cor ### Pré-requisitos -* .NET SDK 9.x ou superior +* .NET SDK 10.x * IDE de sua preferência (Visual Studio, VS Code, Rider) -* (Opcional) Docker para MongoDB ou outros serviços auxiliares -### Passos +### Build e testes do repositório -1. **Clone o repositório** +```bash +git clone https://github.com/Codout/Codout.Framework.git +cd Codout.Framework +dotnet build Codout.Framework.sln --configuration Release +dotnet test Codout.Framework.sln --configuration Release +``` - ```bash - git clone https://github.com/Codout/Codout.Framework.git - cd Codout.Framework - ``` +Os projetos de teste vivem em `tests/` e rodam na solution. -2. **Compile e execute testes** +### Instalação via NuGet - ```bash - dotnet build Codout.Framework.sln --configuration Release - dotnet test tests/**/*.Test.csproj - ``` +```bash +dotnet add package Codout.Framework.Domain +dotnet add package Codout.Framework.EF # ou .Mongo / .NH +``` + +--- -3. **Adicione ao seu projeto via NuGet** +## 🧭 Qual pacote eu instalo? - ```powershell - Install-Package Codout.Framework.Common - Install-Package Codout.Framework.EF - Install-Package Codout.Framework.Api - ``` +| Cenário | Pacotes | +| ------- | ------- | +| API REST com EF Core | `Codout.Framework.Domain` + `Codout.Framework.EF` + `Codout.Framework.Api` | +| Backend com MongoDB | `Codout.Framework.Domain` + `Codout.Framework.Mongo` | +| Sistema legado com NHibernate | `Codout.Framework.Domain` + `Codout.Framework.NH` | +| SaaS multi-tenant | adicione `Codout.Multitenancy` | +| Envio de e-mail transacional | `Codout.Mailer` + um dispatcher (`.SendGrid` ou `.AWS`) + `Codout.Mailer.Razor` para templates | +| Upload/armazenamento de arquivos | `Codout.Framework.Storage` + `Codout.Framework.Storage.Azure` | +| Hash de senhas | `Codout.Security.Core` + um provider (`.Argon2`, `.Bcrypt` ou `.Scrypt`) | +| Grids com filtro/paginação dinâmicos | `Codout.DynamicLinq` | --- ## 🏗️ Exemplo de Uso ```csharp -// Startup.cs (ASP.NET Core) -public void ConfigureServices(IServiceCollection services) +// Entidade de domínio com Id gerado no cliente +public class Cliente : ClientGeneratedEntity { - // Configura Domain e Common - services.AddCodoutDomain(); - services.AddCodoutCommon(Configuration); - - // Opte pela implementação de dados: EF, Mongo ou NH - services.AddCodoutEf(options => options.UseSqlServer(connectionString)); - // ou - // services.AddCodoutMongo(options => options.ConnectionString = mongoUri); + public string Nome { get; set; } = string.Empty; +} - // API - services.AddCodoutApi(); +// Repositório + Unit of Work (implementação EF Core) +public class ClienteService(IRepository repository, IUnitOfWork uow) +{ + public async Task CadastrarAsync(string nome, CancellationToken ct) + { + await repository.SaveAsync(new Cliente { Nome = nome }, ct); + await uow.CommitAsync(ct); + } } ``` ---- +Cada pacote tem um README próprio com exemplos específicos — veja a página do +pacote no NuGet.org ou a pasta correspondente neste repositório. -## 🎨 Roadmap +--- -* [x] Domain & Common Essentials -* [x] EF Core & Migrations -* [x] MongoDB e replicação automática -* [x] NHibernate + FluentMigrator -* [ ] Suporte a gRPC e mensageria (RabbitMQ, Kafka) -* [ ] Dashboard de monitoramento e métricas +## 🗺️ Roadmap -Contribuições e sugestões são muito bem-vindas! +O plano de evolução de qualidade do repositório está em [ROADMAP.md](ROADMAP.md). --- @@ -152,5 +174,5 @@ Este projeto está licenciado sob a [MIT License](LICENSE). ---

- Desenvolvido com ❤️ por **Codout** + Desenvolvido com ❤️ por Codout

diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e6792c7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,205 @@ +# ROADMAP — Plano de qualidade "nota 10" + +Plano de implementação para elevar o Codout.Framework da avaliação atual +(**6,5/10**, jun/2026) até **10/10**. **Status 2026-06-12: Fases 1-6 executadas; +Fases 7-8 quase completas — pendências marcadas abaixo.** As fases estão em ordem de execução +recomendada: cada uma cria a base da seguinte. Os ganhos de nota são +estimativas para acompanhamento, não ciência exata. + +## Princípios + +- **Não quebrar consumidores**: pacotes já publicados no NuGet.org seguem + SemVer. Mudança que altere API pública exige bump major e aviso no + CHANGELOG. +- **Cada pacote anda no próprio ritmo**: bumps de versão apenas nos pacotes + efetivamente tocados (ver CLAUDE.md). +- **CI primeiro**: nenhuma fase é considerada concluída sem o `dotnet.yml` + verde validando o resultado. + +--- + +## Fase 1 — Quick wins de CI e higiene (esforço: pequeno · ganho: +0,25) + +Correções de baixo risco que destravam o resto. + +- [x] `dotnet.yml`: atualizar `dotnet-version` de `9.0.x` para `10.0.x` + (o core targeta `net10.0` via `Directory.Build.props`). +- [x] Adicionar `Codout.Framework.Api.Dto` e `Softprime.Multitenancy` à + `Codout.Framework.sln` (estão no `release-packages.json` mas fora da + solution — hoje o CI de PR não os builda). +- [x] Remover `appveyor.yml` (referencia Visual Studio 2012; o pipeline real + é GitHub Actions). +- [x] Corrigir badge de build do README (aponta para `build.yml`/`dotnet.yml` + de forma inconsistente). + +**Critério de aceite**: `dotnet build` da solution verde no CI com SDK 10. + +## Fase 2 — Testes na solution (esforço: pequeno · ganho: +0,25) + +Hoje os 4 projetos de teste existem fora da solution; o `dotnet test +Codout.Framework.sln` do `release.yml` roda zero testes. + +- [x] Criar pasta `tests/` na raiz. +- [x] Migrar `NetCore/Codout.Framework.EF.Tests` para + `tests/Codout.Framework.EF.Tests` e atualizar `TEST_PROJECT` no + `core-release.yml` (linha 39). +- [x] Adicionar os projetos de teste ativos (EF.Tests e, se viável por + caminho, Mcp.Tests) à `Codout.Framework.sln`. +- [x] Remover do escopo os testes legados (`NetFull/*.Tests`, + `NetCore/Codout.Framework.NetCore.Tests`) — dependem de infraestrutura + externa (appsettings com conexões reais) e serão substituídos na Fase 4. + +**Critério de aceite**: `dotnet test Codout.Framework.sln` executa testes de +verdade (> 0) no CI de PR e no gate do `release.yml`. + +## Fase 3 — Limpeza do legado (esforço: médio · ganho: +1,0) + +Remover ~2.000 linhas de código morto que distorcem a leitura do repo e +impedem políticas globais de qualidade (Fase 5). + +- [x] **Decisão prévia (usuário)**: deletar ou arquivar em branch + `archive/legacy` antes de remover. Recomendação: branch de arquivo + + remoção do master. +- [x] Remover `NetFull/` (EF6, .NET Framework — EOL). +- [x] Remover `NetCore/` (netcoreapp2.x — EOL; testes já migrados na Fase 2). +- [x] Remover `src/NetCore/` (Cosmos e DocumentDB em `netcoreapp2.0` com SDK + `Microsoft.Azure.DocumentDB.Core` descontinuado e anti-padrão + `AllAsync().Result` em `CosmosRepository.cs`). Se houver demanda real + por Cosmos, recriar do zero como `Codout.Framework.Cosmos` com SDK + `Microsoft.Azure.Cosmos` — fora do escopo deste plano. +- [x] Remover `Codout.Framework.DP` (quebrado: referencia + `Codout.Framework.DAL` inexistente; `IsPackable=false`). +- [x] Remover/renomear `Shared/Codout.Framework.Shared.Commom` (typo + "Commom") e avaliar se `Shared/` ainda tem consumidor. +- [x] Atualizar CLAUDE.md (seção "Pacotes excluídos do release automatizado") + e CHANGELOG (`### Repository`) refletindo as remoções. + +**Critério de aceite**: nenhum csproj com target EOL no repo; busca por +"Commom" retorna vazio. + +## Fase 4 — Cobertura de testes (esforço: grande · ganho: +1,5) + +O maior gap: 19 de 23 pacotes publicáveis sem teste algum. Priorizar por +risco × facilidade, em `tests/.Tests`: + +| Onda | Pacotes | Abordagem | +|------|---------|-----------| +| 1 | Data, Domain, Common, DynamicLinq, Security.* | Unit puro (sem I/O) — maior retorno imediato | +| 2 | EF (ampliar), Mongo, NH | EF: SQLite in-memory; Mongo: Testcontainers ou EphemeralMongo; NH: SQLite | +| 3 | Mailer, Mailer.Razor, Api.Client, Storage | Fakes/mocks (HttpMessageHandler fake, template rendering, contratos de abstração) | +| 4 | Storage.Azure, Mailer.AWS, Mailer.SendGrid, Api, Application, Multitenancy | Azurite para Azure; demais via mocks dos SDKs | + +- [x] Onda 1 concluída e no CI. +- [x] Onda 2 concluída (Testcontainers exige Docker no runner — `ubuntu-latest` já tem). +- [x] Onda 3 concluída. +- [x] Onda 4 concluída. +- [x] Coleta de cobertura com `coverlet.collector` + publicação de relatório + no CI (artifact ou Codecov + badge no README). +- [ ] Meta de cobertura: ≥ 70 % nos pacotes core, ≥ 50 % nos demais. + *Status 2026-06-12: 47,4% total com gate ratchet de 45% no CI; EF 73%, + Mongo 96%, NH 79%, Multitenancy 99% — pendências principais: Domain + (22%), DynamicLinq (28%), Common (44%). Subir o piso junto com a + cobertura.* + +**Critério de aceite**: todo pacote publicável tem projeto de teste; metas de +cobertura atingidas e visíveis no CI. + +## Fase 5 — Qualidade estática (esforço: médio · ganho: +0,75) + +Aplicar via `Directory.Build.props` (depois da Fase 3, para não gastar +esforço em código morto): + +- [x] `enable` global, corrigindo projetos em ondas + (13/37 já têm). Para projetos ainda não migrados, opt-out temporário + explícito no csproj com comentário `TODO`. +- [x] `true` + + `true` + + `latest-recommended`. +- [x] Expandir `.editorconfig` com convenções C# (indentação, naming, + `dotnet_diagnostic.*` para calibrar severidade dos analyzers). +- [x] Resolver os `[Obsolete]` pendentes em `Codout.Framework.Common` + (Crypto, NumericExtensions, WebPageFetcher): remover no próximo bump + major ou documentar substitutos. +- [x] Atualizar `System.ComponentModel.Annotations` 5.0.0 → versão atual (ou + remover a referência onde o shared framework já cobre). + +**Critério de aceite**: `dotnet build` sem warnings na solution inteira; +nullable ativo em 100 % dos projetos publicáveis. + +## Fase 6 — Empacotamento profissional (esforço: médio · ganho: +0,75) + +Tudo no `Directory.Build.props`, exceto onde indicado: + +- [x] SourceLink: `Microsoft.SourceLink.GitHub`, `PublishRepositoryUrl`, + `EmbedUntrackedSources`, `IncludeSymbols` + `SymbolPackageFormat=snupkg`. +- [x] `ContinuousIntegrationBuild=true` nos workflows de release (build + determinístico). +- [x] `PackageReadmeFile`: criar `README.md` curto por pacote (o que é, + instalação, exemplo mínimo) e empacotar — hoje 19/20 pacotes têm página + vazia no NuGet.org. +- [x] `Description` e `PackageTags` individualizados em cada csproj. +- [x] Publicar os snupkg no fluxo de release (NuGet.org aceita no mesmo + `dotnet nuget push`). +- [ ] Republicar pacotes com bump **patch** (mudança só de empacotamento) — + confirmar com o usuário antes, pois publicar exige tag. + +**Critério de aceite**: página de cada pacote no NuGet.org com README, ícone +e símbolos depuráveis (F12 no código do framework a partir de um consumidor). + +## Fase 7 — Documentação (esforço: médio · ganho: +0,5) + +- [x] Reescrever a tabela de módulos do README: remover "Zenvia", "DAL", + "Kendo.DynamicLinq", "DP", "Shared"; incluir Security.*, Storage, + Storage.Azure, Application, Mcp, Image.Extensions. +- [x] Seção "Qual pacote eu instalo?" com cenários comuns (EF + API, + Mongo, multitenancy, mailer). +- [x] `GenerateDocumentationFile=true` global (via `Directory.Build.props`) + e completar XML docs nos tipos públicos de `Domain`, `Common`, `Data` + e `Api` — com `TreatWarningsAsErrors` da Fase 5, o CS1591 força a + conclusão (calibrar severidade se necessário). +- [x] Atualizar instruções de build/teste do README (caminhos reais da + pasta `tests/`). + +**Critério de aceite**: README sem referência a módulo inexistente; XML docs +completos nos 4 pacotes core. + +## Fase 8 — Hardening de CI/CD (esforço: pequeno · ganho: +0,5) + +- [x] Dependabot (`.github/dependabot.yml`) para NuGet + GitHub Actions, + agrupando updates minor/patch. +- [x] CodeQL (`github/codeql-action`) para análise de segurança em PR. +- [ ] Gate de cobertura no `dotnet.yml` (falhar se cair abaixo da meta). +- [ ] Branch protection em `master`: exigir CI verde + 1 review (config no + GitHub, não no repo). +- [x] Concurrency/cancel-in-progress nos workflows de PR para economizar + runner. + +**Critério de aceite**: PRs só mergeiam com CI + CodeQL verdes; dependências +atualizadas automaticamente via PRs do Dependabot. + +--- + +## Resumo de progressão + +| Fase | Tema | Esforço | Nota acumulada (alvo) | +|------|------|---------|----------------------| +| — | Estado atual | — | 6,5 | +| 1 | Quick wins CI | P | 6,75 | +| 2 | Testes na solution | P | 7,0 | +| 3 | Limpeza do legado | M | 8,0 | +| 4 | Cobertura de testes | G | 9,5 *(maior fase — pode rodar em paralelo às 5–8)* | +| 5 | Qualidade estática | M | +0,75 dentro do teto | +| 6 | Empacotamento | M | +0,75 dentro do teto | +| 7 | Documentação | M | +0,5 dentro do teto | +| 8 | Hardening CI/CD | P | **10,0** | + +## Pontos de decisão (perguntar ao usuário antes de executar) + +1. **Fase 3**: deletar legado direto ou arquivar em branch `archive/legacy` + primeiro? (Recomendação: arquivar e deletar.) +2. **Fase 4, onda 2**: Testcontainers (fiel, requer Docker) vs. bibliotecas + in-memory (mais rápido, menos fiel) para Mongo. +3. **Fase 6**: republicar todos os pacotes com bump patch após SourceLink/ + README, ou deixar para o próximo release natural de cada pacote? +4. **Fase 5**: `[Obsolete]` de `Common` — remover em bump major agora ou + manter até 7.0? diff --git a/Shared.msbuild b/Shared.msbuild deleted file mode 100644 index e95b734..0000000 --- a/Shared.msbuild +++ /dev/null @@ -1,36 +0,0 @@ - - - - $(AssemblyName) - Copyright (c) Codout and contributors (Clovis Coli Jr, Marcelo Paiva). - Clovis Coli Jr, Marcelo Paiva - Codout Solutions http://codout.com - https://github.com/Codout/Codout.Framework/blob/master/LICENSE - https://github.com/Codout/Codout.Framework/raw/master/logo-nuget.png - https://github.com/Codout/Codout.Framework - git - Codout;Framework;ORM;DAL;NHibernate; - - - - 2.0.0 - - - - - $(DefineConstants);NETCORE - portable - - - - false - false - false - false - false - false - false - false - - - \ No newline at end of file diff --git a/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.projitems b/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.projitems deleted file mode 100644 index 73d4258..0000000 --- a/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.projitems +++ /dev/null @@ -1,16 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - a494d36e-af26-408d-92db-797511d60588 - - - Codout.Framework.Shared.Commom - - - - - - - \ No newline at end of file diff --git a/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.shproj b/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.shproj deleted file mode 100644 index 57d9bae..0000000 --- a/Shared/Codout.Framework.Shared.Commom/Codout.Framework.Shared.Commom.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - a494d36e-af26-408d-92db-797511d60588 - 14.0 - - - - - - - - diff --git a/Shared/Codout.Framework.Shared.Commom/Email/Configuration/Configuration.cs b/Shared/Codout.Framework.Shared.Commom/Email/Configuration/Configuration.cs deleted file mode 100644 index c74dee7..0000000 --- a/Shared/Codout.Framework.Shared.Commom/Email/Configuration/Configuration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Configuration; - -namespace Codout.Framework.Commom.Email.Configuration -{ - /// - /// Acessa as configurações de envio de Emails - /// - public static class Email - { - /// - /// Obtem as configurações de envio de Email - /// - /// - public static EmailConfiguration EmailConfiguration - { - get { return (EmailConfiguration)ConfigurationManager.GetSection("softprime/email"); } - } - } -} diff --git a/Shared/Codout.Framework.Shared.Commom/Email/Configuration/EmailConfiguration.cs b/Shared/Codout.Framework.Shared.Commom/Email/Configuration/EmailConfiguration.cs deleted file mode 100644 index f97e9d2..0000000 --- a/Shared/Codout.Framework.Shared.Commom/Email/Configuration/EmailConfiguration.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Configuration; - -namespace Codout.Framework.Commom.Email.Configuration -{ - /// - /// Obtem as configurações do envio de Email - /// - public class EmailConfiguration : ConfigurationSection - { - - /// - /// Define se o formato do Email é HTML ou Texto puro - /// - [ConfigurationProperty("isBodyHtml", IsRequired = true)] - public bool IsBodyHtml - { - get - { - return (bool)this["isBodyHtml"]; - } - set - { - this["isBodyHtml"] = value; - } - } - - /// - /// Login para autenticação da conta de Email - /// - [ConfigurationProperty("userName", IsRequired = true)] - public string UserName - { - get - { - return (string)this["userName"]; - } - set - { - this["userName"] = value; - } - } - - /// - /// Senha para autenticação da conta de Email - /// - [ConfigurationProperty("password", IsRequired = true)] - public string Password - { - get - { - return (string)this["password"]; - } - set - { - this["password"] = value; - } - } - - /// - /// Obtém ou define o nome ou o endereço IP do host usado para transações de SMTP. - /// - [ConfigurationProperty("smtp", IsRequired = true)] - public string Smtp - { - get - { - return (string)this["smtp"]; - } - set - { - this["smtp"] = value; - } - } - - /// - /// btém ou define a porta usada para transações de SMTP. - /// - [ConfigurationProperty("port", IsRequired = true)] - public int Port - { - get - { - return (int)this["port"]; - } - set - { - this["port"] = value; - } - } - - /// - /// Especifique se o SmtpClient usa Secure Sockets Layer (SSL) para criptografar a conexão. - /// - [ConfigurationProperty("enableSsl", IsRequired = true)] - public bool EnableSsl - { - get - { - return (bool)this["enableSsl"]; - } - set - { - this["enableSsl"] = value; - } - } - - /// - /// Especifica como e-mails enviados serão tratados. - /// - [ConfigurationProperty("defaultCredentials", IsRequired = true)] - public bool DefaultCredentials - { - get - { - return (bool)this["defaultCredentials"]; - } - set - { - this["defaultCredentials"] = value; - } - } - - /// - /// Email de origem - /// - [ConfigurationProperty("emailFrom", IsRequired = true)] - public string EmailFrom - { - get - { - return (string)this["emailFrom"]; - } - set - { - this["emailFrom"] = value; - } - } - - /// - /// Nome do Remetente do Email - /// - [ConfigurationProperty("displayName", IsRequired = false)] - public string DisplayName - { - get - { - return (string)this["displayName"]; - } - set - { - this["displayName"] = value; - } - } - } -} diff --git a/Shared/Codout.Framework.Shared.Commom/Email/Email.cs b/Shared/Codout.Framework.Shared.Commom/Email/Email.cs deleted file mode 100644 index 210ed19..0000000 --- a/Shared/Codout.Framework.Shared.Commom/Email/Email.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Net.Mail; -using System.Text; - -namespace Codout.Framework.Commom.Email -{ - /// - /// Classe de envio de Emails - /// - public class Email - { - /// - /// Envia Email - /// Obs.: Verifique se existe configuração da conta de envio de Emails no arquivo .config - /// - /// Email de destino - /// Assunto - /// Mensagem do Email - public void Send(string subject, string message, string emailTo) - { - if (string.IsNullOrWhiteSpace(emailTo)) - throw new Exception("Email must not be empty"); - - var from = string.IsNullOrWhiteSpace(Configuration.Email.EmailConfiguration.DisplayName) - ? new MailAddress(Configuration.Email.EmailConfiguration.EmailFrom) - : new MailAddress(Configuration.Email.EmailConfiguration.EmailFrom, - Configuration.Email.EmailConfiguration.DisplayName); - - var to = new MailAddress(emailTo.Trim()); - - var mailMessage = new MailMessage(from, to) - { - Body = message, - BodyEncoding = Encoding.UTF8, - Subject = subject, - SubjectEncoding = Encoding.UTF8, - IsBodyHtml = Configuration.Email.EmailConfiguration.IsBodyHtml - }; - - var client = new SmtpClient(Configuration.Email.EmailConfiguration.Smtp, Configuration.Email.EmailConfiguration.Port) - { - UseDefaultCredentials = Configuration.Email.EmailConfiguration.DefaultCredentials, - EnableSsl = Configuration.Email.EmailConfiguration.EnableSsl, - Credentials = new System.Net.NetworkCredential(Configuration.Email.EmailConfiguration.UserName, Configuration.Email.EmailConfiguration.Password) - }; - - client.Send(mailMessage); - } - } -} diff --git a/appveyor.yml b/appveyor.yml index 28955e7..0290fdf 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,21 +1,9 @@ -version: '{build}' -image: Visual Studio 2012 - -init: - - git config --global core.autocrlf true - -pull_requests: - do_not_increment_build_number: true - -environment: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - -before_build: - - cmd: dotnet restore - -branches: - only: - - master - +# O CI real deste repositório é GitHub Actions (.github/workflows/dotnet.yml). +# A integração do AppVeyor permaneceu conectada no nível do serviço após a +# remoção do appveyor.yml legado, e o auto-build falhava porque a raiz tem +# várias solutions/projetos. Este arquivo desliga build e testes para que o +# AppVeyor reporte sucesso sem rodar nada. Para remover o check de vez, +# desconecte o AppVeyor nas configurações do GitHub/AppVeyor. +build: off test: off +deploy: off diff --git a/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/Codout.Framework.NetCore.Repository.Cosmos.csproj b/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/Codout.Framework.NetCore.Repository.Cosmos.csproj deleted file mode 100644 index 3d3d1cc..0000000 --- a/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/Codout.Framework.NetCore.Repository.Cosmos.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - 6.2.2 - netcoreapp2.0 - - false - true - Marcelo & Clovis - Codout - Generic Repository Deployment Package for Azure Cosmos DB in .Net Projects - https://github.com/Codout/Codout.Framework - https://github.com/Codout/Codout.Framework - Azure CosmosDB repository generic .net core - - - - - - - - - - - - diff --git a/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/CosmosRepository.cs b/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/CosmosRepository.cs deleted file mode 100644 index 461cd55..0000000 --- a/src/NetCore/Codout.Framework.NetCore.Repository.Cosmos/CosmosRepository.cs +++ /dev/null @@ -1,192 +0,0 @@ -using Codout.Framework.NetStandard.Domain.Entity; -using Codout.Framework.NetStandard.Repository; -using System; -using System.Collections.Generic; -using System.Text; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using Microsoft.Azure.Documents.Client; -using Microsoft.Azure.Documents.Linq; -using System.Reflection; -using Microsoft.Azure.Documents; - -namespace Codout.Framework.NetCore.Repository.Cosmos -{ - /// - /// Repositório genérico de dados para Azure CosmosDB - /// - /// Classe que define o tipo do repositório - public class CosmosRepository : IRepository where T : class, IEntity - { - private static DocumentClient client; - private static string DatabaseId; - private static string CollectionId; - - public CosmosRepository(string endPoint, string key, string databaseId, string collectionId) - { - client = new DocumentClient(new Uri(endPoint), key, new ConnectionPolicy { EnableEndpointDiscovery = false }); - DatabaseId = databaseId; - CollectionId = collectionId; - } - - /// - public IQueryable All() - { - throw new NotImplementedException(); - } - - /// - /// Retorna todos os objetos do repositório (pode ser lento) - /// - /// Lista de objetos - public async Task> AllAsync() - { - IDocumentQuery query = client.CreateDocumentQuery( - UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), - new FeedOptions { MaxItemCount = -1 }) - .AsDocumentQuery(); - - List results = new List(); - while (query.HasMoreResults) - { - results.AddRange(await query.ExecuteNextAsync()); - } - - return results.AsQueryable(); - } - - public void Delete(T entity) - { - } - - public void Delete(Expression> predicate) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(T entity) - { - await client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, GetIdValue(entity).ToString())); - } - - public Task DeleteAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - private bool _disposed; - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - // Libera os componentes - } - } - _disposed = true; - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public IQueryable Find(Expression> predicate) - { - throw new NotImplementedException(); - } - - public IQueryable Find(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public Task> FindAsync(Expression> predicate) - { - return AllAsync().Result.Where(predicate); - } - - public Task> FindAsync(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public T Get(Expression> predicate) - { - throw new NotImplementedException(); - } - - public T Get(object key) - { - throw new NotImplementedException(); - } - - public Task GetAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public Task GetAsync(object key) - { - throw new NotImplementedException(); - } - - public T Load(object key) - { - throw new NotImplementedException(); - } - - public Task LoadAsync(object key) - { - throw new NotImplementedException(); - } - - public T Merge(T entity) - { - throw new NotImplementedException(); - } - - public Task MergeAsync(T entity) - { - throw new NotImplementedException(); - } - - public T Save(T entity) - { - throw new NotImplementedException(); - } - - public Task SaveAsync(T entity) - { - throw new NotImplementedException(); - } - - public T SaveOrUpdate(T entity) - { - throw new NotImplementedException(); - } - - public Task SaveOrUpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public void Update(T entity) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public static object GetIdValue(T entity) - { - return entity.GetType().GetTypeInfo().GetProperty("Id").GetValue(entity); - } - } -} diff --git a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/Codout.Framework.NetCore.Repository.DocumentDB.csproj b/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/Codout.Framework.NetCore.Repository.DocumentDB.csproj deleted file mode 100644 index 9907699..0000000 --- a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/Codout.Framework.NetCore.Repository.DocumentDB.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - 6.2.2 - netcoreapp2.0 - - false - - - - - - - - - - - - diff --git a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBContext.cs b/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBContext.cs deleted file mode 100644 index ed000e9..0000000 --- a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using Microsoft.Azure.Documents.Client; - -namespace Codout.Framework.NetCore.Repository.DocumentDB -{ - public class DocumentDBContext - { - - private static DocumentClient client; - private static string DatabaseId; - private static string CollectionId; - - public DocumentDBContext() - { - var database = new Microsoft.Azure.Documents.Database(); - } - } -} diff --git a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBRepository.cs b/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBRepository.cs deleted file mode 100644 index ed80c34..0000000 --- a/src/NetCore/Codout.Framework.NetCore.Repository.DocumentDB/DocumentDBRepository.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Text; -using System.Threading.Tasks; -using Codout.Framework.NetStandard.Domain.Entity; -using Codout.Framework.NetStandard.Repository; - -namespace Codout.Framework.NetCore.Repository.DocumentDB -{ - /// - public class DocumentDBRepository : IRepository where T : class, IEntity - { - public void Dispose() - { - throw new NotImplementedException(); - } - - /// - public IQueryable All() - { - throw new NotImplementedException(); - } - - /// - public async Task> AllAsync() - { - throw new NotImplementedException(); - } - - public IQueryable Find(Expression> predicate) - { - throw new NotImplementedException(); - } - - public async Task> FindAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public IQueryable Find(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public async Task> FindAsync(Expression> filter, out int total, int index = 0, int size = 50) - { - throw new NotImplementedException(); - } - - public T Get(Expression> predicate) - { - throw new NotImplementedException(); - } - - public async Task GetAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public T Get(object key) - { - throw new NotImplementedException(); - } - - public async Task GetAsync(object key) - { - throw new NotImplementedException(); - } - - public T Load(object key) - { - throw new NotImplementedException(); - } - - public async Task LoadAsync(object key) - { - throw new NotImplementedException(); - } - - public void Delete(T entity) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(T entity) - { - throw new NotImplementedException(); - } - - public void Delete(Expression> predicate) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(Expression> predicate) - { - throw new NotImplementedException(); - } - - public T Save(T entity) - { - throw new NotImplementedException(); - } - - public async Task SaveAsync(T entity) - { - throw new NotImplementedException(); - } - - public T SaveOrUpdate(T entity) - { - throw new NotImplementedException(); - } - - public async Task SaveOrUpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public void Update(T entity) - { - throw new NotImplementedException(); - } - - public async Task UpdateAsync(T entity) - { - throw new NotImplementedException(); - } - - public T Merge(T entity) - { - throw new NotImplementedException(); - } - - public async Task MergeAsync(T entity) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj b/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj index 0044902..cd6a682 100644 --- a/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj +++ b/src/Security/Codout.Security.Argon2/Codout.Security.Argon2.csproj @@ -1,7 +1,12 @@  - 6.3.0 + 6.4.0 + Provider Argon2id para Codout.Security.Core (libsodium): hash e verificação de senhas com parâmetros configuráveis e rehash incremental. + Codout;Security;PasswordHash;Argon2;Argon2id;libsodium + + true + 6.3.0 net10.0 enable enable diff --git a/src/Security/Codout.Security.Argon2/README.md b/src/Security/Codout.Security.Argon2/README.md new file mode 100644 index 0000000..291bdb2 --- /dev/null +++ b/src/Security/Codout.Security.Argon2/README.md @@ -0,0 +1,46 @@ +# Codout.Security.Argon2 + +Implementação de `IPasswordHasher` (de [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core)) usando Argon2id via [Sodium.Core](https://www.nuget.org/packages/Sodium.Core) (libsodium). + +## Instalação + +```bash +dotnet add package Codout.Security.Argon2 +``` + +## Uso + +Registre o algoritmo no DI com o método de extensão `UseArgon2` (de `Argon2Extensions`), encadeado ao builder do Core. A força (`PasswordHasherStrength.Interactive`, `Moderate` ou `Sensitive`) controla CPU e memória usados pelo Argon2: + +```csharp +using Codout.Security.Argon2; +using Codout.Security.Core; + +builder.Services + .UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Sensitive) + .UseArgon2(); +``` + +Para controle fino, configure `Argon2Options`. Quando `OpsLimit` e `MemLimit` (em bytes) são definidos juntos, eles sobrepõem o `Strength`: + +```csharp +builder.Services + .UpgradePasswordSecurity() + .UseArgon2(options => + { + options.OpsLimit = 4; // iterações (custo de CPU) + options.MemLimit = 256 * 1024 * 1024; // 256 MiB de RAM + }); +``` + +A implementação registrada é `ArgonPasswordHash` (scoped). `HashPassword` gera um hash no formato `$argon2id$...`; `VerifyHashedPassword` retorna `PasswordVerificationResult.SuccessRehashNeeded` quando o hash armazenado foi gerado com parâmetros (`m`/`t`) inferiores aos atualmente configurados, permitindo upgrade transparente dos hashes. + +## Pacotes relacionados + +- [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core) — abstrações e builder +- [Codout.Security.Bcrypt](https://www.nuget.org/packages/Codout.Security.Bcrypt) — Bcrypt via BCrypt.Net-Next +- [Codout.Security.Scrypt](https://www.nuget.org/packages/Codout.Security.Scrypt) — Scrypt via libsodium + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj b/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj index 99ac856..405a7eb 100644 --- a/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj +++ b/src/Security/Codout.Security.Bcrypt/Codout.Security.Bcrypt.csproj @@ -1,7 +1,12 @@  - 6.3.0 + 6.4.0 + Provider BCrypt para Codout.Security.Core: hash e verificação de senhas com work factor configurável e rehash incremental. + Codout;Security;PasswordHash;BCrypt + + true + 6.3.0 net10.0 enable enable diff --git a/src/Security/Codout.Security.Bcrypt/README.md b/src/Security/Codout.Security.Bcrypt/README.md new file mode 100644 index 0000000..51689aa --- /dev/null +++ b/src/Security/Codout.Security.Bcrypt/README.md @@ -0,0 +1,45 @@ +# Codout.Security.Bcrypt + +Implementação de `IPasswordHasher` (de [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core)) usando Bcrypt via [BCrypt.Net-Next](https://www.nuget.org/packages/BCrypt.Net-Next). + +## Instalação + +```bash +dotnet add package Codout.Security.Bcrypt +``` + +## Uso + +Registre o algoritmo no DI com o método de extensão `UseBcrypt` (de `BcryptExtensions`), encadeado ao builder do Core: + +```csharp +using Codout.Security.Bcrypt; +using Codout.Security.Core; + +builder.Services + .UpgradePasswordSecurity() + .UseBcrypt(); +``` + +O custo do Bcrypt é controlado por `BcryptOptions` (e não pelo `WithStrength` do builder): `WorkFactor` é o log2 do número de rounds (faixa válida 4–31, padrão `12`) e `SaltRevision` escolhe a revisão do salt (enum `BcryptSaltRevision`: `Revision2`, `Revision2A`, `Revision2B`, `Revision2X`, `Revision2Y`; padrão `Revision2B`): + +```csharp +builder.Services + .UpgradePasswordSecurity() + .UseBcrypt(options => + { + options.WorkFactor = 14; + options.SaltRevision = BcryptSaltRevision.Revision2B; + }); +``` + +A implementação registrada é `BcryptPasswordHash` (scoped). `VerifyHashedPassword` retorna `PasswordVerificationResult.SuccessRehashNeeded` quando o hash armazenado usa `WorkFactor` menor que o configurado, permitindo upgrade transparente dos hashes existentes. + +## Pacotes relacionados + +- [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core) — abstrações e builder +- [Codout.Security.Argon2](https://www.nuget.org/packages/Codout.Security.Argon2) — Argon2id via libsodium +- [Codout.Security.Scrypt](https://www.nuget.org/packages/Codout.Security.Scrypt) — Scrypt via libsodium + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/src/Security/Codout.Security.Core/Codout.Security.Core.csproj b/src/Security/Codout.Security.Core/Codout.Security.Core.csproj index 9f05ce6..91ae11d 100644 --- a/src/Security/Codout.Security.Core/Codout.Security.Core.csproj +++ b/src/Security/Codout.Security.Core/Codout.Security.Core.csproj @@ -1,7 +1,12 @@  - 6.3.0 + 6.4.0 + Abstrações de hash de senha com upgrade incremental de algoritmo/parâmetros (rehash transparente no login). Use com Codout.Security.Argon2, Bcrypt ou Scrypt. + Codout;Security;PasswordHash;Hashing;Rehash + + true + 6.3.0 net10.0 enable enable diff --git a/src/Security/Codout.Security.Core/README.md b/src/Security/Codout.Security.Core/README.md new file mode 100644 index 0000000..1beda2b --- /dev/null +++ b/src/Security/Codout.Security.Core/README.md @@ -0,0 +1,54 @@ +# Codout.Security.Core + +Abstrações para hashing de senhas (`IPasswordHasher`, `IPasswordHashBuilder`) com integração ao container de DI do .NET — o algoritmo concreto (Argon2, Bcrypt ou Scrypt) é plugado por um pacote irmão. + +## Instalação + +```bash +dotnet add package Codout.Security.Core +``` + +Este pacote contém apenas as abstrações. Instale também um dos pacotes de algoritmo (veja "Pacotes relacionados") para registrar uma implementação de `IPasswordHasher`. + +## Uso + +Registre o builder no DI com `PasswordHasherServiceExtensions` e configure a força do hash com `WithStrength` (enum `PasswordHasherStrength`: `Interactive`, `Moderate` ou `Sensitive`). Em seguida, encadeie o método `Use*` do pacote de algoritmo escolhido: + +```csharp +using Codout.Security.Core; +using Codout.Security.Argon2; // ou .Bcrypt / .Scrypt + +builder.Services + .UpgradePasswordSecurity() // ou UseCustomHashPasswordBuilder() + .WithStrength(PasswordHasherStrength.Moderate) + .UseArgon2(); +``` + +Consuma `IPasswordHasher` por injeção de dependência: + +```csharp +using Codout.Security.Core; + +public class AccountService(IPasswordHasher hasher) +{ + public string Register(string password) => hasher.HashPassword(password); + + public bool Login(string hashed, string provided) + { + var result = hasher.VerifyHashedPassword(hashed, provided); + // PasswordVerificationResult: Failed, Success ou SuccessRehashNeeded + return result != PasswordVerificationResult.Failed; + } +} +``` + +Quando `VerifyHashedPassword` retorna `PasswordVerificationResult.SuccessRehashNeeded`, a senha está correta, mas foi gerada com parâmetros mais fracos que os atuais — re-hasheie e persista o novo hash. + +## Pacotes relacionados + +- [Codout.Security.Argon2](https://www.nuget.org/packages/Codout.Security.Argon2) — Argon2id via libsodium +- [Codout.Security.Bcrypt](https://www.nuget.org/packages/Codout.Security.Bcrypt) — Bcrypt via BCrypt.Net-Next +- [Codout.Security.Scrypt](https://www.nuget.org/packages/Codout.Security.Scrypt) — Scrypt via libsodium + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj b/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj index e08b9a4..a1496be 100644 --- a/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj +++ b/src/Security/Codout.Security.Scrypt/Codout.Security.Scrypt.csproj @@ -1,7 +1,12 @@  - 6.3.0 + 6.4.0 + Provider SCrypt para Codout.Security.Core: hash e verificação de senhas com parâmetros configuráveis e rehash incremental. + Codout;Security;PasswordHash;SCrypt + + true + 6.3.0 net10.0 enable enable diff --git a/src/Security/Codout.Security.Scrypt/README.md b/src/Security/Codout.Security.Scrypt/README.md new file mode 100644 index 0000000..07a5a9a --- /dev/null +++ b/src/Security/Codout.Security.Scrypt/README.md @@ -0,0 +1,46 @@ +# Codout.Security.Scrypt + +Implementação de `IPasswordHasher` (de [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core)) usando Scrypt via [Sodium.Core](https://www.nuget.org/packages/Sodium.Core) (libsodium). + +## Instalação + +```bash +dotnet add package Codout.Security.Scrypt +``` + +## Uso + +Registre o algoritmo no DI com o método de extensão `UseScrypt` (de `ScryptExtensions`), encadeado ao builder do Core. A força (`PasswordHasherStrength.Interactive`, `Moderate` ou `Sensitive`) controla CPU e memória usados pelo Scrypt: + +```csharp +using Codout.Security.Core; +using Codout.Security.Scrypt; + +builder.Services + .UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Moderate) + .UseScrypt(); +``` + +Para controle fino, configure `ScryptOptions`. Quando `OpsLimit` e `MemLimit` (em bytes) são definidos juntos, eles sobrepõem o `Strength`: + +```csharp +builder.Services + .UpgradePasswordSecurity() + .UseScrypt(options => + { + options.OpsLimit = 524288; // custo de CPU + options.MemLimit = 128 * 1024 * 1024; // 128 MiB de RAM + }); +``` + +A implementação registrada é `ScryptPasswordHash` (scoped). `HashPassword` gera um hash no formato `$7$...` (escrypt/libsodium); `VerifyHashedPassword` retorna `PasswordVerificationResult.SuccessRehashNeeded` quando o hash armazenado foi gerado com parâmetro `N` inferior ao atualmente configurado, permitindo upgrade transparente dos hashes. + +## Pacotes relacionados + +- [Codout.Security.Core](https://www.nuget.org/packages/Codout.Security.Core) — abstrações e builder +- [Codout.Security.Argon2](https://www.nuget.org/packages/Codout.Security.Argon2) — Argon2id via libsodium +- [Codout.Security.Bcrypt](https://www.nuget.org/packages/Codout.Security.Bcrypt) — Bcrypt via BCrypt.Net-Next + +--- +Parte do [Codout.Framework](https://github.com/Codout/Codout.Framework) — licença MIT. diff --git a/tests/Codout.DynamicLinq.Tests/AggregatesAndGroupTests.cs b/tests/Codout.DynamicLinq.Tests/AggregatesAndGroupTests.cs new file mode 100644 index 0000000..059c0d5 --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/AggregatesAndGroupTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.DynamicLinq.Tests; + +public class AggregatesAndGroupTests +{ + private static object? GetAggregateValue(object aggregates, string field, string function) + { + var fieldObj = aggregates.GetType().GetProperty(field)?.GetValue(aggregates); + fieldObj.Should().NotBeNull($"o objeto de agregados deveria ter a propriedade {field}"); + return fieldObj!.GetType().GetProperty(function)?.GetValue(fieldObj); + } + + private static DataSourceResult RunAggregates(params Aggregator[] aggregators) + { + return TestData.People().ToDataSourceResult(10, 0, [], null!, aggregators, []); + } + + [Fact] + public void Aggregate_Sum_DeveSomarValores() + { + var result = RunAggregates(new Aggregator { Field = "Salary", Aggregate = "sum" }); + + GetAggregateValue(result.Aggregates!, "Salary", "sum").Should().Be(10001.50m); + } + + [Fact] + public void Aggregate_Min_Max_DevemCalcularExtremos() + { + var result = RunAggregates( + new Aggregator { Field = "Age", Aggregate = "min" }, + new Aggregator { Field = "Age", Aggregate = "max" }); + + GetAggregateValue(result.Aggregates!, "Age", "min").Should().Be(25); + GetAggregateValue(result.Aggregates!, "Age", "max").Should().Be(40); + } + + [Fact] + public void Aggregate_Average_DeveCalcularMedia() + { + var result = RunAggregates(new Aggregator { Field = "Salary", Aggregate = "average" }); + + GetAggregateValue(result.Aggregates!, "Salary", "average").Should().Be(2000.30m); + } + + [Fact] + public void Aggregate_Count_DeveContarRegistros() + { + var result = RunAggregates(new Aggregator { Field = "Age", Aggregate = "count" }); + + GetAggregateValue(result.Aggregates!, "Age", "count").Should().Be(5); + } + + [Fact] + public void Aggregate_Count_EmCampoNullable_ContaApenasNaoNulos() + { + var result = RunAggregates(new Aggregator { Field = "Score", Aggregate = "count" }); + + GetAggregateValue(result.Aggregates!, "Score", "count").Should().Be(4); + } + + [Fact] + public void Aggregate_SobreConsultaFiltrada_ConsideraApenasOsFiltrados() + { + var filter = TestData.Single("Category", "eq", "A"); + + var result = TestData.People().ToDataSourceResult(10, 0, [], filter, + [new Aggregator { Field = "Salary", Aggregate = "sum" }], []); + + GetAggregateValue(result.Aggregates!, "Salary", "sum").Should().Be(4001.25m); + } + + [Fact] + public void Aggregate_Vazio_RetornaAggregatesNulo() + { + var result = RunAggregates(); + + result.Aggregates.Should().BeNull(); + } + + [Fact] + public void Aggregate_EmCampoInexistente_LancaExcecao() + { + var act = () => RunAggregates(new Aggregator { Field = "NaoExiste", Aggregate = "sum" }); + + act.Should().Throw(); + } + + [Fact] + public void Group_SemAggregates_LancaAoEnumerarOsGrupos() + { + // BUG?: GroupByMany repassa Group.Aggregates (nulo por padrão) para + // QueryableExtensions.Aggregates, que faz `aggregates.ToArray()` e lança + // ArgumentNullException na enumeração. Todo Group precisa de Aggregates = [] + // para o agrupamento funcionar. + var result = TestData.People() + .ToDataSourceResult(10, 0, [], null!, [], [new Group { Field = "Category", Dir = "asc" }]); + + var act = () => ((IEnumerable)result.Groups!).ToList(); + + act.Should().Throw(); + } + + [Fact] + public void Group_DeveAgruparPorCampo() + { + var result = TestData.People() + .ToDataSourceResult(10, 0, [], null!, [], + [new Group { Field = "Category", Dir = "asc", Aggregates = [] }]); + + result.Data.Should().BeNull("quando há grupos o retorno vai em Groups"); + result.Groups.Should().NotBeNull(); + + var groups = ((IEnumerable)result.Groups!).ToList(); + + groups.Select(g => (string)g.Value!).Should().ContainInOrder("A", "B", "C"); + groups.Select(g => g.Count).Should().ContainInOrder(2, 2, 1); + groups.Should().OnlyContain(g => !g.HasSubgroups); + } + + [Fact] + public void Group_Aninhado_DeveMarcarHasSubgroups() + { + var result = TestData.People().ToDataSourceResult(10, 0, [], null!, [], + [ + new Group { Field = "Category", Dir = "asc", Aggregates = [] }, + new Group { Field = "Address.City", Dir = "asc", Aggregates = [] } + ]); + + var groups = ((IEnumerable)result.Groups!).ToList(); + + groups.Should().OnlyContain(g => g.HasSubgroups); + + var groupA = groups.Single(g => (string)g.Value! == "A"); + var subgroups = ((IEnumerable)groupA.Items!).ToList(); + subgroups.Select(g => (string)g.Value!).Should().BeEquivalentTo("Vitoria", "Curitiba"); + } + + [Fact] + public void Group_ComAggregates_DeveCalcularAgregadoPorGrupo() + { + var result = TestData.People().ToDataSourceResult(10, 0, [], null!, [], + [ + new Group + { + Field = "Category", + Dir = "asc", + Aggregates = [new Aggregator { Field = "Salary", Aggregate = "sum" }] + } + ]); + + var groups = ((IEnumerable)result.Groups!).ToList(); + var groupA = groups.Single(g => (string)g.Value! == "A"); + + GetAggregateValue(groupA.Aggregates!, "Salary", "sum").Should().Be(4001.25m); + } + + [Fact] + public void Group_FieldExposeContagem() + { + var groupResult = new GroupResult { SelectorField = "Category", Count = 3 }; + + groupResult.Field.Should().Be("Category (3)"); + } +} diff --git a/tests/Codout.DynamicLinq.Tests/Codout.DynamicLinq.Tests.csproj b/tests/Codout.DynamicLinq.Tests/Codout.DynamicLinq.Tests.csproj new file mode 100644 index 0000000..dba4594 --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/Codout.DynamicLinq.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.DynamicLinq.Tests/FilterAndSortExpressionTests.cs b/tests/Codout.DynamicLinq.Tests/FilterAndSortExpressionTests.cs new file mode 100644 index 0000000..3a49ede --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/FilterAndSortExpressionTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.DynamicLinq.Tests; + +public class FilterAndSortExpressionTests +{ + [Fact] + public void Sort_ToExpression_DeveConcatenarCampoEDirecao() + { + new Sort { Field = "Name", Dir = "desc" }.ToExpression().Should().Be("Name desc"); + } + + [Fact] + public void Filter_All_DeveAchatarFiltrosAninhados() + { + var leaf1 = new Filter { Field = "A", Operator = "eq", Value = 1 }; + var leaf2 = new Filter { Field = "B", Operator = "eq", Value = 2 }; + var leaf3 = new Filter { Field = "C", Operator = "eq", Value = 3 }; + + var root = new Filter + { + Logic = "and", + Filters = new List + { + leaf1, + new() { Logic = "or", Filters = new List { leaf2, leaf3 } } + } + }; + + root.All().Should().ContainInOrder(leaf1, leaf2, leaf3); + } + + [Fact] + public void Filter_All_DeFiltroSimples_RetornaEleMesmo() + { + var filter = new Filter { Field = "Age", Operator = "gt", Value = 1 }; + + filter.All().Should().ContainSingle().Which.Should().BeSameAs(filter); + } + + [Theory] + [InlineData("eq", "Age = @0")] + [InlineData("neq", "Age != @0")] + [InlineData("lt", "Age < @0")] + [InlineData("lte", "Age <= @0")] + [InlineData("gt", "Age > @0")] + [InlineData("gte", "Age >= @0")] + public void Filter_ToExpression_OperadoresDeComparacao(string op, string expected) + { + var filter = new Filter { Field = "Age", Operator = op, Value = 1 }; + + filter.ToExpression(typeof(Person), filter.All()).Should().Be(expected); + } + + [Theory] + [InlineData("contains", "Name != null && Name.Contains(@0)")] + [InlineData("startswith", "Name != null && Name.StartsWith(@0)")] + [InlineData("endswith", "Name != null && Name.EndsWith(@0)")] + [InlineData("doesnotcontain", "Name != null && !Name.Contains(@0)")] + public void Filter_ToExpression_OperadoresDeString(string op, string expected) + { + var filter = new Filter { Field = "Name", Operator = op, Value = "x" }; + + filter.ToExpression(typeof(Person), filter.All()).Should().Be(expected); + } + + [Theory] + [InlineData("isnull", "Nickname = null")] + [InlineData("isnotnull", "Nickname != null")] + [InlineData("isempty", "Nickname = String.Empty")] + [InlineData("isnotempty", "Nickname != String.Empty")] + [InlineData("isnullorempty", "String.IsNullOrEmpty(Nickname)")] + [InlineData("isnotnullorempty", "!String.IsNullOrEmpty(Nickname)")] + public void Filter_ToExpression_OperadoresDeNuloEVazio(string op, string expected) + { + var filter = new Filter { Field = "Nickname", Operator = op }; + + filter.ToExpression(typeof(Person), filter.All()).Should().Be(expected); + } + + [Fact] + public void Filter_ToExpression_Composto_DeveUsarIndicesDaListaAchatada() + { + var root = new Filter + { + Logic = "or", + Filters = new List + { + new() { Field = "Age", Operator = "gt", Value = 30 }, + new() { Field = "Name", Operator = "eq", Value = "Ana" } + } + }; + + root.ToExpression(typeof(Person), root.All()).Should().Be("(Age > @0 or Name = @1)"); + } + + [Fact] + public void Filter_ToExpression_OperadorDeString_EmCampoNaoString_Lanca() + { + var filter = new Filter { Field = "Age", Operator = "contains", Value = "3" }; + + var act = () => filter.ToExpression(typeof(Person), filter.All()); + + act.Should().Throw().WithMessage("*contains*"); + } + + [Fact] + public void Aggregator_MethodInfo_ParaCampoInexistente_LancaArgumentException() + { + var aggregator = new Aggregator { Field = "NaoExiste", Aggregate = "sum" }; + + var act = () => aggregator.MethodInfo(typeof(Person)); + + act.Should().Throw(); + } + + [Fact] + public void Aggregator_MethodInfo_ComTipoNulo_LancaArgumentNullException() + { + var aggregator = new Aggregator { Field = "Age", Aggregate = "sum" }; + + var act = () => aggregator.MethodInfo(null!); + + act.Should().Throw(); + } + + [Fact] + public void Aggregator_MethodInfo_ParaAgregadoDesconhecido_RetornaNulo() + { + var aggregator = new Aggregator { Field = "Age", Aggregate = "median" }; + + aggregator.MethodInfo(typeof(Person)).Should().BeNull(); + } + + [Fact] + public void DataSourceResult_Padrao_TemValoresNulosETotalZero() + { + var result = new DataSourceResult(); + + result.Data.Should().BeNull(); + result.Groups.Should().BeNull(); + result.Aggregates.Should().BeNull(); + result.Errors.Should().BeNull(); + result.Total.Should().Be(0); + } +} diff --git a/tests/Codout.DynamicLinq.Tests/TestData.cs b/tests/Codout.DynamicLinq.Tests/TestData.cs new file mode 100644 index 0000000..b70b672 --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/TestData.cs @@ -0,0 +1,89 @@ +namespace Codout.DynamicLinq.Tests; + +public class Address +{ + public string City { get; set; } = string.Empty; +} + +public class Person +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public int Age { get; set; } + + public int? Score { get; set; } + + public decimal Salary { get; set; } + + public DateTime BirthDate { get; set; } + + public string Category { get; set; } = string.Empty; + + public Address Address { get; set; } = new(); +} + +public static class TestData +{ + /// + /// Conjunto fixo e determinístico usado pela maioria dos testes. + /// + public static IQueryable People() + { + return new List + { + new() + { + Id = 1, Name = "Ana", Nickname = "Aninha", Age = 30, Score = 10, Salary = 1000.50m, + BirthDate = new DateTime(1994, 5, 10, 12, 0, 0), Category = "A", + Address = new Address { City = "Vitoria" } + }, + new() + { + Id = 2, Name = "Bruno", Nickname = null, Age = 25, Score = null, Salary = 2000.00m, + BirthDate = new DateTime(1999, 1, 20, 12, 0, 0), Category = "B", + Address = new Address { City = "Belo Horizonte" } + }, + new() + { + Id = 3, Name = "Carla", Nickname = "", Age = 40, Score = 30, Salary = 3000.75m, + BirthDate = new DateTime(1984, 8, 1, 12, 0, 0), Category = "A", + Address = new Address { City = "Curitiba" } + }, + new() + { + Id = 4, Name = "Daniel", Nickname = "Dani", Age = 35, Score = 20, Salary = 1500.25m, + BirthDate = new DateTime(1989, 12, 31, 12, 0, 0), Category = "B", + Address = new Address { City = "Vitoria" } + }, + new() + { + Id = 5, Name = "Eduarda", Nickname = "Duda", Age = 28, Score = 50, Salary = 2500.00m, + BirthDate = new DateTime(1996, 3, 15, 12, 0, 0), Category = "C", + Address = new Address { City = "Salvador" } + } + }.AsQueryable(); + } + + public static Filter Single(string field, string @operator, object? value) + { + // O método Filters() de QueryableExtensions só aplica o filtro quando + // Logic != null, então todo filtro simples precisa ser embrulhado. + return new Filter + { + Logic = "and", + Filters = new List + { + new() { Field = field, Operator = @operator, Value = value } + } + }; + } + + public static List DataOf(DataSourceResult result) + { + return ((IEnumerable)result.Data!).ToList(); + } +} diff --git a/tests/Codout.DynamicLinq.Tests/ToDataSourceResultFilterTests.cs b/tests/Codout.DynamicLinq.Tests/ToDataSourceResultFilterTests.cs new file mode 100644 index 0000000..c02e8cc --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/ToDataSourceResultFilterTests.cs @@ -0,0 +1,278 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.DynamicLinq.Tests; + +public class ToDataSourceResultFilterTests +{ + private static readonly Sort[] NoSort = []; + private static readonly Aggregator[] NoAggregates = []; + private static readonly Group[] NoGroups = []; + + private static DataSourceResult Run(Filter? filter, int take = 10, int skip = 0) + { + return TestData.People().ToDataSourceResult(take, skip, NoSort, filter!, NoAggregates, NoGroups); + } + + [Fact] + public void Filtro_Eq_DeveRetornarApenasRegistroIgual() + { + var result = Run(TestData.Single("Name", "eq", "Ana")); + + result.Total.Should().Be(1); + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Ana"); + } + + [Fact] + public void Filtro_Neq_DeveExcluirRegistro() + { + var result = Run(TestData.Single("Name", "neq", "Ana")); + + result.Total.Should().Be(4); + TestData.DataOf(result).Should().NotContain(p => p.Name == "Ana"); + } + + [Theory] + [InlineData("gt", 30, new[] { 3, 4 })] + [InlineData("gte", 30, new[] { 1, 3, 4 })] + [InlineData("lt", 28, new[] { 2 })] + [InlineData("lte", 28, new[] { 2, 5 })] + public void Filtros_DeComparacao_DevemFiltrarPorIdade(string op, int value, int[] expectedIds) + { + var result = Run(TestData.Single("Age", op, value)); + + TestData.DataOf(result).Select(p => p.Id).Should().BeEquivalentTo(expectedIds); + } + + [Fact] + public void Filtro_StartsWith_DeveFiltrarPorPrefixo() + { + var result = Run(TestData.Single("Name", "startswith", "Br")); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Bruno"); + } + + [Fact] + public void Filtro_EndsWith_DeveFiltrarPorSufixo() + { + var result = Run(TestData.Single("Name", "endswith", "la")); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Carla"); + } + + [Fact] + public void Filtro_Contains_DeveFiltrarPorTrecho() + { + var result = Run(TestData.Single("Name", "contains", "an")); + + // "Daniel" contém "an"; a comparação é case-sensitive, então "Ana" não entra. + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Daniel"); + } + + [Fact] + public void Filtro_Contains_EmCampoNulo_NaoDeveLancarExcecao() + { + // O predicado gerado inclui o null-check: "Nickname != null && Nickname.Contains(@0)" + var result = Run(TestData.Single("Nickname", "contains", "D")); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Daniel", "Eduarda"); + } + + [Fact] + public void Filtro_DoesNotContain_DeveExcluirTrechoENulos() + { + var result = Run(TestData.Single("Nickname", "doesnotcontain", "D")); + + // Bruno (Nickname null) também fica de fora, pois o predicado exige Nickname != null. + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Carla"); + } + + [Fact] + public void Filtro_IsNull_DeveRetornarApenasNulos() + { + var result = Run(TestData.Single("Nickname", "isnull", null)); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Bruno"); + } + + [Fact] + public void Filtro_IsNotNull_DeveExcluirNulos() + { + var result = Run(TestData.Single("Nickname", "isnotnull", null)); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Carla", "Daniel", "Eduarda"); + } + + [Fact] + public void Filtro_IsEmpty_DeveRetornarApenasStringVazia() + { + var result = Run(TestData.Single("Nickname", "isempty", null)); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Carla"); + } + + [Fact] + public void Filtro_IsNotEmpty_DeveExcluirStringVazia() + { + var result = Run(TestData.Single("Nickname", "isnotempty", null)); + + // null != "" é verdadeiro, então Bruno (null) também é retornado. + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Bruno", "Daniel", "Eduarda"); + } + + [Fact] + public void Filtro_IsNullOrEmpty_DeveRetornarNulosEVazios() + { + var result = Run(TestData.Single("Nickname", "isnullorempty", null)); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Bruno", "Carla"); + } + + [Fact] + public void Filtro_IsNotNullOrEmpty_DeveRetornarPreenchidos() + { + var result = Run(TestData.Single("Nickname", "isnotnullorempty", null)); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Daniel", "Eduarda"); + } + + [Fact] + public void Filtro_Composto_ComLogicAnd_DeveAplicarTodasAsCondicoes() + { + var filter = new Filter + { + Logic = "and", + Filters = new List + { + new() { Field = "Category", Operator = "eq", Value = "A" }, + new() { Field = "Age", Operator = "gt", Value = 30 } + } + }; + + var result = Run(filter); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Carla"); + } + + [Fact] + public void Filtro_Composto_ComLogicOr_DeveAplicarQualquerCondicao() + { + var filter = new Filter + { + Logic = "or", + Filters = new List + { + new() { Field = "Name", Operator = "eq", Value = "Ana" }, + new() { Field = "Name", Operator = "eq", Value = "Bruno" } + } + }; + + var result = Run(filter); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Bruno"); + } + + [Fact] + public void Filtro_Aninhado_DeveCombinarLogicasDiferentes() + { + // Category == "B" and (Age < 30 or Age > 34) + var filter = new Filter + { + Logic = "and", + Filters = new List + { + new() { Field = "Category", Operator = "eq", Value = "B" }, + new() + { + Logic = "or", + Filters = new List + { + new() { Field = "Age", Operator = "lt", Value = 30 }, + new() { Field = "Age", Operator = "gt", Value = 34 } + } + } + } + }; + + var result = Run(filter); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Bruno", "Daniel"); + } + + [Fact] + public void Filtro_EmPropriedadeAninhada_DeveSerSuportado() + { + var result = Run(TestData.Single("Address.City", "eq", "Vitoria")); + + TestData.DataOf(result).Select(p => p.Id).Should().BeEquivalentTo(new[] { 1, 4 }); + } + + [Fact] + public void Filtro_Decimal_DeveConverterValorParaDecimal() + { + // PreliminaryWork converte o valor (double) para decimal antes do Where. + var result = Run(TestData.Single("Salary", "gt", 2000.0)); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Carla", "Eduarda"); + } + + [Fact] + public void Filtro_DateTime_Eq_MeiaNoite_DeveCasarComODiaInteiro() + { + // PreliminaryWork expande "eq meia-noite" para o intervalo [00:00:00, 23:59:59] + // do dia (em horário local; o ambiente de teste roda em UTC). + var result = Run(TestData.Single("BirthDate", "eq", "1994-05-10T00:00:00")); + + TestData.DataOf(result).Should().ContainSingle(p => p.Name == "Ana"); + } + + [Fact] + public void Filtro_DateTime_Gt_DeveCompararDatas() + { + var result = Run(TestData.Single("BirthDate", "gt", new DateTime(1994, 1, 1))); + + TestData.DataOf(result).Select(p => p.Name).Should().BeEquivalentTo("Ana", "Bruno", "Eduarda"); + } + + [Fact] + public void Filtro_SemLogic_EhIgnoradoSilenciosamente() + { + // BUG?: um Filter simples (sem Logic) não é aplicado — Filters() exige Logic != null, + // então o chamador recebe todos os registros sem nenhum aviso. + var filter = new Filter { Field = "Name", Operator = "eq", Value = "Ana" }; + + var result = Run(filter); + + result.Total.Should().Be(5); + result.Errors.Should().BeNull(); + } + + [Fact] + public void Filtro_ComOperadorDesconhecido_DeveRegistrarErroERetornarTudo() + { + var result = Run(TestData.Single("Name", "like", "Ana")); + + result.Total.Should().Be(5); + result.Errors.Should().NotBeNull(); + ((IEnumerable)result.Errors!).Should().NotBeEmpty(); + } + + [Fact] + public void Filtro_DeString_EmCampoNumerico_DeveRegistrarErroERetornarTudo() + { + // ToExpression lança NotSupportedException, que é capturada e vai para Errors. + var result = Run(TestData.Single("Age", "contains", "3")); + + result.Total.Should().Be(5); + result.Errors.Should().NotBeNull(); + } + + [Fact] + public void Filtro_Nulo_NaoFiltraNada() + { + var result = Run(null); + + result.Total.Should().Be(5); + TestData.DataOf(result).Should().HaveCount(5); + } +} diff --git a/tests/Codout.DynamicLinq.Tests/ToDataSourceResultSortPageTests.cs b/tests/Codout.DynamicLinq.Tests/ToDataSourceResultSortPageTests.cs new file mode 100644 index 0000000..9cb8145 --- /dev/null +++ b/tests/Codout.DynamicLinq.Tests/ToDataSourceResultSortPageTests.cs @@ -0,0 +1,140 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.DynamicLinq.Tests; + +public class ToDataSourceResultSortPageTests +{ + private static readonly Aggregator[] NoAggregates = []; + private static readonly Group[] NoGroups = []; + + private static DataSourceResult Run(int take, int skip, IEnumerable sort) + { + return TestData.People().ToDataSourceResult(take, skip, sort, null!, NoAggregates, NoGroups); + } + + [Fact] + public void Sort_Asc_DeveOrdenarCrescente() + { + var result = Run(10, 0, [new Sort { Field = "Age", Dir = "asc" }]); + + TestData.DataOf(result).Select(p => p.Age).Should().BeInAscendingOrder(); + } + + [Fact] + public void Sort_Desc_DeveOrdenarDecrescente() + { + var result = Run(10, 0, [new Sort { Field = "Age", Dir = "desc" }]); + + TestData.DataOf(result).Select(p => p.Age).Should().BeInDescendingOrder(); + } + + [Fact] + public void Sort_Multiplo_DeveOrdenarPorCampoSecundario() + { + var result = Run(10, 0, + [ + new Sort { Field = "Category", Dir = "asc" }, + new Sort { Field = "Age", Dir = "desc" } + ]); + + TestData.DataOf(result).Select(p => p.Id).Should().ContainInOrder(3, 1, 4, 2, 5); + } + + [Fact] + public void Sort_PorPropriedadeAninhada_DeveSerSuportado() + { + var result = Run(10, 0, [new Sort { Field = "Address.City", Dir = "asc" }]); + + TestData.DataOf(result).First().Address.City.Should().Be("Belo Horizonte"); + } + + [Fact] + public void Paginacao_DeveAplicarSkipETake() + { + var result = Run(2, 1, [new Sort { Field = "Id", Dir = "asc" }]); + + result.Total.Should().Be(5, "o total reflete a contagem antes da paginação"); + TestData.DataOf(result).Select(p => p.Id).Should().ContainInOrder(2, 3); + } + + [Fact] + public void Paginacao_ComTakeZero_RetornaTodosOsRegistros() + { + var result = Run(0, 0, [new Sort { Field = "Id", Dir = "asc" }]); + + TestData.DataOf(result).Should().HaveCount(5); + } + + [Fact] + public void Paginacao_AlemDoFim_RetornaListaVazia() + { + var result = Run(10, 10, [new Sort { Field = "Id", Dir = "asc" }]); + + result.Total.Should().Be(5); + TestData.DataOf(result).Should().BeEmpty(); + } + + [Fact] + public void Total_DeveRefletirAContagemFiltrada() + { + var filter = TestData.Single("Category", "eq", "A"); + + var result = TestData.People().ToDataSourceResult(1, 0, [], filter, NoAggregates, NoGroups); + + result.Total.Should().Be(2); + TestData.DataOf(result).Should().HaveCount(1); + } + + [Fact] + public void SortNulo_SemGrupos_LancaArgumentNullException() + { + // BUG?: Sort() faz `sort as Sort[] ?? sort.ToArray()` — com sort nulo (e sem grupos + // que o substituam), ToArray() lança ArgumentNullException em vez de ignorar a ordenação. + var act = () => TestData.People().ToDataSourceResult(10, 0, null!, null!, NoAggregates, NoGroups); + + act.Should().Throw(); + } + + [Fact] + public void OverloadSemAggregatesEGroups_LancaArgumentNullException() + { + // BUG?: o overload de 4 parâmetros repassa aggregates/group nulos, e + // Aggregates() faz `aggregates as Aggregator[] ?? aggregates.ToArray()`, + // que lança ArgumentNullException. Ou seja, o overload "simples" documentado + // é inutilizável como está. + var act = () => TestData.People() + .ToDataSourceResult(10, 0, [new Sort { Field = "Id", Dir = "asc" }], null!); + + act.Should().Throw(); + } + + [Fact] + public void OverloadComDataSourceRequestPadrao_LancaArgumentNullException() + { + // BUG?: mesma causa raiz do overload de 4 parâmetros — um DataSourceRequest + // recém-criado (Sort/Aggregate/Group nulos) derruba a chamada. + var act = () => TestData.People().ToDataSourceResult(new DataSourceRequest { Take = 10 }); + + act.Should().Throw(); + } + + [Fact] + public void OverloadComDataSourceRequestPreenchido_DeveFuncionar() + { + var request = new DataSourceRequest + { + Take = 2, + Skip = 0, + Sort = [new Sort { Field = "Age", Dir = "desc" }], + Filter = null, + Aggregate = [], + Group = [] + }; + + var result = TestData.People().ToDataSourceResult(request); + + result.Total.Should().Be(5); + TestData.DataOf(result).Select(p => p.Age).Should().ContainInOrder(40, 35); + } +} diff --git a/tests/Codout.Framework.Api.Client.Tests/Codout.Framework.Api.Client.Tests.csproj b/tests/Codout.Framework.Api.Client.Tests/Codout.Framework.Api.Client.Tests.csproj new file mode 100644 index 0000000..eb6ce08 --- /dev/null +++ b/tests/Codout.Framework.Api.Client.Tests/Codout.Framework.Api.Client.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Api.Client.Tests/FakeHttpMessageHandler.cs b/tests/Codout.Framework.Api.Client.Tests/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..4c54278 --- /dev/null +++ b/tests/Codout.Framework.Api.Client.Tests/FakeHttpMessageHandler.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Reflection; +using System.Text; + +namespace Codout.Framework.Api.Client.Tests; + +/// +/// Handler fake que captura as requisições e devolve respostas pré-configuradas, +/// sem tocar a rede. +/// +public class FakeHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public List Requests { get; } = []; + + public record CapturedRequest(HttpMethod Method, Uri? Uri, string? Body, string? ContentType); + + public FakeHttpMessageHandler Enqueue(HttpStatusCode statusCode, string? body = null, + string contentType = "application/json") + { + _responses.Enqueue(_ => + { + var response = new HttpResponseMessage(statusCode); + if (body != null) + response.Content = new StringContent(body, Encoding.UTF8, contentType); + return response; + }); + return this; + } + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + string? body = null; + if (request.Content != null) + body = await request.Content.ReadAsStringAsync(cancellationToken); + + Requests.Add(new CapturedRequest( + request.Method, + request.RequestUri, + body, + request.Content?.Headers.ContentType?.MediaType)); + + if (_responses.Count == 0) + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + return _responses.Dequeue()(request); + } + + public HttpClient CreateClient(string baseUrl = "https://api.example.test/") + { + return new HttpClient(this) { BaseAddress = new Uri(baseUrl) }; + } + + /// + /// Substitui via reflection o handler interno do HttpClient criado por + /// ApiClientBase (que não permite injeção de handler). + /// + public void InjectInto(HttpClient client) + { + var field = typeof(HttpMessageInvoker) + .GetField("_handler", BindingFlags.Instance | BindingFlags.NonPublic); + + if (field == null) + throw new InvalidOperationException( + "Campo privado _handler não encontrado em HttpMessageInvoker — ajuste o teste para esta versão do runtime."); + + field.SetValue(client, this); + } +} diff --git a/tests/Codout.Framework.Api.Client.Tests/HttpClientExtensionsTests.cs b/tests/Codout.Framework.Api.Client.Tests/HttpClientExtensionsTests.cs new file mode 100644 index 0000000..db45ed6 --- /dev/null +++ b/tests/Codout.Framework.Api.Client.Tests/HttpClientExtensionsTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using Codout.Framework.Api.Client.Extensions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Api.Client.Tests; + +public class PersonDto : EntityDto +{ + public string? Name { get; set; } + + public string? Notes { get; set; } +} + +public class HttpClientExtensionsTests +{ + private readonly FakeHttpMessageHandler _handler = new(); + + [Fact] + public async Task GetAsync_DeveUsarVerboGetEDeserializarResposta() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":7,\"name\":\"Ana\"}"); + var client = _handler.CreateClient(); + + var dto = await client.GetAsync("people/7"); + + _handler.Requests.Should().ContainSingle(); + _handler.Requests[0].Method.Should().Be(HttpMethod.Get); + _handler.Requests[0].Uri!.ToString().Should().Be("https://api.example.test/people/7"); + dto.Id.Should().Be(7); + dto.Name.Should().Be("Ana"); + } + + [Fact] + public async Task PostAsync_DeveSerializarCorpoEmCamelCase() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":1,\"name\":\"Ana\"}"); + var client = _handler.CreateClient(); + + var result = await client.PostAsync("people", new PersonDto { Id = 1, Name = "Ana" }); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Post); + request.ContentType.Should().Be("application/json"); + request.Body.Should().Be("{\"name\":\"Ana\",\"id\":1}"); + result.Id.Should().Be(1); + } + + [Fact] + public async Task PostAsync_NaoSerializaPropriedadesNulas() + { + _handler.Enqueue(HttpStatusCode.OK, "{}"); + var client = _handler.CreateClient(); + + await client.PostAsync("people", new PersonDto { Id = 2, Name = "Bia", Notes = null }); + + _handler.Requests.Single().Body.Should().NotContain("notes"); + } + + [Fact] + public async Task PostAsync_SemRetorno_DeveEnviarCorpo() + { + _handler.Enqueue(HttpStatusCode.NoContent); + var client = _handler.CreateClient(); + + await client.PostAsync("people", new PersonDto { Id = 3 }); + + _handler.Requests.Single().Body.Should().Contain("\"id\":3"); + } + + [Fact] + public async Task PostAsync_SemCorpo_DeveEnviarPostVazio() + { + _handler.Enqueue(HttpStatusCode.OK); + var client = _handler.CreateClient(); + + await client.PostAsync("people/refresh"); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Post); + request.Body.Should().BeNull(); + } + + [Fact] + public async Task PutAsync_DeveUsarVerboPutERetornarObjeto() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":4,\"name\":\"Caio\"}"); + var client = _handler.CreateClient(); + + var dto = await client.PutAsync("people/4", new PersonDto { Id = 4, Name = "Caio" }); + + _handler.Requests.Single().Method.Should().Be(HttpMethod.Put); + dto.Name.Should().Be("Caio"); + } + + [Fact] + public async Task DeleteAsync_DeveUsarVerboDelete() + { + // Chamada estática proposital: `client.DeleteAsync(...)` resolveria para o + // método de INSTÂNCIA HttpClient.DeleteAsync, não para a extensão. + _handler.Enqueue(HttpStatusCode.OK); + var client = _handler.CreateClient(); + + await HttpClientExtensions.DeleteAsync(client, "people/5"); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Delete); + request.Uri!.ToString().Should().EndWith("people/5"); + } + + [Fact] + public async Task RespostaDeDeserializacaoCaseInsensitive_DeveFuncionar() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"ID\":9,\"NAME\":\"Eva\"}"); + var client = _handler.CreateClient(); + + var dto = await client.GetAsync("people/9"); + + dto.Id.Should().Be(9); + dto.Name.Should().Be("Eva"); + } + + [Fact] + public async Task RespostaDeErro_ComCorpoDeApiException_LancaExceptionGenericaComOCorpo() + { + // BUG?: GetExceptionAsync lança ApiClientException DENTRO do próprio try e o + // catch genérico a engole, relançando `new Exception(corpo)`. Ou seja, o + // ApiClientException (tipado) nunca chega ao chamador — sempre vem Exception + // com o corpo bruto da resposta. + var errorBody = "{\"statusCode\":500,\"message\":\"falhou\",\"errors\":[]}"; + _handler.Enqueue(HttpStatusCode.InternalServerError, errorBody); + var client = _handler.CreateClient(); + + var act = () => client.GetAsync("people/1"); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Should().NotBeOfType(); + ex.Which.Message.Should().Be(errorBody); + } + + [Fact] + public async Task RespostaDeErro_ComCorpoNaoJson_LancaExceptionComOCorpo() + { + _handler.Enqueue(HttpStatusCode.BadRequest, "erro interno em texto puro", "text/plain"); + var client = _handler.CreateClient(); + + var act = () => HttpClientExtensions.DeleteAsync(client, "people/1"); + + (await act.Should().ThrowAsync()) + .WithMessage("erro interno em texto puro"); + } + + [Fact] + public async Task RespostaDeErro_SemResposta_PropagaExcecaoOriginal() + { + using var handler = new ThrowingHandler(); + var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.test/") }; + + var act = () => client.GetAsync("people/1"); + + await act.Should().ThrowAsync(); + } + + private sealed class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + throw new HttpRequestException("rede indisponível"); + } + } +} + +public class JsonExtensionsTests +{ + [Fact] + public async Task ReadAsAsync_DeserializaCamelCaseECaseInsensitive() + { + var content = new StringContent("{\"id\":3,\"Name\":\"Zoe\"}"); + + var dto = await content.ReadAsAsync(); + + dto.Id.Should().Be(3); + dto.Name.Should().Be("Zoe"); + } + + [Fact] + public async Task PostAsJsonAsync_DefineContentTypeApplicationJson() + { + var handler = new FakeHttpMessageHandler().Enqueue(System.Net.HttpStatusCode.OK); + var client = handler.CreateClient(); + + await client.PostAsJsonAsync("any", new PersonDto { Id = 1 }); + + handler.Requests.Single().ContentType.Should().Be("application/json"); + } + + [Fact] + public async Task PutAsJsonAsync_SerializaEmCamelCase() + { + var handler = new FakeHttpMessageHandler().Enqueue(System.Net.HttpStatusCode.OK); + var client = handler.CreateClient(); + + await client.PutAsJsonAsync("any", new PersonDto { Id = 2, Name = "Lia" }); + + handler.Requests.Single().Body.Should().Be("{\"name\":\"Lia\",\"id\":2}"); + } +} diff --git a/tests/Codout.Framework.Api.Client.Tests/RestApiClientTests.cs b/tests/Codout.Framework.Api.Client.Tests/RestApiClientTests.cs new file mode 100644 index 0000000..af5983e --- /dev/null +++ b/tests/Codout.Framework.Api.Client.Tests/RestApiClientTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Text.Json; +using Codout.DynamicLinq; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Api.Client.Tests; + +public class ApiClientBaseTests +{ + [Fact] + public void Construtor_DeveConfigurarBaseAddressAcceptETimeout() + { + var client = new RestApiClient("people", "https://api.example.test/"); + + client.UriService.Should().Be("people"); + client.Client.BaseAddress.Should().Be(new Uri("https://api.example.test/")); + client.Client.DefaultRequestHeaders.Accept.Should() + .ContainSingle(h => h.MediaType == "application/json"); + client.Client.Timeout.Should().Be(TimeSpan.FromMinutes(1)); + client.Client.DefaultRequestHeaders.Contains("ApiKey").Should().BeFalse(); + } + + [Fact] + public void Construtor_ComApiKey_DeveAdicionarHeader() + { + var client = new RestApiClient("people", "https://api.example.test/", "chave-secreta"); + + client.Client.DefaultRequestHeaders.GetValues("ApiKey").Should().ContainSingle("chave-secreta"); + } + + [Fact] + public void Construtor_ComBaseUrlInvalida_LancaUriFormatException() + { + var act = () => new RestApiClient("people", "not a url"); + + act.Should().Throw(); + } +} + +public class RestApiClientTests +{ + private readonly FakeHttpMessageHandler _handler = new(); + + private RestApiClient CreateClient() + { + var client = new RestApiClient("people", "https://api.example.test/"); + // ApiClientBase cria o HttpClient internamente, sem ponto de injeção; + // substituímos o handler via reflection para evitar rede. + _handler.InjectInto(client.Client); + return client; + } + + [Fact] + public async Task GetAsync_DeveChamarGetNaRotaComId() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":7,\"name\":\"Ana\"}"); + var client = CreateClient(); + + var dto = await client.GetAsync(7); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Get); + request.Uri!.ToString().Should().Be("https://api.example.test/people/7"); + dto.Id.Should().Be(7); + } + + [Fact] + public async Task PostAsync_DeveChamarPostNaRotaBase() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":1,\"name\":\"Ana\"}"); + var client = CreateClient(); + + var dto = await client.PostAsync(new PersonDto { Id = 1, Name = "Ana" }); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Post); + request.Uri!.ToString().Should().Be("https://api.example.test/people"); + request.Body.Should().Contain("\"name\":\"Ana\""); + dto.Name.Should().Be("Ana"); + } + + [Fact] + public async Task PutAsync_DeveChamarPutNaRotaComIdDoObjeto() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"id\":4,\"name\":\"Caio\"}"); + var client = CreateClient(); + + await client.PutAsync(new PersonDto { Id = 4, Name = "Caio" }); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Put); + request.Uri!.ToString().Should().Be("https://api.example.test/people/4"); + } + + [Fact] + public async Task DeleteAsync_DeveChamarDeleteNaRotaComId() + { + _handler.Enqueue(HttpStatusCode.OK); + var client = CreateClient(); + + await client.DeleteAsync(5); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Delete); + request.Uri!.ToString().Should().Be("https://api.example.test/people/5"); + } + + [Fact] + public async Task GetAllAsync_DevePostarORequestNaRotaGetAll() + { + _handler.Enqueue(HttpStatusCode.OK, "{\"total\":2,\"data\":[]}"); + var client = CreateClient(); + + var result = await client.GetAllAsync(new DataSourceRequest { Take = 10, Skip = 0 }); + + var request = _handler.Requests.Single(); + request.Method.Should().Be(HttpMethod.Post); + request.Uri!.ToString().Should().Be("https://api.example.test/people/get-all"); + request.Body.Should().Contain("\"take\":10"); + result.Total.Should().Be(2); + } + + [Fact] + public async Task DeleteAsync_ComRespostaDeErro_NaoLancaExcecao() + { + // BUG?: dentro de RestApiClient, `Client.DeleteAsync(...)` resolve para o método + // de INSTÂNCIA HttpClient.DeleteAsync (métodos de instância têm precedência sobre + // extensões), então a extensão com EnsureSuccessStatusCode nunca é chamada e + // qualquer erro HTTP do DELETE é silenciosamente ignorado. + _handler.Enqueue(HttpStatusCode.InternalServerError, "falhou", "text/plain"); + var client = CreateClient(); + + var act = () => client.DeleteAsync(5); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetAsync_ComRespostaDeErro_LancaExcecaoComCorpo() + { + _handler.Enqueue(HttpStatusCode.NotFound, "nao encontrado", "text/plain"); + var client = CreateClient(); + + var act = () => client.GetAsync(99); + + (await act.Should().ThrowAsync()).WithMessage("nao encontrado"); + } +} + +public class ApiExceptionTests +{ + [Fact] + public void ApiException_NaoEhUmaException() + { + // Observação de caracterização: ApiException é um POCO (não herda de + // System.Exception), portanto não pode ser lançada diretamente. + typeof(Exception).IsAssignableFrom(typeof(ApiException)).Should().BeFalse(); + } + + [Fact] + public void ApiException_DeveGuardarStatusMensagemEErros() + { + var error = new ApiErrorMessage(400, "campo obrigatório"); + var exception = new ApiException(400, "requisição inválida", error); + + exception.StatusCode.Should().Be(400); + exception.Message.Should().Be("requisição inválida"); + exception.Errors.Should().ContainSingle().Which.Should().BeSameAs(error); + } + + [Fact] + public void ApiException_ToString_SerializaEmJsonCamelCase() + { + var exception = new ApiException(500, "falhou", new ApiErrorMessage(500, "detalhe")); + + var json = exception.ToString(); + using var document = JsonDocument.Parse(json); + + document.RootElement.GetProperty("statusCode").GetInt32().Should().Be(500); + document.RootElement.GetProperty("message").GetString().Should().Be("falhou"); + document.RootElement.GetProperty("errors")[0].GetProperty("errorMessage").GetString() + .Should().Be("detalhe"); + } + + [Fact] + public void ApiErrorMessage_GuardaCodigoEMensagem() + { + var error = new ApiErrorMessage(404, "não achei"); + + error.ErrorCode.Should().Be(404); + error.ErrorMessage.Should().Be("não achei"); + } + + [Fact] + public void ApiClientException_ExpoeApiExceptionEMensagem() + { + var apiException = new ApiException(500, "erro interno"); + + var clientException = new ApiClientException(apiException); + + clientException.Should().BeAssignableTo(); + clientException.Message.Should().Be("erro interno"); + clientException.ApiException.Should().BeSameAs(apiException); + } + + [Fact] + public void EntityDtoBase_ExpoeIdTipado() + { + var dto = new PersonDto { Id = 10 }; + + ((IEntityDto)dto).Id.Should().Be(10); + } +} diff --git a/tests/Codout.Framework.Api.Dto.Tests/Codout.Framework.Api.Dto.Tests.csproj b/tests/Codout.Framework.Api.Dto.Tests/Codout.Framework.Api.Dto.Tests.csproj new file mode 100644 index 0000000..c58c90f --- /dev/null +++ b/tests/Codout.Framework.Api.Dto.Tests/Codout.Framework.Api.Dto.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Api.Dto.Tests/DtoContractTests.cs b/tests/Codout.Framework.Api.Dto.Tests/DtoContractTests.cs new file mode 100644 index 0000000..f5844f9 --- /dev/null +++ b/tests/Codout.Framework.Api.Dto.Tests/DtoContractTests.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using Codout.Framework.Api.Client; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Api.Dto.Tests; + +public class DtoContractTests +{ + [Fact] + public void Dto_DeveImplementarIDto() + { + new Client.Dto().Should().BeAssignableTo(); + } + + [Fact] + public void EntityDto_DeveImplementarIEntityDto() + { + new EntityDto().Should().BeAssignableTo>(); + } + + [Fact] + public void EntityDto_Int_IdPadraoEhZero() + { + new EntityDto().Id.Should().Be(0); + } + + [Fact] + public void EntityDto_Guid_IdPadraoEhGuidVazio() + { + new EntityDto().Id.Should().Be(Guid.Empty); + } + + [Fact] + public void EntityDto_String_IdPadraoEhNulo() + { + new EntityDto().Id.Should().BeNull(); + } + + [Fact] + public void EntityDto_Id_DevePermitirLeituraEEscrita() + { + var id = Guid.NewGuid(); + var dto = new EntityDto { Id = id }; + + dto.Id.Should().Be(id); + } + + [Fact] + public void EntityDto_AcessadoViaInterface_RefleteOMesmoId() + { + IEntityDto dto = new EntityDto(); + dto.Id = 42; + + ((EntityDto)dto).Id.Should().Be(42); + } + + [Fact] + public void TiposDoPacote_EstaoNoNamespaceCodoutFrameworkApiClient() + { + // Observação: o pacote chama-se Codout.Framework.Api.Dto, mas os tipos do + // shared project Codout.Framework.Dto.Shared são declarados no namespace + // Codout.Framework.Api.Client (compartilhado com o pacote do client). + typeof(EntityDto<>).Namespace.Should().Be("Codout.Framework.Api.Client"); + typeof(Client.Dto).Namespace.Should().Be("Codout.Framework.Api.Client"); + typeof(IDto).Namespace.Should().Be("Codout.Framework.Api.Client"); + typeof(IEntityDto<>).Namespace.Should().Be("Codout.Framework.Api.Client"); + } +} + +public class DtoSerializationTests +{ + private static readonly JsonSerializerOptions Web = JsonSerializerOptions.Web; + + private class ClienteDto : EntityDto + { + public string? Nome { get; set; } + public int Pontos { get; set; } + } + + [Fact] + public void EntityDto_RoundtripJson_PreservaId() + { + var original = new EntityDto { Id = 123 }; + + var json = JsonSerializer.Serialize(original, Web); + var restored = JsonSerializer.Deserialize>(json, Web); + + restored!.Id.Should().Be(123); + } + + [Fact] + public void EntityDto_SerializadoComOpcoesWeb_UsaCamelCase() + { + var json = JsonSerializer.Serialize(new EntityDto { Id = 7 }, Web); + + json.Should().Be("{\"id\":7}"); + } + + [Fact] + public void EntityDto_Guid_RoundtripJson_PreservaId() + { + var id = Guid.NewGuid(); + var json = JsonSerializer.Serialize(new EntityDto { Id = id }, Web); + + var restored = JsonSerializer.Deserialize>(json, Web); + + restored!.Id.Should().Be(id); + } + + [Fact] + public void DtoDerivado_RoundtripJson_PreservaTodasAsPropriedades() + { + var original = new ClienteDto { Id = Guid.NewGuid(), Nome = "Maria", Pontos = 10 }; + + var json = JsonSerializer.Serialize(original, Web); + var restored = JsonSerializer.Deserialize(json, Web); + + restored.Should().BeEquivalentTo(original); + } + + [Fact] + public void DtoDerivado_DeserializaJsonCamelCase() + { + var json = "{\"id\":\"7e1f9a8a-9d24-4a6c-8e6e-2a76a3a1b001\",\"nome\":\"João\",\"pontos\":3}"; + + var dto = JsonSerializer.Deserialize(json, Web); + + dto!.Id.Should().Be(Guid.Parse("7e1f9a8a-9d24-4a6c-8e6e-2a76a3a1b001")); + dto.Nome.Should().Be("João"); + dto.Pontos.Should().Be(3); + } + + [Fact] + public void DtoDerivado_DeserializacaoDeJsonVazio_UsaDefaults() + { + var dto = JsonSerializer.Deserialize("{}", Web); + + dto!.Id.Should().Be(Guid.Empty); + dto.Nome.Should().BeNull(); + dto.Pontos.Should().Be(0); + } +} diff --git a/tests/Codout.Framework.Api.Tests/ApiExceptionMiddlewareTests.cs b/tests/Codout.Framework.Api.Tests/ApiExceptionMiddlewareTests.cs new file mode 100644 index 0000000..3fdf5ab --- /dev/null +++ b/tests/Codout.Framework.Api.Tests/ApiExceptionMiddlewareTests.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Text.Json; +using Codout.Framework.Api.Middleware; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Codout.Framework.Api.Tests; + +public class ApiExceptionMiddlewareTests +{ + private static DefaultHttpContext CreateContext() + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + return context; + } + + private static string ReadBody(HttpContext context) + { + context.Response.Body.Seek(0, SeekOrigin.Begin); + return new StreamReader(context.Response.Body, Encoding.UTF8).ReadToEnd(); + } + + [Fact] + public async Task QuandoNaoHaExcecao_DeixaORequestPassar() + { + var nextCalled = false; + var middleware = new ApiExceptionMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, + NullLogger.Instance); + + var context = CreateContext(); + await middleware.InvokeAsync(context); + + nextCalled.Should().BeTrue(); + ReadBody(context).Should().BeEmpty(); + } + + [Fact] + public async Task QuandoHaExcecao_EscreveApiExceptionComoJson() + { + var middleware = new ApiExceptionMiddleware( + _ => throw new InvalidOperationException("algo deu errado"), + NullLogger.Instance); + + var context = CreateContext(); + await middleware.InvokeAsync(context); + + context.Response.ContentType.Should().StartWith("application/json"); + + using var document = JsonDocument.Parse(ReadBody(context)); + document.RootElement.GetProperty("message").GetString().Should().Be("algo deu errado"); + document.RootElement.GetProperty("errors")[0].GetProperty("errorMessage").GetString() + .Should().Be("algo deu errado"); + } + + [Fact] + public async Task QuandoHaExcecao_StatusCodePermanece200() + { + // BUG?: o middleware nunca altera Response.StatusCode — ele serializa o status + // vigente (200) dentro do corpo e responde `200 OK` para qualquer exceção não + // tratada, em vez de 500. + var middleware = new ApiExceptionMiddleware( + _ => throw new InvalidOperationException("falha interna"), + NullLogger.Instance); + + var context = CreateContext(); + await middleware.InvokeAsync(context); + + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + + using var document = JsonDocument.Parse(ReadBody(context)); + document.RootElement.GetProperty("statusCode").GetInt32().Should().Be(200); + } + + [Fact] + public async Task ConfigureExceptionMiddleware_RegistraOMiddlewareNoPipeline() + { + var services = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + var app = new ApplicationBuilder(services); + app.ConfigureExceptionMiddleware(); + app.Run(_ => throw new ApplicationException("estourou no pipeline")); + var pipeline = app.Build(); + + var context = CreateContext(); + context.RequestServices = services; + await pipeline(context); + + using var document = JsonDocument.Parse(ReadBody(context)); + document.RootElement.GetProperty("message").GetString().Should().Be("estourou no pipeline"); + } +} diff --git a/tests/Codout.Framework.Api.Tests/Codout.Framework.Api.Tests.csproj b/tests/Codout.Framework.Api.Tests/Codout.Framework.Api.Tests.csproj new file mode 100644 index 0000000..6a22496 --- /dev/null +++ b/tests/Codout.Framework.Api.Tests/Codout.Framework.Api.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Api.Tests/RestApiEntityBaseTests.cs b/tests/Codout.Framework.Api.Tests/RestApiEntityBaseTests.cs new file mode 100644 index 0000000..51e1515 --- /dev/null +++ b/tests/Codout.Framework.Api.Tests/RestApiEntityBaseTests.cs @@ -0,0 +1,147 @@ +using Codout.DynamicLinq; +using Codout.Framework.Api.Client; +using Codout.Framework.Application.Interfaces; +using Codout.Framework.Domain.Entities; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Codout.Framework.Api.Tests; + +public class Product : Entity +{ + public string? Name { get; set; } +} + +public class ProductDto : EntityDto +{ + public string? Name { get; set; } +} + +public class ProductsController(ICrudAppService appService) + : RestApiEntityBase(appService); + +public class RestApiEntityBaseTests +{ + private readonly Mock> _appService = new(); + private readonly ProductsController _controller; + + public RestApiEntityBaseTests() + { + _controller = new ProductsController(_appService.Object); + } + + [Fact] + public void Construtor_DeveExporOAppService() + { + _controller.AppService.Should().BeSameAs(_appService.Object); + } + + [Fact] + public async Task Get_DeveRetornarOkComODto() + { + var id = Guid.NewGuid(); + var dto = new ProductDto { Id = id, Name = "Caneta" }; + _appService.Setup(s => s.GetAsync(id)).ReturnsAsync(dto); + + var result = await _controller.Get(id); + + result.Should().BeOfType() + .Which.Value.Should().BeSameAs(dto); + } + + [Fact] + public async Task Get_QuandoNaoEncontrado_RetornaOkComCorpoNulo() + { + // BUG?: o controller sempre responde 200, mesmo quando o serviço retorna null — + // o contrato documenta 404 (ProducesResponseType Status404NotFound), mas o + // chamador recebe `200 OK` com corpo vazio. + _appService.Setup(s => s.GetAsync(It.IsAny())).ReturnsAsync((ProductDto)null!); + + var result = await _controller.Get(Guid.NewGuid()); + + result.Should().BeOfType() + .Which.Value.Should().BeNull(); + } + + [Fact] + public async Task Post_DeveSalvarERetornarOk() + { + var input = new ProductDto { Name = "Lápis" }; + var saved = new ProductDto { Id = Guid.NewGuid(), Name = "Lápis" }; + _appService.Setup(s => s.SaveAsync(input)).ReturnsAsync(saved); + + var result = await _controller.Post(input); + + _appService.Verify(s => s.SaveAsync(input), Times.Once); + result.Should().BeOfType() + .Which.Value.Should().BeSameAs(saved); + } + + [Fact] + public async Task Put_DeveSobrescreverOIdDoDtoComODaRota() + { + var routeId = Guid.NewGuid(); + var input = new ProductDto { Id = Guid.NewGuid(), Name = "Borracha" }; + _appService.Setup(s => s.UpdateAsync(input)).ReturnsAsync(input); + + var result = await _controller.Put(routeId, input); + + input.Id.Should().Be(routeId, "o id da rota tem precedência sobre o id do corpo"); + _appService.Verify(s => s.UpdateAsync(input), Times.Once); + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_DeveChamarOServicoERetornarOk() + { + var id = Guid.NewGuid(); + + var result = await _controller.Delete(id); + + _appService.Verify(s => s.DeleteAsync(id), Times.Once); + result.Should().BeOfType(); + } + + [Fact] + public async Task GetAll_DeveRetornarOkComODataSourceResult() + { + var request = new DataSourceRequest { Take = 10 }; + var dataSourceResult = new DataSourceResult { Total = 3 }; + _appService.Setup(s => s.GetAllAsync(request)).ReturnsAsync(dataSourceResult); + + var result = await _controller.GetAll(request); + + result.Should().BeOfType() + .Which.Value.Should().BeSameAs(dataSourceResult); + } + + [Fact] + public async Task ExcecaoDoServico_NaoEhTratadaNoController() + { + // O tratamento de erro fica a cargo do ApiExceptionMiddleware. + _appService.Setup(s => s.GetAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var act = () => _controller.Get(Guid.NewGuid()); + + await act.Should().ThrowAsync().WithMessage("boom"); + } + + [Fact] + public void RotasDosMetodos_SeguemOPadraoRest() + { + var type = typeof(RestApiEntityBase); + + type.GetMethod("Get")!.GetCustomAttributes(typeof(HttpGetAttribute), true) + .Cast().Single().Template.Should().Be("{id}"); + type.GetMethod("Post")!.GetCustomAttributes(typeof(HttpPostAttribute), true) + .Cast().Single().Template.Should().Be(""); + type.GetMethod("Put")!.GetCustomAttributes(typeof(HttpPutAttribute), true) + .Cast().Single().Template.Should().Be("{id}"); + type.GetMethod("Delete")!.GetCustomAttributes(typeof(HttpDeleteAttribute), true) + .Cast().Single().Template.Should().Be("{id}"); + type.GetMethod("GetAll")!.GetCustomAttributes(typeof(HttpPostAttribute), true) + .Cast().Single().Template.Should().Be("get-all"); + } +} diff --git a/tests/Codout.Framework.Application.Tests/Codout.Framework.Application.Tests.csproj b/tests/Codout.Framework.Application.Tests/Codout.Framework.Application.Tests.csproj new file mode 100644 index 0000000..54858da --- /dev/null +++ b/tests/Codout.Framework.Application.Tests/Codout.Framework.Application.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Application.Tests/CrudAppServiceBaseTests.cs b/tests/Codout.Framework.Application.Tests/CrudAppServiceBaseTests.cs new file mode 100644 index 0000000..5fb85c5 --- /dev/null +++ b/tests/Codout.Framework.Application.Tests/CrudAppServiceBaseTests.cs @@ -0,0 +1,210 @@ +using Codout.DynamicLinq; +using FluentAssertions; +using Moq; +using Xunit; + +namespace Codout.Framework.Application.Tests; + +public class CrudAppServiceBaseTests +{ + private readonly CrudServiceFixture _fixture = new(); + + [Fact] + public void Construtor_DeveExporDependencias() + { + _fixture.Service.UnitOfWork.Should().BeSameAs(_fixture.UnitOfWork.Object); + _fixture.Service.Repository.Should().BeSameAs(_fixture.Repository.Object); + _fixture.Service.Mapper.Should().BeSameAs(_fixture.Mapper); + } + + [Fact] + public async Task GetAsync_QuandoEntidadeExiste_RetornaDtoMapeado() + { + var id = Guid.NewGuid(); + var entity = CrudServiceFixture.NewCustomer(id, "Ana", 30); + _fixture.Repository.Setup(r => r.GetAsync(It.IsAny())).ReturnsAsync(entity); + + var dto = await _fixture.Service.GetAsync(id); + + dto.Should().NotBeNull(); + dto.Id.Should().Be(id); + dto.Name.Should().Be("Ana"); + dto.Age.Should().Be(30); + } + + [Fact] + public async Task GetAsync_QuandoEntidadeNaoExiste_RetornaNulo() + { + _fixture.Repository.Setup(r => r.GetAsync(It.IsAny())).ReturnsAsync((Customer?)null); + + var dto = await _fixture.Service.GetAsync(Guid.NewGuid()); + + dto.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_DeveSalvarComitarERetornarDto() + { + var input = new CustomerDto { Id = Guid.NewGuid(), Name = "Bruno", Age = 25 }; + Customer? saved = null; + _fixture.Repository + .Setup(r => r.SaveAsync(It.IsAny())) + .Callback(c => saved = c) + .ReturnsAsync((Customer c) => c); + + var output = await _fixture.Service.SaveAsync(input); + + saved.Should().NotBeNull(); + saved!.Name.Should().Be("Bruno"); + saved.Age.Should().Be(25); + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Once); + output.Should().BeEquivalentTo(input); + } + + [Fact] + public async Task SaveAsync_ComEntradaNula_LancaNullReferenceException() + { + // BUG?: contrato esperado seria ArgumentNullException; o código lança + // NullReferenceException e a mensagem usa nameof(TDto), que vira o texto + // literal "TDto" em vez do nome real do DTO. + var act = () => _fixture.Service.SaveAsync(null!); + + (await act.Should().ThrowAsync()) + .WithMessage("*TDto*"); + + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Never); + } + + [Fact] + public async Task UpdateAsync_QuandoEntidadeExiste_MapeiaComitaERetornaInput() + { + var id = Guid.NewGuid(); + var entity = CrudServiceFixture.NewCustomer(id, "Velho", 20); + _fixture.Repository.Setup(r => r.GetAsync(It.IsAny())).ReturnsAsync(entity); + + var input = new CustomerDto { Id = id, Name = "Novo", Age = 21 }; + var output = await _fixture.Service.UpdateAsync(input); + + output.Should().BeSameAs(input); + entity.Name.Should().Be("Novo", "o DTO deve ser mapeado sobre a entidade rastreada"); + entity.Age.Should().Be(21); + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Once); + } + + [Fact] + public async Task UpdateAsync_QuandoEntidadeNaoExiste_RetornaNuloESemCommit() + { + _fixture.Repository.Setup(r => r.GetAsync(It.IsAny())).ReturnsAsync((Customer?)null); + + var output = await _fixture.Service.UpdateAsync(new CustomerDto { Id = Guid.NewGuid() }); + + output.Should().BeNull(); + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Never); + } + + [Fact] + public async Task UpdateAsync_NaoChamaUpdateDoRepositorio() + { + // Observação de caracterização: UpdateAsync depende de a entidade estar + // rastreada pelo ORM — nenhum método Update/SaveOrUpdate do repositório é chamado. + var id = Guid.NewGuid(); + _fixture.Repository.Setup(r => r.GetAsync(It.IsAny())) + .ReturnsAsync(CrudServiceFixture.NewCustomer(id)); + + await _fixture.Service.UpdateAsync(new CustomerDto { Id = id, Name = "X" }); + + _fixture.Repository.Verify(r => r.Update(It.IsAny()), Times.Never); + _fixture.Repository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + _fixture.Repository.Verify(r => r.SaveOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteAsync_QuandoEntidadeExiste_DeletaEComita() + { + var id = Guid.NewGuid(); + var entity = CrudServiceFixture.NewCustomer(id); + _fixture.Repository.Setup(r => r.LoadAsync(It.IsAny())).ReturnsAsync(entity); + + await _fixture.Service.DeleteAsync(id); + + _fixture.Repository.Verify(r => r.DeleteAsync(entity), Times.Once); + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Once); + } + + [Fact] + public async Task DeleteAsync_QuandoEntidadeNaoExiste_NaoDeletaNemComita() + { + _fixture.Repository.Setup(r => r.LoadAsync(It.IsAny())).ReturnsAsync((Customer?)null); + + await _fixture.Service.DeleteAsync(Guid.NewGuid()); + + _fixture.Repository.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never); + _fixture.UnitOfWork.Verify(u => u.Commit(), Times.Never); + } + + [Fact] + public async Task GetAllAsync_ComRequestPreenchido_RetornaPaginaETotal() + { + var customers = Enumerable.Range(1, 7) + .Select(i => CrudServiceFixture.NewCustomer(Guid.NewGuid(), $"Cliente {i}", 20 + i)) + .AsQueryable(); + _fixture.Repository.Setup(r => r.All()).Returns(customers); + + var request = new DataSourceRequest + { + Take = 3, + Skip = 3, + Sort = [new Sort { Field = "Age", Dir = "asc" }], + Aggregate = [], + Group = [] + }; + + var result = await _fixture.Service.GetAllAsync(request); + + result.Total.Should().Be(7); + ((IEnumerable)result.Data!).Select(c => c.Age).Should().ContainInOrder(24, 25, 26); + } + + [Fact] + public async Task GetAllAsync_ComFiltro_AplicaOFiltro() + { + var customers = new[] + { + CrudServiceFixture.NewCustomer(Guid.NewGuid(), "Ana", 30), + CrudServiceFixture.NewCustomer(Guid.NewGuid(), "Bruno", 25) + }.AsQueryable(); + _fixture.Repository.Setup(r => r.All()).Returns(customers); + + var request = new DataSourceRequest + { + Take = 10, + Skip = 0, + Sort = [], + Aggregate = [], + Group = [], + Filter = new Filter + { + Logic = "and", + Filters = [new Filter { Field = "Name", Operator = "eq", Value = "Ana" }] + } + }; + + var result = await _fixture.Service.GetAllAsync(request); + + result.Total.Should().Be(1); + ((IEnumerable)result.Data!).Single().Name.Should().Be("Ana"); + } + + [Fact] + public async Task GetAllAsync_ComRequestPadrao_LancaArgumentNullException() + { + // BUG?: um DataSourceRequest recém-criado (Sort/Aggregate/Group nulos) derruba + // ToDataSourceResult com ArgumentNullException (`aggregates.ToArray()` sobre nulo). + // Qualquer consumidor da API que poste um request "vazio" recebe erro 500. + _fixture.Repository.Setup(r => r.All()).Returns(Array.Empty().AsQueryable()); + + var act = () => _fixture.Service.GetAllAsync(new DataSourceRequest { Take = 10 }); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Codout.Framework.Application.Tests/RegisterServicesTests.cs b/tests/Codout.Framework.Application.Tests/RegisterServicesTests.cs new file mode 100644 index 0000000..4c2cb13 --- /dev/null +++ b/tests/Codout.Framework.Application.Tests/RegisterServicesTests.cs @@ -0,0 +1,111 @@ +using AutoMapper; +using Codout.Framework.Api.Client; +using Codout.Framework.Application.Interfaces; +using Codout.Framework.Data; +using Codout.Framework.Data.Repository; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Codout.Framework.Application.Tests; + +public class RegisterServicesTests +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddScoped(_ => Mock.Of()); + services.AddScoped(_ => Mock.Of>()); + + services.AddCrudAppServices(); + + return services.BuildServiceProvider(); + } + + [Fact] + public void AddCrudAppServices_RegistraICrudAppServiceGenerico() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + + var service = scope.ServiceProvider.GetService>(); + + service.Should().NotBeNull(); + service.Should().BeOfType>(); + } + + [Fact] + public void AddCrudAppServices_RegistraIMapper() + { + using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddCrudAppServices_RegistroEhScoped() + { + using var provider = BuildProvider(); + + using var scope1 = provider.CreateScope(); + var a = scope1.ServiceProvider.GetRequiredService>(); + var b = scope1.ServiceProvider.GetRequiredService>(); + + using var scope2 = provider.CreateScope(); + var c = scope2.ServiceProvider.GetRequiredService>(); + + a.Should().BeSameAs(b); + a.Should().NotBeSameAs(c); + } + + [Fact] + public void AddCrudAppServices_RetornaAMesmaColecao() + { + var services = new ServiceCollection(); + + services.AddCrudAppServices().Should().BeSameAs(services); + } +} + +public class MappingProfileTests +{ + [Fact] + public void MappingProfile_MapeiaEntityParaEntityDto_ViaMapaGenericoAberto() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAutoMapper(x => x.AddMaps(typeof(MappingProfile))); + using var provider = services.BuildServiceProvider(); + + var mapper = provider.GetRequiredService(); + var entity = CrudServiceFixture.NewCustomer(Guid.NewGuid()); + + var dto = mapper.Map>(entity); + + dto.Id.Should().Be(entity.Id); + } + + [Fact] + public void MappingProfile_MapaReverso_NaoConsegueCriarEntityConcreta() + { + // BUG?: o ReverseMap de CreateMap(typeof(Entity<>), typeof(EntityDto<>)) casa o + // destino com o tipo aberto Entity<>, e o AutoMapper tenta instanciar a classe + // abstrata Entity em vez do tipo concreto pedido (Customer). Na prática, + // Mapper.Map(dto) — usado por CrudAppServiceBase.SaveAsync — falha se o + // consumidor não registrar um mapa próprio DTO→Entidade. + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAutoMapper(x => x.AddMaps(typeof(MappingProfile))); + using var provider = services.BuildServiceProvider(); + + var mapper = provider.GetRequiredService(); + + var act = () => mapper.Map(new EntityDto { Id = Guid.NewGuid() }); + + act.Should().Throw() + .WithMessage("*abstract type*Entity*"); + } +} diff --git a/tests/Codout.Framework.Application.Tests/TestModel.cs b/tests/Codout.Framework.Application.Tests/TestModel.cs new file mode 100644 index 0000000..f386925 --- /dev/null +++ b/tests/Codout.Framework.Application.Tests/TestModel.cs @@ -0,0 +1,66 @@ +using AutoMapper; +using Codout.Framework.Api.Client; +using Codout.Framework.Application; +using Codout.Framework.Data; +using Codout.Framework.Data.Repository; +using Codout.Framework.Domain.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Codout.Framework.Application.Tests; + +public class Customer : Entity +{ + public string? Name { get; set; } + + public int Age { get; set; } +} + +public class CustomerDto : EntityDto +{ + public string? Name { get; set; } + + public int Age { get; set; } +} + +public class CustomerAppService( + IUnitOfWork unitOfWork, + IRepository repository, + IMapper mapper) + : CrudAppServiceBase(unitOfWork, repository, mapper); + +public static class TestMapper +{ + public static IMapper Create() + { + var configuration = new MapperConfiguration(cfg => + { + cfg.CreateMap().ReverseMap(); + }, NullLoggerFactory.Instance); + + return configuration.CreateMapper(); + } +} + +public class CrudServiceFixture +{ + public CrudServiceFixture() + { + Service = new CustomerAppService(UnitOfWork.Object, Repository.Object, Mapper); + } + + public Mock UnitOfWork { get; } = new(); + + public Mock> Repository { get; } = new(); + + public IMapper Mapper { get; } = TestMapper.Create(); + + public CustomerAppService Service { get; } + + public static Customer NewCustomer(Guid id, string name = "Ana", int age = 30) + { + var customer = new Customer { Name = name, Age = age }; + customer.SetId(id); + return customer; + } +} diff --git a/tests/Codout.Framework.Common.Tests/Codout.Framework.Common.Tests.csproj b/tests/Codout.Framework.Common.Tests/Codout.Framework.Common.Tests.csproj new file mode 100644 index 0000000..a1489c1 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Codout.Framework.Common.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Common.Tests/Extensions/DateTimeExtensionsTests.cs b/tests/Codout.Framework.Common.Tests/Extensions/DateTimeExtensionsTests.cs new file mode 100644 index 0000000..4e2aaed --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Extensions/DateTimeExtensionsTests.cs @@ -0,0 +1,141 @@ +using Codout.Framework.Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Extensions; + +public class DateTimeExtensionsTests +{ + [Fact] + public void IsWeekDay_SegundaASexta_RetornaTrue() + { + new DateTime(2026, 6, 8).IsWeekDay().Should().BeTrue(); // segunda-feira + new DateTime(2026, 6, 12).IsWeekDay().Should().BeTrue(); // sexta-feira + new DateTime(2026, 6, 13).IsWeekDay().Should().BeFalse(); // sábado + new DateTime(2026, 6, 14).IsWeekDay().Should().BeFalse(); // domingo + } + + [Fact] + public void IsWeekEnd_SabadoEDomingo_RetornaTrue() + { + new DateTime(2026, 6, 13).IsWeekEnd().Should().BeTrue(); + new DateTime(2026, 6, 14).IsWeekEnd().Should().BeTrue(); + new DateTime(2026, 6, 10).IsWeekEnd().Should().BeFalse(); + } + + [Fact] + public void CountWeekdays_UmaSemanaCompleta_RetornaCinco() + { + var start = new DateTime(2026, 6, 8); // segunda + var end = new DateTime(2026, 6, 15); // segunda seguinte + start.CountWeekdays(end).Should().Be(5); + } + + [Fact] + public void CountWeekends_UmaSemanaCompleta_RetornaDois() + { + var start = new DateTime(2026, 6, 8); + var end = new DateTime(2026, 6, 15); + start.CountWeekends(end).Should().Be(2); + } + + [Fact] + public void IsDate_ValidaConversao() + { + "2026-06-12".IsDate().Should().BeTrue(); + "não é data".IsDate().Should().BeFalse(); + } + + [Theory] + [InlineData(1, "1st")] + [InlineData(2, "2nd")] + [InlineData(3, "3rd")] + [InlineData(4, "4th")] + [InlineData(11, "11th")] + [InlineData(21, "21st")] + [InlineData(22, "22nd")] + [InlineData(23, "23rd")] + public void GetDateDayWithSuffix_RetornaSufixoCorreto(int day, string expected) + { + new DateTime(2026, 1, day).GetDateDayWithSuffix().Should().Be(expected); + } + + [Fact] + public void GetAge_AntesDoAniversario_SubtraiUm() + { + var nascimento = new DateTime(2000, 12, 31); + var referencia = new DateTime(2020, 6, 15); + nascimento.GetAge(referencia).Should().Be(19); + } + + [Fact] + public void GetAge_DepoisDoAniversario_RetornaIdadeCheia() + { + var nascimento = new DateTime(2000, 1, 2); + var referencia = new DateTime(2020, 6, 15); + nascimento.GetAge(referencia).Should().Be(20); + } + + [Fact] + public void GetAge_NoDiaDoAniversario_ComportamentoAtual() + { + // BUG?: no próprio dia do aniversário (mesmo DayOfYear) a comparação + // estrita "<" faz a idade ser subtraída em 1. Quem nasceu em 15/06/2000 + // deveria completar 20 anos em 15/06/2020, mas o método retorna 19. + var nascimento = new DateTime(2000, 6, 15); + var referencia = new DateTime(2020, 6, 15); + nascimento.GetAge(referencia).Should().Be(19); + } + + [Fact] + public void Diff_RetornaDiferencaEntreDatas() + { + var d1 = new DateTime(2026, 6, 12, 10, 0, 0); + var d2 = new DateTime(2026, 6, 10, 10, 0, 0); + d1.Diff(d2).Should().Be(TimeSpan.FromDays(2)); + d1.DiffDays(d2).Should().Be(2); + d1.DiffHours(d2).Should().Be(48); + d1.DiffMinutes(d2).Should().Be(48 * 60); + } + + [Fact] + public void DiffDays_ComStringsInvalidas_RetornaZero() + { + "abc".DiffDays("def").Should().Be(0); + } + + [Fact] + public void DiffDays_ComStringsValidas_CalculaDiferenca() + { + "2026-06-12".DiffDays("2026-06-10").Should().Be(2); + } + + [Fact] + public void TimeDiff_FormataDiferencaLegivel() + { + var start = new DateTime(2020, 1, 1, 0, 0, 0); + var end = new DateTime(2022, 1, 1, 0, 0, 5); + var result = start.TimeDiff(end); + result.Should().Contain("2 anos"); + result.Should().Contain("5 segundos"); + } + + [Fact] + public void DaysAgoEDaysFromNow_RetornamDatasRelativas() + { + 2.DaysAgo().Should().BeCloseTo(DateTime.Now.AddDays(-2), TimeSpan.FromSeconds(5)); + 2.DaysFromNow().Should().BeCloseTo(DateTime.Now.AddDays(2), TimeSpan.FromSeconds(5)); + 3.HoursAgo().Should().BeCloseTo(DateTime.Now.AddHours(-3), TimeSpan.FromSeconds(5)); + 10.MinutesFromNow().Should().BeCloseTo(DateTime.Now.AddMinutes(10), TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ReadableDiff_RetornaTextoComAtras() + { + var start = new DateTime(2026, 6, 10, 10, 0, 0); + var end = new DateTime(2026, 6, 12, 11, 0, 0); + var result = start.ReadableDiff(end); + result.Should().Contain("2 dias"); + result.Should().EndWith("atrás"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Extensions/NumericExtensionsTests.cs b/tests/Codout.Framework.Common.Tests/Extensions/NumericExtensionsTests.cs new file mode 100644 index 0000000..add8d29 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Extensions/NumericExtensionsTests.cs @@ -0,0 +1,84 @@ +using Codout.Framework.Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Extensions; + +public class NumericExtensionsTests +{ + [Theory] + [InlineData("123", true)] + [InlineData("007", true)] + [InlineData("0", false)] + [InlineData("-5", false)] + [InlineData("12.3", false)] + [InlineData("abc", false)] + public void IsNaturalNumber_ValidaNumeroNatural(string input, bool expected) + { + input.IsNaturalNumber().Should().Be(expected); + } + + [Theory] + [InlineData("123", true)] + [InlineData("0", true)] + [InlineData("-5", false)] + [InlineData("1.5", false)] + public void IsWholeNumber_ValidaNumeroInteiroSemSinal(string input, bool expected) + { + input.IsWholeNumber().Should().Be(expected); + } + + [Theory] + [InlineData("123", true)] + [InlineData("-123", true)] + [InlineData("--123", false)] + [InlineData("1.5", false)] + [InlineData("abc", false)] + public void IsInteger_ValidaInteiroComSinal(string input, bool expected) + { + input.IsInteger().Should().Be(expected); + } + + [Theory] + [InlineData(0, true)] + [InlineData(2, true)] + [InlineData(-4, true)] + [InlineData(3, false)] + [InlineData(-7, false)] + public void IsEven_ValidaPar(int value, bool expected) + { + value.IsEven().Should().Be(expected); + value.IsOdd().Should().Be(!expected); + } + + [Theory] + [InlineData(3.14159, 2, 3.14)] + [InlineData(3.999, 0, 3.0)] + [InlineData(-1.239, 2, -1.23)] + public void Truncate_TruncaCasasDecimais(double value, int precision, double expected) + { + value.Truncate(precision).Should().Be(expected); + } + + [Fact] + public void Random_ComLimites_RetornaDentroDoIntervalo() + { + for (var i = 0; i < 50; i++) + NumericExtensions.Random(5, 10).Should().BeInRange(5, 9); + } + + [Fact] + public void Random_SemParametros_RetornaEntreZeroEUm() + { + NumericExtensions.Random().Should().BeInRange(0.0, 1.0); + } + + [Fact] + public void Random_ComLimiteSuperior_RetornaMenorQueLimite() + { +#pragma warning disable CS0618 // Random(int) está marcado como Obsolete + for (var i = 0; i < 50; i++) + NumericExtensions.Random(10).Should().BeInRange(0, 9); +#pragma warning restore CS0618 + } +} diff --git a/tests/Codout.Framework.Common.Tests/Extensions/ObjectExtensionsTests.cs b/tests/Codout.Framework.Common.Tests/Extensions/ObjectExtensionsTests.cs new file mode 100644 index 0000000..fb0711b --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Extensions/ObjectExtensionsTests.cs @@ -0,0 +1,80 @@ +using Codout.Framework.Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Extensions; + +public class ObjectExtensionsTests +{ + private sealed class Pessoa + { + public string? Nome { get; set; } + public int Idade { get; set; } + } + + [Fact] + public void ChangeTypeTo_ConverteTiposSimples() + { + "42".ChangeTypeTo().Should().Be(42); + "true".ChangeTypeTo().Should().Be(true); + } + + [Fact] + public void ChangeTypeTo_ConverteParaNullable() + { + "42".ChangeTypeTo().Should().Be(42); + ((object?)null!).ChangeTypeTo().Should().BeNull(); + } + + [Fact] + public void ChangeTypeTo_ConverteParaGuid() + { + var guid = Guid.NewGuid(); + guid.ToString().ChangeTypeTo().Should().Be(guid); + } + + [Fact] + public void ChangeTypeTo_IntParaLong_LancaExcecao() + { + // Comportamento intencional documentado no código (caso SQLite/PK Int64) + var act = () => 42.ChangeTypeTo(); + act.Should().Throw(); + } + + [Fact] + public void ChangeTypeTo_TipoNulo_LancaArgumentNullException() + { + var act = () => "x".ChangeTypeTo(null!); + act.Should().Throw(); + } + + [Fact] + public void ToDictionary_ConvertePropriedadesPublicas() + { + var dict = new Pessoa { Nome = "Ana", Idade = 30 }.ToDictionary(); + dict.Should().Contain("Nome", "Ana"); + dict.Should().Contain("Idade", 30); + } + + [Fact] + public void CopyTo_CopiaPropriedadesEntreObjetos() + { + var origem = new Pessoa { Nome = "Ana", Idade = 30 }; + var destino = new Pessoa(); + + var resultado = origem.CopyTo(destino); + + resultado.Nome.Should().Be("Ana"); + resultado.Idade.Should().Be(30); + } + + [Fact] + public void FromDictionary_PreencheObjeto() + { + var dict = new Dictionary { ["Nome"] = "Bia", ["Idade"] = 25 }; + var pessoa = dict.FromDictionary(new Pessoa()); + + pessoa.Nome.Should().Be("Bia"); + pessoa.Idade.Should().Be(25); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Extensions/StringExtensionsTests.cs b/tests/Codout.Framework.Common.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000..1f9c0db --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,206 @@ +using Codout.Framework.Common.Extensions; +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Extensions; + +public class StringExtensionsTests +{ + [Theory] + [InlineData("abc", "ABC", true)] + [InlineData("abc", "abc", true)] + [InlineData("abc", "abd", false)] + public void Matches_ComparaIgnorandoCase(string source, string compare, bool expected) + { + source.Matches(compare).Should().Be(expected); + } + + [Fact] + public void MatchesTrimmed_IgnoraEspacosNasBordas() + { + " abc ".MatchesTrimmed("ABC").Should().BeTrue(); + " abc ".MatchesTrimmed("ab c").Should().BeFalse(); + } + + [Fact] + public void MatchesRegex_ValidaPadrao() + { + "abc123".MatchesRegex(@"^[a-z]+\d+$").Should().BeTrue(); + "123abc".MatchesRegex(@"^[a-z]+\d+$").Should().BeFalse(); + } + + [Fact] + public void Chop_RemoveUltimosCaracteres() + { + "abcdef".Chop(2).Should().Be("abcd"); + "abcdef".Chop().Should().Be("abcde"); + "ab".Chop(0).Should().Be("ab"); + } + + [Fact] + public void Chop_ComStringAlvo_RemoveAtePadrao() + { + "documento.txt".Chop(".txt").Should().Be("documento"); + } + + [Fact] + public void Chop_ComStringAlvoInexistente_LancaExcecao() + { + // BUG?: quando o padrão não é encontrado, LastIndexOf retorna -1 e o + // código chama Remove(-1, 0), que lança ArgumentOutOfRangeException + // em vez de retornar a string original. + var act = () => "abc".Chop("zzz"); + act.Should().Throw(); + } + + [Fact] + public void Clip_RemoveCaracteresDoInicio() + { + "abcdef".Clip(2).Should().Be("cdef"); + "abcdef".Clip().Should().Be("bcdef"); + "ab".Clip(5).Should().Be("ab"); + } + + [Fact] + public void Clip_ComStringAlvo_RemoveAtePadrao() + { + "prefixo:valor".Clip(":").Should().Be(":valor"); + } + + [Fact] + public void FastReplace_SubstituiIgnorandoCase() + { + "Hello World, hello!".FastReplace("hello", "bye").Should().Be("bye World, bye!"); + } + + [Fact] + public void FastReplace_OriginalNula_RetornaNull() + { + ((string?)null!).FastReplace("a", "b").Should().BeNull(); + } + + [Fact] + public void Crop_RetornaTextoEntreDelimitadores() + { + "negrito".Crop("", "").Should().Be("negrito"); + "sem delimitador".Crop("", "").Should().Be(string.Empty); + } + + [Fact] + public void Squeeze_RemoveEspacosExcedentes() + { + " a b c ".Squeeze().Should().Be("a b c"); + } + + [Fact] + public void ToAlphaNumericOnly_RemoveCaracteresNaoAlfanumericos() + { + "ab-12!cd #34".ToAlphaNumericOnly().Should().Be("ab12cd34"); + } + + [Theory] + [InlineData("(11) 98765-4321", "11987654321")] + [InlineData("abc", "")] + [InlineData("", "")] + public void OnlyNumbers_RetornaSomenteDigitos(string input, string expected) + { + input.OnlyNumbers().Should().Be(expected); + } + + [Fact] + public void ToWords_SeparaPalavras() + { + " uma frase de teste ".ToWords().Should().Equal("uma", "frase", "de", "teste"); + } + + [Fact] + public void StripHtml_RemoveTags() + { + "

Texto importante & outro

".StripHtml().Should().Be("Texto importante& outro"); + } + + [Fact] + public void FindMatches_RetornaOcorrencias() + { + "a1 b2 c3".FindMatches(@"[a-z]\d").Should().Equal("a1", "b2", "c3"); + } + + [Fact] + public void ToDelimitedList_ConcatenaComDelimitador() + { + new[] { "a", "b", "c" }.ToDelimitedList().Should().Be("a,b,c"); + new[] { "a", "b" }.ToDelimitedList(";").Should().Be("a;b"); + } + + [Fact] + public void Strip_RemovePadroesSeparadosPorVirgula() + { + "abc123def".Strip(@"\d").Should().Be("abcdef"); + } + + [Fact] + public void ToFormattedString_FormataComArgumentos() + { + "{0}-{1}".ToFormattedString(1, "a").Should().Be("1-a"); + } + + [Fact] + public void ToEnum_ConvertePorNomeIgnorandoCase() + { + "friday".ToEnum().Should().Be(DayOfWeek.Friday); + "inexistente".ToEnum().Should().Be(default(DayOfWeek)); + } + + [Theory] + [InlineData("abcdef", 3, "abc")] + [InlineData("ab", 5, "ab")] + [InlineData("", 3, "")] + public void Truncate_LimitaTamanho(string input, int max, string expected) + { + input.Truncate(max).Should().Be(expected); + } + + [Fact] + public void HtmlEncode_HtmlDecode_SaoInversos() + { + // BUG?: HtmlEncode substitui '&' por "&" por ÚLTIMO, depois de já ter + // gerado as entidades; com isso "é" vira "&eacute;" (duplo escape) em + // vez de "é". O roundtrip com HtmlDecode ainda funciona porque o + // decode desfaz na ordem inversa. + const string texto = "café & ação "; + var encoded = texto.HtmlEncode(); + + encoded.Should().Contain("&"); + encoded.Should().Contain("eacute;"); + encoded.Should().NotContain("é"); + encoded.Should().NotContain("<"); + + encoded.HtmlDecode().Should().Be(texto); + } + + [Fact] + public void Pluralize_UsaSingularOuPluralConformeQuantidade() + { + 1.Pluralize("casa").Should().Be("1 casa"); + 2.Pluralize("casa").Should().Be("2 casas"); + } + + [Fact] + public void RemoveAccents_ComportamentoAtual() + { + // BUG?: RemoveAccents usa Encoding.GetEncoding("iso-8859-8") (hebraico), + // que não está registrado por padrão no .NET (Core); o método lança + // ArgumentException em runtime moderno em vez de remover acentos. + var act = () => "ação".RemoveAccents(); + act.Should().Throw(); + } + + [Fact] + public void RemoveCharactersSpecial_SemReplaceAccents_RemoveSimbolos() + { + // replaceAccents = false para evitar o caminho do RemoveAccents (ver teste acima) + "olá! @mundo#".RemoveCharactersSpecial(allowWhiteSpace: true, replaceAccents: false) + .Should().Be("olá mundo"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Extensions/ValidationExtensionsTests.cs b/tests/Codout.Framework.Common.Tests/Extensions/ValidationExtensionsTests.cs new file mode 100644 index 0000000..71b5f62 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Extensions/ValidationExtensionsTests.cs @@ -0,0 +1,151 @@ +using Codout.Framework.Common.Extensions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Extensions; + +public class ValidationExtensionsTests +{ + [Theory] + [InlineData("usuario@dominio.com", true)] + [InlineData("nome.sobrenome@empresa.com.br", true)] + [InlineData("sem-arroba.com", false)] + [InlineData("a@b", false)] + public void IsEmail_ValidaFormato(string input, bool expected) + { + input.IsEmail().Should().Be(expected); + } + + [Fact] + public void IsEmail_StringVazia_RetornaTrue() + { + // BUG?: e-mail vazio/whitespace é considerado válido pela implementação + // atual (provavelmente para campos opcionais). Documentando o comportamento. + string.Empty.IsEmail().Should().BeTrue(); + " ".IsEmail().Should().BeTrue(); + } + + [Theory] + [InlineData("529.982.247-25", true)] // CPF válido conhecido + [InlineData("52998224725", true)] // mesmo CPF sem máscara + [InlineData("529.982.247-24", false)] // dígito verificador errado + [InlineData("11111111111", false)] // sequência repetida + [InlineData("123", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsCpf_ValidaDigitosVerificadores(string? input, bool expected) + { + input!.IsCpf().Should().Be(expected); + } + + [Theory] + [InlineData("11.222.333/0001-81", true)] // CNPJ válido conhecido + [InlineData("11222333000181", true)] + [InlineData("11.222.333/0001-80", false)] // dígito errado + [InlineData("123", false)] + public void IsCnpj_ValidaDigitosVerificadores(string input, bool expected) + { + input.IsCnpj().Should().Be(expected); + } + + [Theory] + [InlineData("01310-100", true)] + [InlineData("01310100", true)] + [InlineData("01.310-100", true)] + [InlineData("1310-100", false)] + [InlineData("abcde-fgh", false)] + public void IsCep_ValidaFormato(string input, bool expected) + { + input.IsCep().Should().Be(expected); + } + + [Fact] + public void IsGuid_ValidaFormato() + { + Guid.NewGuid().ToString().IsGuid().Should().BeTrue(); + "não-é-um-guid".IsGuid().Should().BeFalse(); + } + + [Theory] + [InlineData("192.168.0.1", true)] + [InlineData("255.255.255.255", true)] + [InlineData("256.1.1.1", false)] + [InlineData("1.2.3", false)] + public void IsIpAddress_ValidaFormato(string input, bool expected) + { + input.IsIpAddress().Should().Be(expected); + } + + [Theory] + [InlineData("https://www.exemplo.com", true)] + [InlineData("http://exemplo.com/caminho?x=1", true)] + [InlineData("ftp://arquivos.exemplo.com", true)] + [InlineData("não é url", false)] + public void IsUrl_ValidaFormato(string input, bool expected) + { + input.IsUrl().Should().Be(expected); + } + + [Theory] + [InlineData("Senha123!", true)] + [InlineData("abcdefgh", false)] // sem número/maiúscula/símbolo + [InlineData("Ab1!", false)] // curta demais + public void IsStrongPassword_ValidaComplexidade(string input, bool expected) + { + input.IsStrongPassword().Should().Be(expected); + } + + [Fact] + public void IsValidLuhn_ValidaAlgoritmo() + { + // 4111111111111111 é um número de teste Visa que passa no Luhn + var valid = "4111111111111111".Select(c => c - '0').ToArray(); + valid.IsValidLuhn().Should().BeTrue(); + + var invalid = "4111111111111112".Select(c => c - '0').ToArray(); + invalid.IsValidLuhn().Should().BeFalse(); + } + + [Theory] + [InlineData("4111 1111 1111 1111", true)] // Visa de teste + [InlineData("5500-0000-0000-0004", true)] // MasterCard de teste + [InlineData("1234567890123456", false)] + public void IsCreditCardAny_ValidaCartoes(string input, bool expected) + { + input.IsCreditCardAny().Should().Be(expected); + } + + [Fact] + public void IsCreditCardVisa_ValidaSomenteVisa() + { + "4111111111111111".IsCreditCardVisa().Should().BeTrue(); + "5500000000000004".IsCreditCardVisa().Should().BeFalse(); + } + + [Fact] + public void CleanCreditCardNumber_RemoveNaoNumericos() + { + "4111-1111 1111x1111".CleanCreditCardNumber().Should().Be("4111111111111111"); + } + + [Theory] + [InlineData("12345", true)] + [InlineData("12345-6789", true)] + [InlineData("1234", false)] + public void IsZipCodeAny_ValidaFormatoAmericano(string input, bool expected) + { + input.IsZipCodeAny().Should().Be(expected); + } + + [Fact] + public void IsInscricaoEstadual_Isento_RetornaTrue() + { + ValidationExtensions.IsInscricaoEstadual("SP", "ISENTO").Should().BeTrue(); + } + + [Fact] + public void IsInscricaoEstadual_NumeroInvalido_RetornaFalse() + { + ValidationExtensions.IsInscricaoEstadual("MT", "00000000001").Should().BeFalse(); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Helpers/EnumHelperTests.cs b/tests/Codout.Framework.Common.Tests/Helpers/EnumHelperTests.cs new file mode 100644 index 0000000..92e2edf --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Helpers/EnumHelperTests.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Helpers; + +public class EnumHelperTests +{ + private enum Status + { + [Description("Em andamento")] + EmAndamento, + + [Description("Concluído")] + Concluido, + + SemDescricao + } + + private enum Idioma + { + // GetLocalizedName lê DisplayAttribute.GetDescription(), então é a + // propriedade Description (e não Name) que precisa estar preenchida. + [Display(Description = "Português")] + Portugues, + + Ingles + } + + [Fact] + public void GetDescription_ComAtributo_RetornaDescricao() + { + Status.EmAndamento.GetDescription().Should().Be("Em andamento"); + Status.Concluido.GetDescription().Should().Be("Concluído"); + } + + [Fact] + public void GetDescription_SemAtributo_RetornaNome() + { + Status.SemDescricao.GetDescription().Should().Be("SemDescricao"); + } + + [Fact] + public void GetDescription_PorTipoENome_RetornaDescricao() + { + EnumHelper.GetDescription(typeof(Status), nameof(Status.Concluido)).Should().Be("Concluído"); + } + + [Fact] + public void GetValueFromDescription_RetornaValorDoEnum() + { + EnumHelper.GetValueFromDescription("Em andamento").Should().Be(Status.EmAndamento); + EnumHelper.GetValueFromDescription("SemDescricao").Should().Be(Status.SemDescricao); + } + + [Fact] + public void GetValueFromDescription_DescricaoInexistente_RetornaDefault() + { + EnumHelper.GetValueFromDescription("Não existe").Should().Be(default(Status)); + } + + [Fact] + public void GetValueFromDescription_TipoNaoEnum_LancaInvalidOperationException() + { + var act = () => EnumHelper.GetValueFromDescription("x"); + act.Should().Throw(); + } + + [Fact] + public void GetLocalizedName_ComDisplayAttribute_RetornaNome() + { + Idioma.Portugues.GetLocalizedName().Should().Be("Português"); + Idioma.Ingles.GetLocalizedName().Should().Be("Ingles"); + } + + [Fact] + public void GetDicionary_RetornaParesValorDescricao() + { + var entries = EnumHelper.GetDicionary(typeof(Status)); + entries.Should().HaveCount(3); + entries.Select(e => e.Value).Should().Contain("Em andamento"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Helpers/InflectorTests.cs b/tests/Codout.Framework.Common.Tests/Helpers/InflectorTests.cs new file mode 100644 index 0000000..01af234 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Helpers/InflectorTests.cs @@ -0,0 +1,95 @@ +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Helpers; + +public class InflectorTests +{ + [Theory] + [InlineData("casa", "casas")] + [InlineData("cão", "cães")] + [InlineData("alemão", "alemães")] + [InlineData("irmão", "irmãos")] + [InlineData("papel", "papéis")] + [InlineData("animal", "animais")] + public void MakePlural_PluralizaPortugues(string singular, string plural) + { + singular.MakePlural().Should().Be(plural); + } + + [Theory] + [InlineData("casas", "casa")] + [InlineData("cães", "cão")] + [InlineData("animais", "animal")] + public void MakeSingular_SingularizaPortugues(string plural, string singular) + { + plural.MakeSingular().Should().Be(singular); + } + + [Fact] + public void MakePlural_PalavraIncontavel_NaoAltera() + { + "fénix".MakePlural().Should().Be("fénix"); + } + + [Theory] + [InlineData("MyTestString", "my_test_string")] + [InlineData("my test", "my_test")] + public void AddUnderscores_ConverteParaSnakeCase(string input, string expected) + { + input.AddUnderscores().Should().Be(expected); + } + + [Fact] + public void ToPascalCase_ConverteUnderscores() + { + "minha_propriedade_teste".ToPascalCase().Should().Be("MinhaPropriedadeTeste"); + } + + [Fact] + public void ToCamelCase_ConverteUnderscores() + { + "minha_propriedade".ToCamelCase().Should().Be("minhaPropriedade"); + } + + [Fact] + public void ToHumanCase_ConverteParaTextoLegivel() + { + "meu_campo_teste".ToHumanCase().Should().Be("Meu campo teste"); + } + + [Fact] + public void ToTitleCase_CapitalizaCadaPalavra() + { + "meu_campo_teste".ToTitleCase().Should().Be("Meu Campo Teste"); + } + + [Fact] + public void MakeInitialCapsELowerCase_AlteramPrimeiraLetra() + { + "teste ABC".MakeInitialCaps().Should().Be("Teste abc"); + "Teste".MakeInitialLowerCase().Should().Be("teste"); + } + + [Theory] + [InlineData("1", "1st")] + [InlineData("2", "2nd")] + [InlineData("3", "3rd")] + [InlineData("4", "4th")] + [InlineData("11", "11th")] + [InlineData("12", "12th")] + [InlineData("13", "13th")] + [InlineData("21", "21st")] + [InlineData("abc", "abc")] + public void AddOrdinalSuffix_AdicionaSufixoIngles(string input, string expected) + { + input.AddOrdinalSuffix().Should().Be(expected); + } + + [Fact] + public void ConvertUnderscoresToDashes_SubstituiUnderscores() + { + "a_b_c".ConvertUnderscoresToDashes().Should().Be("a-b-c"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Helpers/LimitedListTests.cs b/tests/Codout.Framework.Common.Tests/Helpers/LimitedListTests.cs new file mode 100644 index 0000000..9111d86 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Helpers/LimitedListTests.cs @@ -0,0 +1,70 @@ +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Helpers; + +public class LimitedListTests +{ + [Fact] + public void Add_AbaixoDoLimite_IncrementaCount() + { + var list = new LimitedList(3) { 1, 2 }; + list.Count.Should().Be(2); + list[0].Should().Be(1); + list[1].Should().Be(2); + } + + [Fact] + public void Add_AlemDoLimite_DescartaOMaisAntigo() + { + var list = new LimitedList(3) { 1, 2, 3, 4 }; + + list.Count.Should().Be(3); + list[0].Should().Be(2); + list[1].Should().Be(3); + list[2].Should().Be(4); + } + + [Fact] + public void Contains_EncontraItens() + { + var list = new LimitedList(2) { "a", "b" }; + list.Contains("a").Should().BeTrue(); + list.Contains("z").Should().BeFalse(); + } + + [Fact] + public void Clear_ZeraContagem() + { + var list = new LimitedList(2) { 1, 2 }; + list.Clear(); + list.Count.Should().Be(0); + list.Contains(1).Should().BeFalse(); + } + + [Fact] + public void Indexer_ForaDoIntervalo_LancaExcecao() + { + var list = new LimitedList(2); + var act = () => list[5]; + act.Should().Throw(); + } + + [Fact] + public void ToArray_RetornaTodosOsSlots() + { + var list = new LimitedList(3) { 1, 2, 3 }; + list.ToArray().Should().Equal(1, 2, 3); + } + + [Fact] + public void Enumeracao_PercorreOsSlots() + { + var list = new LimitedList(3) { 7, 8, 9 }; + var items = new List(); + foreach (int item in list) + items.Add(item); + items.Should().Equal(7, 8, 9); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Helpers/NumberToTextTests.cs b/tests/Codout.Framework.Common.Tests/Helpers/NumberToTextTests.cs new file mode 100644 index 0000000..319b76a --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Helpers/NumberToTextTests.cs @@ -0,0 +1,46 @@ +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Helpers; + +public class NumberToTextTests +{ + [Theory] + [InlineData("1", "hum real")] + [InlineData("2", "dois reais")] + [InlineData("2.50", "dois reais e cinqüenta centavos")] + [InlineData("0.01", "um centavo")] + [InlineData("0.25", "vinte e cinco centavos")] + [InlineData("100", "cem reais")] + [InlineData("101", "cento e um reais")] + [InlineData("1000", "hum mil reais")] + public void ToString_ConverteValorPorExtenso(string valor, string expected) + { + var n = new NumberToText(decimal.Parse(valor, System.Globalization.CultureInfo.InvariantCulture)); + n.ToString().Should().Be(expected); + } + + [Fact] + public void ToString_Zero_RetornaVazio() + { + new NumberToText(0m).ToString().Should().BeEmpty(); + } + + [Fact] + public void SetNumero_PermiteReuso() + { + var n = new NumberToText(); + n.SetNumero(2m); + n.ToString().Should().Be("dois reais"); + + n.SetNumero(3m); + n.ToString().Should().Be("três reais"); + } + + [Fact] + public void ToString_ArredondaParaDuasCasas() + { + new NumberToText(1.999m).ToString().Should().Be("dois reais"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Helpers/SlugHelperTests.cs b/tests/Codout.Framework.Common.Tests/Helpers/SlugHelperTests.cs new file mode 100644 index 0000000..824af0b --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Helpers/SlugHelperTests.cs @@ -0,0 +1,75 @@ +using Codout.Framework.Common.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Helpers; + +public class SlugHelperTests +{ + [Theory] + [InlineData("Olá Mundo!", "ola-mundo")] + [InlineData("Ação & Reação", "acao-reacao")] + [InlineData("hello world", "hello-world")] + [InlineData("UPPER Case", "upper-case")] + public void ToSlug_GeraSlugLimpo(string input, string expected) + { + input.ToSlug().Should().Be(expected); + } + + [Fact] + public void ToSlug_ColapsaEspacosEHifens() + { + " muitos espaços --- e hifens ".ToSlug().Should().Be("muitos-espacos-e-hifens"); + } + + [Fact] + public void ToSlug_EntradaVaziaOuWhitespace_LancaArgumentException() + { + var actEmpty = () => "".ToSlug(); + var actSpace = () => " ".ToSlug(); + actEmpty.Should().Throw(); + actSpace.Should().Throw(); + } + + [Fact] + public void ToSlug_ResultadoVazio_UsaFallbackComHash() + { + // Apenas caracteres não permitidos resultam em slug vazio → fallback "item-{hash}" + "!!!@@@###".ToSlug().Should().StartWith("item-"); + } + + [Fact] + public void ToSlug_ComAllowEmptySlug_RetornaVazio() + { + var config = new SlugConfig { AllowEmptySlug = true }; + "!!!".ToSlug(config).Should().BeEmpty(); + } + + [Fact] + public void ToSlug_ComMaxLength_TruncaSemHifenFinal() + { + var config = new SlugConfig { MaxLength = 10 }; + var slug = "uma frase bem longa para truncar".ToSlug(config); + slug.Length.Should().BeLessThanOrEqualTo(10); + slug.Should().NotEndWith("-"); + } + + [Fact] + public void ToSlug_ConfigStrict_SubstituiPontosEUnderscores() + { + "arquivo.nome_teste".ToSlug(SlugConfig.Strict).Should().Be("arquivo-nome-teste"); + } + + [Fact] + public void ToSlug_ConfigExtended_PreservaMaiusculas() + { + "Hello World".ToSlug(SlugConfig.Extended).Should().Be("Hello-World"); + } + + [Fact] + public void SlugHelper_ConfigNula_LancaArgumentNullException() + { + var act = () => new SlugHelper(null!); + act.Should().Throw(); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Security/CryptoStringTests.cs b/tests/Codout.Framework.Common.Tests/Security/CryptoStringTests.cs new file mode 100644 index 0000000..7e7e31a --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Security/CryptoStringTests.cs @@ -0,0 +1,105 @@ +using System.Security.Cryptography; +using Codout.Framework.Common.Security; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Security; + +public class CryptoStringTests +{ + // Senha que atende aos critérios de "senha forte" (>= 12 chars, maiúscula, + // minúscula, dígito e caractere especial) + private const string StrongPassword = "S3nh@F0rte!2026"; + + [Fact] + public void EncryptDecrypt_Roundtrip_RecuperaTextoOriginal() + { + const string texto = "informação confidencial: çãé€"; + + var encrypted = CryptoString.Encrypt(texto, StrongPassword); + var decrypted = CryptoString.Decrypt(encrypted, StrongPassword); + + decrypted.Should().Be(texto); + } + + [Fact] + public void Encrypt_MesmoTexto_GeraCiphertextsDiferentes() + { + var c1 = CryptoString.Encrypt("texto", StrongPassword); + var c2 = CryptoString.Encrypt("texto", StrongPassword); + c1.Should().NotBe(c2); // salt e nonce aleatórios + } + + [Fact] + public void Decrypt_SenhaErrada_LancaCryptographicException() + { + var encrypted = CryptoString.Encrypt("texto", StrongPassword); + var act = () => CryptoString.Decrypt(encrypted, "0utr@Senh4!Forte"); + act.Should().Throw(); + } + + [Fact] + public void Decrypt_CiphertextCorrompido_LancaExcecao() + { + var encrypted = CryptoString.Encrypt("texto", StrongPassword); + var bytes = Convert.FromBase64String(encrypted); + bytes[^1] ^= 0xFF; // corrompe o último byte + var corrompido = Convert.ToBase64String(bytes); + + var act = () => CryptoString.Decrypt(corrompido, StrongPassword); + act.Should().Throw(); + } + + [Fact] + public void Decrypt_Base64Invalido_LancaArgumentException() + { + var act = () => CryptoString.Decrypt("@@não-base64@@", StrongPassword); + act.Should().Throw(); + } + + [Fact] + public void Encrypt_SenhaFraca_ComOpcoesPadrao_LancaArgumentException() + { + var act = () => CryptoString.Encrypt("texto", "fraca"); + act.Should().Throw(); + } + + [Fact] + public void Encrypt_SenhaFraca_ComOpcoesLegacy_Funciona() + { + var encrypted = CryptoString.Encrypt("texto", "abc123", CryptoOptions.Legacy); + CryptoString.Decrypt(encrypted, "abc123", CryptoOptions.Legacy).Should().Be("texto"); + } + + [Fact] + public void Encrypt_TextoVazio_LancaArgumentException() + { + var act = () => CryptoString.Encrypt("", StrongPassword); + act.Should().Throw(); + } + + [Fact] + public void ExtensionMethods_EncryptDecrypt_Funcionam() + { + var encrypted = "segredo".Encrypt(StrongPassword); + encrypted.Decrypt(StrongPassword).Should().Be("segredo"); + } + + [Fact] + public void Decrypt_ToleraEspacosNoLugarDeMais() + { + // O Decrypt normaliza ' ' para '+' (caso comum em querystrings) + var encrypted = CryptoString.Encrypt("texto qualquer", StrongPassword); + var comEspacos = encrypted.Replace('+', ' '); + CryptoString.Decrypt(comEspacos, StrongPassword).Should().Be("texto qualquer"); + } + + [Fact] + public void SecureBuffer_AposDispose_LancaObjectDisposedException() + { + var buffer = SecureMemory.Allocate(16); + buffer.Dispose(); + var act = () => { _ = buffer.Span.Length; }; + act.Should().Throw(); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Security/CryptoTests.cs b/tests/Codout.Framework.Common.Tests/Security/CryptoTests.cs new file mode 100644 index 0000000..fcf28e0 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Security/CryptoTests.cs @@ -0,0 +1,44 @@ +using Codout.Framework.Common.Security; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Security; + +public class CryptoTests +{ +#pragma warning disable CS0618 // Métodos legados marcados como Obsolete + [Fact] + public void Md5Encrypt_RetornaHashConhecido() + { + // Vetor de teste padrão do MD5 para "abc" + Crypto.Md5Encrypt("abc").Should().Be("900150983CD24FB0D6963F7D28E17F72"); + } + + [Fact] + public void Sha1Encrypt_RetornaHashConhecido() + { + Crypto.Sha1Encrypt("abc").Should().Be("A9993E364706816ABA3E25717850C26C9CD0D89D"); + } + + [Fact] + public void Sha256Encrypt_RetornaHashConhecido() + { + Crypto.Sha256Encrypt("abc") + .Should().Be("BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"); + } + + [Fact] + public void Sha512Encrypt_RetornaHashCom128CaracteresHex() + { + var hash = Crypto.Sha512Encrypt("abc"); + hash.Should().HaveLength(128); + hash.Should().MatchRegex("^[0-9A-F]+$"); + } +#pragma warning restore CS0618 + + [Fact] + public void ByteArrayToString_ConverteParaHexMaiusculo() + { + Crypto.ByteArrayToString(new byte[] { 0x00, 0xAB, 0xFF }).Should().Be("00ABFF"); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Security/RandomPasswordTests.cs b/tests/Codout.Framework.Common.Tests/Security/RandomPasswordTests.cs new file mode 100644 index 0000000..efc18b8 --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Security/RandomPasswordTests.cs @@ -0,0 +1,68 @@ +using Codout.Framework.Common.Security; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Security; + +public class RandomPasswordTests +{ + private const string AllowedChars = + "abcdefgijkmnopqrstwxyz" + + "ABCDEFGHJKLMNPQRSTWXYZ" + + "23456789" + + "*$-+?_&=!%{}/"; + + [Fact] + public void Generate_SemParametros_RespeitaTamanhoPadrao() + { + for (var i = 0; i < 20; i++) + { + var password = RandomPassword.Generate(); + password.Length.Should().BeInRange(8, 10); + } + } + + [Fact] + public void Generate_ComTamanhoExato_RetornaTamanhoSolicitado() + { + RandomPassword.Generate(12).Should().HaveLength(12); + RandomPassword.Generate(4).Should().HaveLength(4); + } + + [Fact] + public void Generate_ComIntervalo_RespeitaLimites() + { + for (var i = 0; i < 20; i++) + { + var password = RandomPassword.Generate(6, 9); + password.Length.Should().BeInRange(6, 9); + } + } + + [Fact] + public void Generate_UsaSomenteCaracteresPermitidos() + { + var password = RandomPassword.Generate(50); + password.Should().NotBeNull(); + foreach (var c in password!) + AllowedChars.Should().Contain(c.ToString()); + } + + [Theory] + [InlineData(0, 5)] + [InlineData(5, 0)] + [InlineData(-1, 5)] + [InlineData(9, 5)] // min > max + public void Generate_ParametrosInvalidos_RetornaNull(int min, int max) + { + RandomPassword.Generate(min, max).Should().BeNull(); + } + + [Fact] + public void Generate_GeraSenhasDiferentes() + { + var p1 = RandomPassword.Generate(20); + var p2 = RandomPassword.Generate(20); + p1.Should().NotBe(p2); + } +} diff --git a/tests/Codout.Framework.Common.Tests/Security/SecureHashTests.cs b/tests/Codout.Framework.Common.Tests/Security/SecureHashTests.cs new file mode 100644 index 0000000..bc6a45b --- /dev/null +++ b/tests/Codout.Framework.Common.Tests/Security/SecureHashTests.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text; +using Codout.Framework.Common.Security; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Common.Tests.Security; + +public class SecureHashTests +{ + [Theory] + [InlineData(SecureHashAlgorithm.Sha256)] + [InlineData(SecureHashAlgorithm.Sha384)] + [InlineData(SecureHashAlgorithm.Sha512)] + public void ComputeHash_VerifyHash_RoundtripValido(SecureHashAlgorithm algorithm) + { + const string texto = "senha-super-secreta"; + + var hash = SecureHash.ComputeHash(texto, algorithm); + + SecureHash.VerifyHash(texto, hash, algorithm).Should().BeTrue(); + SecureHash.VerifyHash("senha-errada", hash, algorithm).Should().BeFalse(); + } + + [Fact] + public void ComputeHash_MesmoTexto_GeraHashesDiferentes() + { + // Salt aleatório a cada chamada + var h1 = SecureHash.ComputeHash("texto"); + var h2 = SecureHash.ComputeHash("texto"); + h1.Should().NotBe(h2); + } + + [Fact] + public void ComputeHash_RetornaBase64Valido() + { + var hash = SecureHash.ComputeHash("texto"); + var act = () => Convert.FromBase64String(hash); + act.Should().NotThrow(); + } + + [Fact] + public void ComputeHash_TextoVazio_LancaArgumentException() + { + var act = () => SecureHash.ComputeHash(""); + act.Should().Throw(); + } + + [Fact] + public void ComputeHash_SaltMuitoPequeno_LancaArgumentException() + { + var salt = new byte[8]; // mínimo é 16 + var act = () => SecureHash.ComputeHash("texto", SecureHashAlgorithm.Sha256, salt); + act.Should().Throw(); + } + + [Fact] + public void ComputeHash_ComSaltCustomizado_EhVerificavel() + { + var salt = new byte[32]; + RandomNumberGenerator.Fill(salt); + + var hash = SecureHash.ComputeHash("texto", SecureHashAlgorithm.Sha256, salt); + SecureHash.VerifyHash("texto", hash).Should().BeTrue(); + } + + [Fact] + public void ComputeHash_OpcoesInvalidas_LancaArgumentException() + { + var act = () => SecureHash.ComputeHash("texto", SecureHashAlgorithm.Sha256, + new HashOptions { SaltSize = 8 }); + act.Should().Throw(); + } + + [Fact] + public void VerifyHash_HashInvalido_RetornaFalse() + { + SecureHash.VerifyHash("texto", "isto não é base64!!!").Should().BeFalse(); + SecureHash.VerifyHash("texto", Convert.ToBase64String(new byte[4])).Should().BeFalse(); + } + + [Fact] + public async Task ComputeStreamHashAsync_RetornaSha256Conhecido() + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("abc")); + var hash = await SecureHash.ComputeStreamHashAsync(stream); + hash.Should().Be("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } + + [Fact] + public async Task ComputeFileHashAsync_ArquivoInexistente_LancaFileNotFound() + { + var act = () => SecureHash.ComputeFileHashAsync("/caminho/inexistente.bin"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task VerifyFileIntegrityAsync_ArquivoIntegro_RetornaTrue() + { + var path = Path.Combine(Path.GetTempPath(), $"securehash-{Guid.NewGuid():N}.txt"); + try + { + await File.WriteAllTextAsync(path, "conteúdo de teste"); + var hash = await SecureHash.ComputeFileHashAsync(path); + + (await SecureHash.VerifyFileIntegrityAsync(path, hash)).Should().BeTrue(); + (await SecureHash.VerifyFileIntegrityAsync(path, new string('0', 64))).Should().BeFalse(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ExtensionMethods_ToSecureHashEVerifyAgainstHash_Funcionam() + { + const string texto = "minha-senha"; + var hash = texto.ToSecureHash(); + texto.VerifyAgainstHash(hash).Should().BeTrue(); + "outra".VerifyAgainstHash(hash).Should().BeFalse(); + } + + [Fact] + public void GetSupportedAlgorithms_IncluiSha256() + { + SecureHash.GetSupportedAlgorithms().Should().Contain(SecureHashAlgorithm.Sha256); + } +} diff --git a/tests/Codout.Framework.Data.Tests/Codout.Framework.Data.Tests.csproj b/tests/Codout.Framework.Data.Tests/Codout.Framework.Data.Tests.csproj new file mode 100644 index 0000000..b7a7ca8 --- /dev/null +++ b/tests/Codout.Framework.Data.Tests/Codout.Framework.Data.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Data.Tests/EntityAbstractionsTests.cs b/tests/Codout.Framework.Data.Tests/EntityAbstractionsTests.cs new file mode 100644 index 0000000..d0317d6 --- /dev/null +++ b/tests/Codout.Framework.Data.Tests/EntityAbstractionsTests.cs @@ -0,0 +1,93 @@ +using Codout.Framework.Data.Auditing; +using Codout.Framework.Data.Entity; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Data.Tests; + +/// +/// Abstrações de entidade e auditoria: variância, marker interfaces e visibilidade. +/// +public class EntityAbstractionsTests +{ + [Fact] + public void IEntity_of_TId_is_covariant_so_specialized_ids_upcast() + { + var entity = new StringIdEntity("abc"); + + // Compila apenas porque IEntity é covariante. + IEntity upcast = entity; + + upcast.Id.Should().Be("abc"); + } + + [Fact] + public void IEntity_of_TId_extends_IEntity() + { + typeof(IEntity).Should().Implement(); + } + + [Fact] + public void IClientGeneratedId_is_a_pure_marker_interface() + { + typeof(IClientGeneratedId).GetMembers().Should().BeEmpty( + "o marker existe apenas para opt-in da ClientGeneratedIdConvention"); + typeof(IClientGeneratedId).IsInterface.Should().BeTrue(); + } + + [Fact] + public void IHasAssignedId_is_internal_to_the_assembly() + { + var type = typeof(IEntity).Assembly.GetType("Codout.Framework.Data.Entity.IHasAssignedId`1"); + + type.Should().NotBeNull(); + type!.IsPublic.Should().BeFalse("é detalhe interno, não faz parte da API pública"); + } + + [Fact] + public void IAuditable_contract_round_trips_audit_data() + { + IAuditable auditable = new AuditableStub(); + var now = DateTime.UtcNow; + + auditable.CreatedAt = now; + auditable.CreatedBy = "ana"; + auditable.UpdatedAt = now.AddMinutes(1); + auditable.UpdatedBy = "bia"; + + auditable.CreatedAt.Should().Be(now); + auditable.CreatedBy.Should().Be("ana"); + auditable.UpdatedAt.Should().Be(now.AddMinutes(1)); + auditable.UpdatedBy.Should().Be("bia"); + } + + [Fact] + public void ISoftDeletable_contract_round_trips_deletion_data() + { + ISoftDeletable deletable = new SoftDeletableStub(); + var now = DateTime.UtcNow; + + deletable.IsDeleted = true; + deletable.DeletedAt = now; + deletable.DeletedBy = "ana"; + + deletable.IsDeleted.Should().BeTrue(); + deletable.DeletedAt.Should().Be(now); + deletable.DeletedBy.Should().Be("ana"); + } + + private class AuditableStub : IAuditable + { + public DateTime CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + } + + private class SoftDeletableStub : ISoftDeletable + { + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } + public string? DeletedBy { get; set; } + } +} diff --git a/tests/Codout.Framework.Data.Tests/RepositoryContractTests.cs b/tests/Codout.Framework.Data.Tests/RepositoryContractTests.cs new file mode 100644 index 0000000..dd20811 --- /dev/null +++ b/tests/Codout.Framework.Data.Tests/RepositoryContractTests.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using Codout.Framework.Data.Repository; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Data.Tests; + +/// +/// Codout.Framework.Data é composto por abstrações puras (sem lógica concreta). +/// Estes testes travam o CONTRATO compilado no assembly — defaults de parâmetros, +/// overloads de CancellationToken e disposability — para que mudanças quebradoras +/// na interface sejam detectadas antes de quebrar as implementações (EF, NH, Mongo). +/// +public class RepositoryContractTests +{ + private static readonly Type Contract = typeof(IRepository); + + [Fact] + public void IRepository_is_disposable() + { + Contract.Should().Implement(); + } + + [Fact] + public void WherePaged_has_default_index_0_and_size_50() + { + var method = Contract.GetMethod("WherePaged")!; + var parameters = method.GetParameters(); + + parameters.Should().HaveCount(4); + parameters[1].IsOut.Should().BeTrue("total é retornado por out"); + + parameters[2].Name.Should().Be("index"); + parameters[2].HasDefaultValue.Should().BeTrue(); + parameters[2].DefaultValue.Should().Be(0); + + parameters[3].Name.Should().Be("size"); + parameters[3].HasDefaultValue.Should().BeTrue(); + parameters[3].DefaultValue.Should().Be(50, "tamanho de página default do contrato"); + } + + [Theory] + [InlineData("GetAsync")] + [InlineData("LoadAsync")] + [InlineData("DeleteAsync")] + [InlineData("SaveAsync")] + [InlineData("SaveOrUpdateAsync")] + [InlineData("UpdateAsync")] + [InlineData("MergeAsync")] + [InlineData("RefreshAsync")] + public void Every_async_command_has_a_CancellationToken_overload(string methodName) + { + var overloads = Contract.GetMethods().Where(m => m.Name == methodName).ToList(); + + overloads.Should().NotBeEmpty(); + overloads.Should().Contain( + m => m.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken)), + $"{methodName} deve ter overload com CancellationToken"); + } + + [Theory] + [InlineData("FirstOrDefaultAsync")] + [InlineData("AnyAsync")] + [InlineData("CountAsync")] + [InlineData("ToListAsync")] + public void Async_query_helpers_take_an_optional_CancellationToken(string methodName) + { + var method = Contract.GetMethod(methodName)!; + var last = method.GetParameters().Last(); + + last.ParameterType.Should().Be(typeof(CancellationToken)); + last.IsOptional.Should().BeTrue("o token é opcional (default) nesses helpers"); + } + + [Fact] + public void Synchronous_and_asynchronous_query_surface_is_complete() + { + var methodNames = Contract.GetMethods().Select(m => m.Name).Distinct(); + + methodNames.Should().Contain( + [ + "All", "AllReadOnly", "Where", "WhereReadOnly", "WherePaged", + "Get", "Load", "Delete", "Save", "SaveOrUpdate", "Update", + "Merge", "Refresh", "IncludeMany" + ]); + } + + [Fact] + public void Type_constraint_requires_class_implementing_IEntity() + { + var t = typeof(IRepository<>).GetGenericArguments()[0]; + + t.GenericParameterAttributes + .HasFlag(GenericParameterAttributes.ReferenceTypeConstraint) + .Should().BeTrue(); + t.GetGenericParameterConstraints() + .Should().Contain(typeof(Codout.Framework.Data.Entity.IEntity)); + } +} diff --git a/tests/Codout.Framework.Data.Tests/TestEntities.cs b/tests/Codout.Framework.Data.Tests/TestEntities.cs new file mode 100644 index 0000000..6cc7183 --- /dev/null +++ b/tests/Codout.Framework.Data.Tests/TestEntities.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using Codout.Framework.Data.Entity; + +namespace Codout.Framework.Data.Tests; + +/// +/// Implementações mínimas das abstrações de entidade, usadas pelos testes de contrato. +/// +internal class FakeEntity : IEntity +{ + public IEnumerable GetSignatureProperties() => []; + + public bool IsTransient() => true; +} + +internal class StringIdEntity : IEntity +{ + public StringIdEntity(string id) => Id = id; + + public string Id { get; } + + public IEnumerable GetSignatureProperties() => []; + + public bool IsTransient() => false; +} diff --git a/tests/Codout.Framework.Data.Tests/UnitOfWorkContractTests.cs b/tests/Codout.Framework.Data.Tests/UnitOfWorkContractTests.cs new file mode 100644 index 0000000..e231abe --- /dev/null +++ b/tests/Codout.Framework.Data.Tests/UnitOfWorkContractTests.cs @@ -0,0 +1,67 @@ +using System.Data; +using Codout.Framework.Data; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Data.Tests; + +/// +/// Contrato do IUnitOfWork e do (obsoleto) IUnitOfWorkProvider. +/// +public class UnitOfWorkContractTests +{ + [Fact] + public void IUnitOfWork_is_disposable_and_async_disposable() + { + typeof(IUnitOfWork).Should().Implement(); + typeof(IUnitOfWork).Should().Implement(); + } + + [Fact] + public void Commit_and_BeginTransaction_have_IsolationLevel_overloads() + { + typeof(IUnitOfWork).GetMethod("Commit", [typeof(IsolationLevel)]).Should().NotBeNull(); + typeof(IUnitOfWork).GetMethod("BeginTransaction", [typeof(IsolationLevel)]).Should().NotBeNull(); + typeof(IUnitOfWork).GetMethod("BeginTransactionAsync", + [typeof(IsolationLevel), typeof(CancellationToken)]).Should().NotBeNull(); + } + + [Theory] + [InlineData("CommitAsync")] + [InlineData("RollbackAsync")] + public void Async_transaction_methods_take_an_optional_CancellationToken(string methodName) + { + var method = typeof(IUnitOfWork).GetMethod(methodName, [typeof(CancellationToken)])!; + + method.GetParameters().Single().IsOptional.Should().BeTrue(); + } + + [Fact] + public void InTransaction_helpers_are_part_of_the_contract() + { + typeof(IUnitOfWork).GetMethods().Select(m => m.Name) + .Should().Contain(["InTransaction", "InTransactionAsync"]); + } + + [Fact] + public void IUnitOfWorkProvider_is_marked_obsolete_but_still_compiles() + { + var attribute = typeof(IUnitOfWorkProvider<>) + .GetCustomAttributes(typeof(ObsoleteAttribute), false) + .Cast() + .Single(); + + attribute.IsError.Should().BeFalse("ainda é warning, não erro — remoção prevista para versão futura"); + attribute.Message.Should().Contain("dependency injection"); + } + + [Fact] + public void IUnitOfWorkProvider_type_parameter_is_covariant() + { + var t = typeof(IUnitOfWorkProvider<>).GetGenericArguments()[0]; + + t.GenericParameterAttributes + .HasFlag(System.Reflection.GenericParameterAttributes.Covariant) + .Should().BeTrue("declarado como "); + } +} diff --git a/tests/Codout.Framework.Domain.Tests/AuditAndEventsTests.cs b/tests/Codout.Framework.Domain.Tests/AuditAndEventsTests.cs new file mode 100644 index 0000000..10dfb12 --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/AuditAndEventsTests.cs @@ -0,0 +1,78 @@ +using Codout.Framework.Domain.Entities; +using Codout.Framework.Domain.Entities.Events; +using Codout.Framework.Domain.Interfaces; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Domain.Tests; + +/// +/// Bases de auditoria (AuditEntity / AuditEntityBase) e eventos de entidade +/// (EntityCreated / EntityChanged / EntityDeleted). +/// +public class AuditAndEventsTests +{ + private class AuditedOrder : AuditEntity + { + public string? Number { get; set; } + } + + private class AuditedCustomer : AuditEntityBase + { + public string? Name { get; set; } + } + + [Fact] + public void AuditEntity_exposes_audit_properties_and_implements_IAudit() + { + var createdAt = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var updatedAt = createdAt.AddDays(1); + + var order = new AuditedOrder + { + Number = "PED-1", + CreatedAt = createdAt, + CreatedBy = "ana", + UpdatedAt = updatedAt, + UpdatedBy = "bia" + }; + + order.Should().BeAssignableTo(); + order.Should().BeAssignableTo>(); + order.CreatedAt.Should().Be(createdAt); + order.CreatedBy.Should().Be("ana"); + order.UpdatedAt.Should().Be(updatedAt); + order.UpdatedBy.Should().Be("bia"); + } + + [Fact] + public void AuditEntity_audit_fields_start_unset() + { + var order = new AuditedOrder(); + + order.CreatedAt.Should().BeNull(); + order.UpdatedAt.Should().BeNull(); + order.CreatedBy.Should().BeNull(); + order.UpdatedBy.Should().BeNull(); + } + + [Fact] + public void AuditEntityBase_is_a_nullable_guid_entity_with_audit() + { + var customer = new AuditedCustomer { Name = "Ana" }; + + customer.Should().BeAssignableTo(); + customer.Should().BeAssignableTo(); + customer.Id.Should().BeNull("AuditEntityBase é store-generated"); + } + + [Fact] + public void Entity_events_carry_the_entity() + { + var order = new AuditedOrder { Number = "PED-1" }; + + new EntityCreated(order).Entity.Should().BeSameAs(order); + new EntityChanged(order).Entity.Should().BeSameAs(order); + new EntityDeleted(order).Entity.Should().BeSameAs(order); + } +} diff --git a/tests/Codout.Framework.Domain.Tests/Codout.Framework.Domain.Tests.csproj b/tests/Codout.Framework.Domain.Tests/Codout.Framework.Domain.Tests.csproj new file mode 100644 index 0000000..84da2ef --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/Codout.Framework.Domain.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Domain.Tests/EntityEqualityTests.cs b/tests/Codout.Framework.Domain.Tests/EntityEqualityTests.cs new file mode 100644 index 0000000..7a9cbbe --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/EntityEqualityTests.cs @@ -0,0 +1,181 @@ +using Codout.Framework.Domain.Base; +using Codout.Framework.Domain.Entities; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Domain.Tests; + +/// +/// Igualdade de Entity<TId>: identidade por Id quando persistido, assinatura de +/// domínio ([DomainSignature]) quando transient, e nunca igualdade entre tipos distintos. +/// +public class EntityEqualityTests +{ + private class Person : Entity + { + [DomainSignature] + public string? Name { get; set; } + + // Propriedade fora da assinatura: não deve influenciar a igualdade. + public int Age { get; set; } + } + + private class Company : Entity + { + [DomainSignature] + public string? Name { get; set; } + } + + // Entidade sem nenhuma propriedade [DomainSignature]. + private class Anonymous : Entity + { + public string? Tag { get; set; } + } + + [Fact] + public void Transient_entities_with_same_domain_signature_are_equal() + { + var a = new Person { Name = "Ana", Age = 30 }; + var b = new Person { Name = "Ana", Age = 99 }; + + a.Equals(b).Should().BeTrue("ambas transient com mesma [DomainSignature]; Age não faz parte da assinatura"); + } + + [Fact] + public void Transient_entities_with_different_domain_signature_are_not_equal() + { + var a = new Person { Name = "Ana" }; + var b = new Person { Name = "Bia" }; + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Persisted_entities_with_same_id_are_equal_even_with_different_signature() + { + var a = new Person { Name = "Ana" }; + var b = new Person { Name = "Bia" }; + a.SetId(7); + b.SetId(7); + + a.Equals(b).Should().BeTrue("identidade persistida (Id) prevalece sobre a assinatura"); + } + + [Fact] + public void Persisted_entities_with_different_ids_are_not_equal_even_with_same_signature() + { + var a = new Person { Name = "Ana" }; + var b = new Person { Name = "Ana" }; + a.SetId(1); + b.SetId(2); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Persisted_entity_is_not_equal_to_transient_with_same_signature() + { + var persisted = new Person { Name = "Ana" }; + persisted.SetId(1); + var transient = new Person { Name = "Ana" }; + + persisted.Equals(transient).Should().BeFalse("um persistido e um transient nunca são o mesmo objeto de domínio"); + transient.Equals(persisted).Should().BeFalse(); + } + + [Fact] + public void Entities_of_different_types_with_same_id_are_not_equal() + { + var person = new Person { Name = "Acme" }; + var company = new Company { Name = "Acme" }; + person.SetId(5); + company.SetId(5); + + person.Equals(company).Should().BeFalse("tipos CLR diferentes nunca são iguais"); + } + + [Fact] + public void Same_reference_is_always_equal() + { + var a = new Person { Name = "Ana" }; + a.Equals(a).Should().BeTrue(); + } + + [Fact] + public void Entity_is_never_equal_to_null_or_other_kind_of_object() + { + var a = new Person { Name = "Ana" }; + + a.Equals((object?)null).Should().BeFalse(); + a!.Equals((object)"Ana").Should().BeFalse(); + } + + [Fact] + public void Transient_entities_without_signature_properties_fall_back_to_reference_equality() + { + var a = new Anonymous { Tag = "x" }; + var b = new Anonymous { Tag = "x" }; + + a.Equals(b).Should().BeFalse("sem [DomainSignature] a comparação transient cai em igualdade de referência"); + a.Equals(a).Should().BeTrue(); + } + + [Fact] + public void Persisted_entities_with_same_id_have_same_hashcode() + { + var a = new Person { Name = "Ana" }; + var b = new Person { Name = "Bia" }; + a.SetId(7); + b.SetId(7); + + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void Transient_entities_with_same_signature_have_same_hashcode() + { + var a = new Person { Name = "Ana", Age = 1 }; + var b = new Person { Name = "Ana", Age = 2 }; + + a.GetHashCode().Should().Be(b.GetHashCode(), "hash transient deriva da assinatura de domínio"); + } + + [Fact] + public void Hashcode_is_cached_and_stable_across_persistence() + { + // Design (padrão S#arp Architecture): o hash é congelado no primeiro uso para que + // a entidade não "se perca" dentro de um HashSet/Dictionary quando ganhar Id. + var person = new Person { Name = "Ana" }; + var transientHash = person.GetHashCode(); + + person.SetId(42); + + person.GetHashCode().Should().Be(transientHash, "o hash é cacheado no primeiro GetHashCode()"); + + var freshWithSameId = new Person { Name = "Ana" }; + freshWithSameId.SetId(42); + freshWithSameId.GetHashCode().Should().NotBe(transientHash, + "uma instância nova com o mesmo Id calcula o hash por Id, diferente do hash transient congelado"); + } + + [Fact] + public void Entity_survives_hashset_membership_after_gaining_id() + { + var person = new Person { Name = "Ana" }; + var set = new HashSet { person }; + + person.SetId(99); + + set.Contains(person).Should().BeTrue("o cache de hash garante que a entidade continua localizável"); + } + + [Fact] + public void GetSignatureProperties_returns_only_domain_signature_properties() + { + var person = new Person { Name = "Ana", Age = 30 }; + + var properties = person.GetSignatureProperties().Select(p => p.Name); + + properties.Should().BeEquivalentTo(["Name"]); + } +} diff --git a/tests/Codout.Framework.Domain.Tests/EntityIdentityTests.cs b/tests/Codout.Framework.Domain.Tests/EntityIdentityTests.cs new file mode 100644 index 0000000..e69312f --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/EntityIdentityTests.cs @@ -0,0 +1,147 @@ +using Codout.Framework.Data.Entity; +using Codout.Framework.Domain.Entities; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Domain.Tests; + +/// +/// Identidade das entidades: semântica de IsTransient/SetId para diferentes tipos de Id +/// e a invariante de kernel da ClientGeneratedEntity (Id atribuído pela aplicação no ctor). +/// +public class EntityIdentityTests +{ + private class IntEntity : Entity; + + private class GuidEntity : Entity; + + private class NullableGuidEntity : Entity; + + private class StringEntity : Entity; + + private class Document : ClientGeneratedEntity + { + public string? Title { get; set; } + } + + private class ConcreteBase : EntityBase; + + [Fact] + public void Int_entity_is_transient_when_id_is_zero() + { + var entity = new IntEntity(); + entity.IsTransient().Should().BeTrue(); + + entity.SetId(1); + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void Guid_entity_is_transient_when_id_is_empty() + { + var entity = new GuidEntity(); + entity.IsTransient().Should().BeTrue("Guid.Empty é o default"); + + entity.SetId(Guid.NewGuid()); + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void Nullable_guid_entity_is_transient_when_id_is_null() + { + var entity = new NullableGuidEntity(); + entity.IsTransient().Should().BeTrue(); + + entity.SetId(Guid.NewGuid()); + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void String_entity_is_transient_when_id_is_null() + { + var entity = new StringEntity(); + entity.IsTransient().Should().BeTrue(); + + entity.SetId("abc"); + entity.IsTransient().Should().BeFalse(); + } + + [Fact] + public void SetId_assigns_the_id() + { + var entity = new IntEntity(); + entity.SetId(123); + entity.Id.Should().Be(123); + } + + [Fact] + public void EntityBase_uses_nullable_guid_id_and_starts_transient() + { + var entity = new ConcreteBase(); + + entity.Should().BeAssignableTo>(); + entity.Id.Should().BeNull(); + entity.IsTransient().Should().BeTrue("EntityBase é store-generated: nasce sem Id"); + } + + // ---- ClientGeneratedEntity: invariante de kernel (identity client-generated) ---- + + [Fact] + public void ClientGeneratedEntity_assigns_id_in_constructor() + { + var doc = new Document(); + + doc.Id.Should().NotBeNull("a aplicação atribui a PK na criação"); + doc.Id.Should().NotBe(Guid.Empty); + doc.IsTransient().Should().BeFalse("já nasce com identidade"); + } + + [Fact] + public void ClientGeneratedEntity_generates_unique_ids_per_instance() + { + var a = new Document(); + var b = new Document(); + + a.Id.Should().NotBe(b.Id!.Value); + } + + [Fact] + public void ClientGeneratedEntity_implements_the_IClientGeneratedId_marker() + { + new Document().Should().BeAssignableTo( + "o marker é o opt-in da ClientGeneratedIdConvention no EF"); + } + + [Fact] + public void ClientGeneratedEntity_allows_rehydration_to_override_the_ctor_id() + { + // O EF materializa via ctor sem-args e sobrescreve o Id em seguida (SetId/setter). + var doc = new Document(); + var persistedId = Guid.NewGuid(); + + doc.SetId(persistedId); + + doc.Id.Should().Be(persistedId); + } + + [Fact] + public void Two_client_generated_entities_are_not_equal_because_ids_differ() + { + var a = new Document { Title = "x" }; + var b = new Document { Title = "x" }; + + a.Equals(b).Should().BeFalse("ambas já nascem persist-ready com Ids distintos"); + } + + [Fact] + public void Client_generated_entities_with_same_id_are_equal() + { + var id = Guid.NewGuid(); + var a = new Document { Title = "x" }; + var b = new Document { Title = "y" }; + a.SetId(id); + b.SetId(id); + + a.Equals(b).Should().BeTrue(); + } +} diff --git a/tests/Codout.Framework.Domain.Tests/ValidatableObjectTests.cs b/tests/Codout.Framework.Domain.Tests/ValidatableObjectTests.cs new file mode 100644 index 0000000..8b18255 --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/ValidatableObjectTests.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Codout.Framework.Domain.Base; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Domain.Tests; + +/// +/// ValidatableObject: validação via DataAnnotations (Validator.TryValidateObject com +/// validateAllProperties habilitado). +/// +public class ValidatableObjectTests +{ + private class RegistrationForm : ValidatableObject + { + [Required] + public string? Name { get; set; } + + [Range(1, 10)] + public int Score { get; set; } = 5; + + [StringLength(5)] + public string? Nickname { get; set; } + + protected override IEnumerable GetTypeSpecificSignatureProperties() => []; + } + + [Fact] + public void Object_with_all_annotations_satisfied_is_valid() + { + var form = new RegistrationForm { Name = "Ana", Score = 10, Nickname = "an" }; + + form.IsValid().Should().BeTrue(); + form.ValidationResults().Should().BeEmpty(); + } + + [Fact] + public void Missing_required_property_makes_object_invalid() + { + var form = new RegistrationForm { Name = null }; + + form.IsValid().Should().BeFalse(); + form.ValidationResults().Should().ContainSingle() + .Which.MemberNames.Should().Contain("Name"); + } + + [Fact] + public void Out_of_range_property_is_reported() + { + var form = new RegistrationForm { Name = "Ana", Score = 11 }; + + form.IsValid().Should().BeFalse("validateAllProperties=true valida além de [Required]"); + form.ValidationResults().Should().ContainSingle() + .Which.MemberNames.Should().Contain("Score"); + } + + [Fact] + public void Multiple_violations_are_all_reported() + { + var form = new RegistrationForm { Name = null, Score = 0, Nickname = "muito-longo" }; + + var results = form.ValidationResults(); + + results.Should().HaveCount(3); + results.SelectMany(r => r.MemberNames) + .Should().BeEquivalentTo(["Name", "Score", "Nickname"]); + } +} diff --git a/tests/Codout.Framework.Domain.Tests/ValueObjectTests.cs b/tests/Codout.Framework.Domain.Tests/ValueObjectTests.cs new file mode 100644 index 0000000..e16275b --- /dev/null +++ b/tests/Codout.Framework.Domain.Tests/ValueObjectTests.cs @@ -0,0 +1,129 @@ +using Codout.Framework.Domain.Base; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Domain.Tests; + +/// +/// Value objects: igualdade estrutural por TODAS as propriedades públicas, operadores +/// ==/!= null-safe e a proibição de [DomainSignature] em value objects. +/// +public class ValueObjectTests +{ + private class Money : ValueObject + { + public Money(string currency, decimal amount) + { + Currency = currency; + Amount = amount; + } + + public string Currency { get; } + public decimal Amount { get; } + } + + private class Weight : ValueObject + { + public Weight(decimal amount) + { + Amount = amount; + } + + public decimal Amount { get; } + } + + private class BadValueObject : ValueObject + { + [DomainSignature] + public string? Code { get; set; } + } + + [Fact] + public void Value_objects_with_same_property_values_are_equal() + { + var a = new Money("BRL", 10.50m); + var b = new Money("BRL", 10.50m); + + a.Equals(b).Should().BeTrue(); + (a == b).Should().BeTrue(); + (a != b).Should().BeFalse(); + } + + [Fact] + public void Value_objects_with_different_property_values_are_not_equal() + { + var a = new Money("BRL", 10.50m); + var b = new Money("USD", 10.50m); + var c = new Money("BRL", 9.99m); + + a.Equals(b).Should().BeFalse(); + a.Equals(c).Should().BeFalse(); + (a != b).Should().BeTrue(); + } + + [Fact] + public void Equal_value_objects_have_same_hashcode() + { + var a = new Money("BRL", 10.50m); + var b = new Money("BRL", 10.50m); + + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void Different_value_objects_have_different_hashcodes() + { + var a = new Money("BRL", 10.50m); + var b = new Money("USD", 99.99m); + + a.GetHashCode().Should().NotBe(b.GetHashCode()); + } + + [Fact] + public void Value_objects_of_different_types_are_not_equal_even_with_matching_values() + { + var money = new Money("1", 1m); + var weight = new Weight(1m); + + money.Equals(weight).Should().BeFalse(); + } + + [Fact] + public void Equality_operator_handles_nulls() + { + Money? nullA = null; + Money? nullB = null; + var value = new Money("BRL", 1m); + + (nullA == nullB).Should().BeTrue("null == null é consistente com C#"); + (nullA == value).Should().BeFalse(); + (value == nullA).Should().BeFalse(); + (value != nullA).Should().BeTrue(); + value.Equals(null).Should().BeFalse(); + } + + [Fact] + public void Value_object_works_in_linq_set_operations() + { + var list = new[] + { + new Money("BRL", 1m), + new Money("BRL", 1m), + new Money("USD", 2m) + }; + + list.Distinct().Should().HaveCount(2, "igualdade estrutural deduplica"); + } + + [Fact] + public void DomainSignature_on_value_object_property_throws() + { + var a = new BadValueObject { Code = "x" }; + var b = new BadValueObject { Code = "x" }; + + var act = () => a.Equals(b); + + act.Should().Throw( + "[DomainSignature] é proibido em value objects: a assinatura já é o conjunto de todas as propriedades"); + } +} diff --git a/NetCore/Codout.Framework.EF.Tests/ClientGeneratedIdConventionTests.cs b/tests/Codout.Framework.EF.Tests/ClientGeneratedIdConventionTests.cs similarity index 100% rename from NetCore/Codout.Framework.EF.Tests/ClientGeneratedIdConventionTests.cs rename to tests/Codout.Framework.EF.Tests/ClientGeneratedIdConventionTests.cs diff --git a/NetCore/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj b/tests/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj similarity index 100% rename from NetCore/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj rename to tests/Codout.Framework.EF.Tests/Codout.Framework.EF.Tests.csproj diff --git a/tests/Codout.Framework.EF.Tests/EFRepositoryAsyncTests.cs b/tests/Codout.Framework.EF.Tests/EFRepositoryAsyncTests.cs new file mode 100644 index 0000000..d72b78f --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/EFRepositoryAsyncTests.cs @@ -0,0 +1,185 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// Superfície assíncrona do EFRepository: queries, comandos e overloads de +/// CancellationToken (incluindo tokens já cancelados). +/// +public class EFRepositoryAsyncTests : SqliteTestBase +{ + private static readonly CancellationToken Cancelled = new(canceled: true); + + private Guid Seed(params Customer[] customers) + { + using var context = CreateContext(); + context.Customers.AddRange(customers); + context.SaveChanges(); + return customers.First().Id!.Value; + } + + [Fact] + public async Task GetAsync_by_predicate_and_by_key_round_trip() + { + var id = Seed(new Customer { Name = "Ana", Age = 30 }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + (await repository.GetAsync(c => c.Name == "Ana"))!.Id.Should().Be(id); + (await repository.GetAsync((object)id))!.Name.Should().Be("Ana"); + (await repository.LoadAsync(id))!.Name.Should().Be("Ana"); + (await repository.GetAsync(c => c.Name == "Zoe")).Should().BeNull(); + } + + [Fact] + public async Task FirstOrDefault_Any_Count_ToList_work() + { + Seed( + new Customer { Name = "Ana", Age = 18 }, + new Customer { Name = "Bia", Age = 40 }, + new Customer { Name = "Caio", Age = 65 }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + (await repository.FirstOrDefaultAsync(c => c.Age > 100)).Should().BeNull(); + (await repository.AnyAsync(c => c.Age >= 40)).Should().BeTrue(); + (await repository.AnyAsync(c => c.Age > 100)).Should().BeFalse(); + (await repository.CountAsync(c => c.Age >= 40)).Should().Be(2); + (await repository.ToListAsync(c => c.Age >= 40)).Should().HaveCount(2); + } + + [Fact] + public async Task SaveAsync_and_DeleteAsync_round_trip() + { + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + await repository.SaveAsync(new Customer { Name = "Ana" }); + await repository.SaveAsync(new Customer { Name = "Bia" }, CancellationToken.None); + await context.SaveChangesAsync(); + } + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var ana = await repository.GetAsync(c => c.Name == "Ana"); + await repository.DeleteAsync(ana); + await context.SaveChangesAsync(); + } + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + await repository.DeleteAsync(c => c.Name == "Bia"); + await context.SaveChangesAsync(); + } + + using var verify = CreateContext(); + (await verify.Customers.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task UpdateAsync_persists_changes_of_detached_entity() + { + var id = Seed(new Customer { Name = "Ana", Age = 30 }); + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var detached = new Customer { Name = "Atualizada", Age = 31 }; + detached.SetId(id); + + await repository.UpdateAsync(detached); + await context.SaveChangesAsync(); + } + + using var verify = CreateContext(); + (await verify.Customers.SingleAsync(c => c.Id == id)).Name.Should().Be("Atualizada"); + } + + [Fact] + public async Task MergeAsync_attaches_detached_entity() + { + var id = Seed(new Customer { Name = "Ana", Age = 30 }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var detached = new Customer { Name = "Ana", Age = 30 }; + detached.SetId(id); + + var merged = await repository.MergeAsync(detached); + + merged.Should().BeSameAs(detached); + context.Entry(detached).State.Should().Be(EntityState.Unchanged); + } + + [Fact] + public async Task RefreshAsync_restores_database_values() + { + var id = Seed(new Customer { Name = "Ana", Age = 30 }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var entity = (await repository.GetAsync((object)id))!; + entity.Name = "Local"; + + var refreshed = await repository.RefreshAsync(entity); + + refreshed.Name.Should().Be("Ana"); + } + + [Fact] + public async Task Cancelled_token_aborts_async_queries() + { + Seed(new Customer { Name = "Ana" }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + await FluentActions.Awaiting(() => repository.GetAsync(c => c.Name == "Ana", Cancelled)) + .Should().ThrowAsync(); + await FluentActions.Awaiting(() => repository.CountAsync(c => true, Cancelled)) + .Should().ThrowAsync(); + await FluentActions.Awaiting(() => repository.ToListAsync(c => true, Cancelled)) + .Should().ThrowAsync(); + await FluentActions.Awaiting(() => repository.DeleteAsync(c => true, Cancelled)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Cancelled_token_aborts_GetAsync_by_key_for_untracked_entity() + { + var id = Seed(new Customer { Name = "Ana" }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + await FluentActions.Awaiting(() => repository.GetAsync((object)id, Cancelled)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshAsync_with_token_currently_ignores_cancellation() + { + // BUG?: RefreshAsync(entity, cancellationToken) delega para o Refresh SÍNCRONO + // (Task.FromResult(Refresh(entity))): bloqueia a thread e IGNORA o token. + // Characterization test do comportamento atual — ver tests/FINDINGS-B.md. + var id = Seed(new Customer { Name = "Ana", Age = 30 }); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var entity = (await repository.GetAsync((object)id))!; + entity.Name = "Local"; + + var refreshed = await repository.RefreshAsync(entity, Cancelled); + + refreshed.Name.Should().Be("Ana", "o reload acontece mesmo com token cancelado"); + } +} diff --git a/tests/Codout.Framework.EF.Tests/EFRepositoryCrudTests.cs b/tests/Codout.Framework.EF.Tests/EFRepositoryCrudTests.cs new file mode 100644 index 0000000..508c5a0 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/EFRepositoryCrudTests.cs @@ -0,0 +1,281 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// CRUD básico do EFRepository sobre SQLite in-memory: queries (All/AllReadOnly/Where/ +/// WhereReadOnly/WherePaged/Get/Load), comandos (Save/Delete/Update/Merge/Refresh) +/// e Includes. +/// +public class EFRepositoryCrudTests : SqliteTestBase +{ + private static Customer NewCustomer(string name, int age = 20) => new() { Name = name, Age = age }; + + private Guid SeedCustomers(params Customer[] customers) + { + using var context = CreateContext(); + context.Customers.AddRange(customers); + context.SaveChanges(); + return customers.First().Id!.Value; + } + + [Fact] + public void Save_then_Get_by_key_round_trips() + { + Guid id; + using (var context = new EFRepositoryScope(CreateContext())) + { + var saved = context.Repository.Save(NewCustomer("Ana", 30)); + context.Context.SaveChanges(); + id = saved.Id!.Value; + } + + using var verify = new EFRepositoryScope(CreateContext()); + var loaded = verify.Repository.Get(id); + + loaded.Should().NotBeNull(); + loaded.Name.Should().Be("Ana"); + loaded.Age.Should().Be(30); + } + + [Fact] + public void Save_throws_on_null_entity() + { + using var scope = new EFRepositoryScope(CreateContext()); + + var act = () => scope.Repository.Save(null!); + + act.Should().Throw(); + } + + [Fact] + public void Get_by_predicate_returns_single_match() + { + SeedCustomers(NewCustomer("Ana"), NewCustomer("Bia")); + + using var scope = new EFRepositoryScope(CreateContext()); + scope.Repository.Get(c => c.Name == "Bia").Name.Should().Be("Bia"); + scope.Repository.Get(c => c.Name == "Zoe").Should().BeNull(); + } + + [Fact] + public void Get_by_predicate_throws_when_multiple_match() + { + SeedCustomers(NewCustomer("Ana", 30), NewCustomer("Bia", 30)); + + using var scope = new EFRepositoryScope(CreateContext()); + var act = () => scope.Repository.Get(c => c.Age == 30); + + act.Should().Throw("Get usa SingleOrDefault"); + } + + [Fact] + public void Load_delegates_to_Get_by_key() + { + var id = SeedCustomers(NewCustomer("Ana")); + + using var scope = new EFRepositoryScope(CreateContext()); + scope.Repository.Load(id).Name.Should().Be("Ana"); + } + + [Fact] + public void All_returns_every_entity() + { + SeedCustomers(NewCustomer("Ana"), NewCustomer("Bia"), NewCustomer("Caio")); + + using var scope = new EFRepositoryScope(CreateContext()); + scope.Repository.All().Count().Should().Be(3); + } + + [Fact] + public void All_tracks_entities_but_AllReadOnly_does_not() + { + SeedCustomers(NewCustomer("Ana")); + + using (var tracked = new EFRepositoryScope(CreateContext())) + { + _ = tracked.Repository.All().ToList(); + tracked.Context.ChangeTracker.Entries().Should().NotBeEmpty(); + } + + using var untracked = new EFRepositoryScope(CreateContext()); + _ = untracked.Repository.AllReadOnly().ToList(); + untracked.Context.ChangeTracker.Entries().Should().BeEmpty("AsNoTracking não rastreia"); + } + + [Fact] + public void Where_filters_and_WhereReadOnly_does_not_track() + { + SeedCustomers(NewCustomer("Ana", 18), NewCustomer("Bia", 40), NewCustomer("Caio", 65)); + + using var scope = new EFRepositoryScope(CreateContext()); + var tracked = scope.Repository.Where(c => c.Age >= 40).ToList(); + tracked.Should().HaveCount(2); + + _ = scope.Repository.WhereReadOnly(c => c.Age >= 40).ToList(); + scope.Context.ChangeTracker.Entries().Count().Should().Be(2, "apenas o Where tracked rastreou"); + } + + [Fact] + public void WherePaged_returns_page_and_total() + { + SeedCustomers( + NewCustomer("A", 30), NewCustomer("B", 30), NewCustomer("C", 30), + NewCustomer("D", 30), NewCustomer("E", 10)); + + using var scope = new EFRepositoryScope(CreateContext()); + + var page0 = scope.Repository.WherePaged(c => c.Age == 30, out var total, index: 0, size: 3).ToList(); + total.Should().Be(4, "total conta TODOS os que casam com o predicado"); + page0.Should().HaveCount(3); + + var page1 = scope.Repository.WherePaged(c => c.Age == 30, out total, index: 1, size: 3).ToList(); + page1.Should().HaveCount(1, "última página parcial"); + total.Should().Be(4); + } + + [Fact] + public void Delete_entity_removes_the_row() + { + var id = SeedCustomers(NewCustomer("Ana")); + + using (var scope = new EFRepositoryScope(CreateContext())) + { + var entity = scope.Repository.Get(id); + scope.Repository.Delete(entity); + scope.Context.SaveChanges(); + } + + using var verify = new EFRepositoryScope(CreateContext()); + verify.Repository.All().Count().Should().Be(0); + } + + [Fact] + public void Delete_by_predicate_removes_only_matches() + { + SeedCustomers(NewCustomer("Ana", 18), NewCustomer("Bia", 40), NewCustomer("Caio", 70)); + + using (var scope = new EFRepositoryScope(CreateContext())) + { + scope.Repository.Delete(c => c.Age >= 40); + scope.Context.SaveChanges(); + } + + using var verify = new EFRepositoryScope(CreateContext()); + verify.Repository.All().Single().Name.Should().Be("Ana"); + } + + [Fact] + public void Delete_throws_on_null_entity() + { + using var scope = new EFRepositoryScope(CreateContext()); + var act = () => scope.Repository.Delete((Customer)null!); + + act.Should().Throw(); + } + + [Fact] + public void Update_marks_detached_entity_as_modified_and_persists() + { + var id = SeedCustomers(NewCustomer("Ana", 30)); + + using (var scope = new EFRepositoryScope(CreateContext())) + { + var detached = new Customer { Name = "Ana Maria", Age = 31 }; + detached.SetId(id); + + scope.Repository.Update(detached); + scope.Context.Entry(detached).State.Should().Be(EntityState.Modified); + scope.Context.SaveChanges(); + } + + using var verify = new EFRepositoryScope(CreateContext()); + var reloaded = verify.Repository.Get(id); + reloaded.Name.Should().Be("Ana Maria"); + reloaded.Age.Should().Be(31); + } + + [Fact] + public void Merge_attaches_detached_entity_as_unchanged() + { + var id = SeedCustomers(NewCustomer("Ana", 30)); + + using var scope = new EFRepositoryScope(CreateContext()); + var detached = new Customer { Name = "Ana", Age = 30 }; + detached.SetId(id); + + var merged = scope.Repository.Merge(detached); + + merged.Should().BeSameAs(detached); + scope.Context.Entry(detached).State.Should().Be(EntityState.Unchanged, + "Attach com PK preenchida rastreia sem marcar como Modified"); + } + + [Fact] + public void Refresh_restores_database_values_over_local_changes() + { + var id = SeedCustomers(NewCustomer("Ana", 30)); + + using var scope = new EFRepositoryScope(CreateContext()); + var entity = scope.Repository.Get(id); + entity.Name = "Renomeada"; + + var refreshed = scope.Repository.Refresh(entity); + + refreshed.Should().BeSameAs(entity); + entity.Name.Should().Be("Ana", "Reload descarta a mutação local"); + scope.Context.Entry(entity).State.Should().Be(EntityState.Unchanged); + } + + [Fact] + public void IncludeMany_loads_related_collection() + { + Guid blogId; + using (var seed = CreateContext()) + { + var blog = new Blog { Title = "blog" }; + blog.Posts.Add(new Post { BlogId = blog.Id!.Value, Content = "p1" }); + blog.Posts.Add(new Post { BlogId = blog.Id!.Value, Content = "p2" }); + seed.Blogs.Add(blog); + seed.SaveChanges(); + blogId = blog.Id!.Value; + } + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var loaded = repository.IncludeMany(b => b.Posts).Single(b => b.Id == blogId); + + loaded.Posts.Should().HaveCount(2); + } + + [Fact] + public void Dispose_does_not_dispose_the_context() + { + using var context = CreateContext(); + var repository = new EFRepository(context); + + repository.Dispose(); + + // O contexto continua usável: o ciclo de vida é do DI/UnitOfWork. + var act = () => context.Customers.Count(); + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_throws_on_null_context() + { + var act = () => new EFRepository(null!); + act.Should().Throw(); + } + + /// Par contexto+repositório com descarte do contexto ao final do escopo. + private sealed class EFRepositoryScope(TestDbContext context) : IDisposable + { + public TestDbContext Context { get; } = context; + public EFRepository Repository { get; } = new(context); + public void Dispose() => Context.Dispose(); + } +} diff --git a/tests/Codout.Framework.EF.Tests/EFRepositorySaveOrUpdateTests.cs b/tests/Codout.Framework.EF.Tests/EFRepositorySaveOrUpdateTests.cs new file mode 100644 index 0000000..62f4fc0 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/EFRepositorySaveOrUpdateTests.cs @@ -0,0 +1,222 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// SaveOrUpdate / SaveOrUpdateAsync — a lógica delicada do fix 6.3.0: +/// insert vs. update decidido por Entry().State + inspeção da PK + Find no banco, +/// e NÃO por IEntity.IsTransient() (que falha para Id pré-atribuído no ctor). +/// +public class EFRepositorySaveOrUpdateTests : SqliteTestBase +{ + private Guid Seed(string name = "Ana", int age = 30) + { + using var context = CreateContext(); + var customer = new Customer { Name = name, Age = age }; + context.Customers.Add(customer); + context.SaveChanges(); + return customer.Id!.Value; + } + + // ---- Insert: entidade nova com Id pré-atribuído (o caso que o IsTransient errava) ---- + + [Fact] + public void Detached_new_entity_with_preassigned_id_is_inserted() + { + var customer = new Customer { Name = "Ana", Age = 30 }; // Id setado no ctor (ClientGeneratedEntity) + customer.IsTransient().Should().BeFalse("o cenário-chave: Id pré-atribuído engana o IsTransient()"); + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var result = repository.SaveOrUpdate(customer); + + result.Should().BeSameAs(customer); + context.Entry(customer).State.Should().Be(EntityState.Added, "linha não existe ⇒ INSERT"); + context.SaveChanges(); + } + + using var verify = CreateContext(); + verify.Customers.Single().Name.Should().Be("Ana"); + } + + [Fact] + public void Entity_with_default_key_is_added_without_database_roundtrip() + { + using var context = CreateContext(); + var repository = new EFRepository(context); + + var invoice = new Invoice { Number = "INV-1" }; // Id int = 0 (store-generated) + repository.SaveOrUpdate(invoice); + + context.Entry(invoice).State.Should().Be(EntityState.Added); + context.SaveChanges(); + invoice.Id.Should().BeGreaterThan(0, "autoincrement preenche a chave"); + } + + // ---- Update: entidade detached cuja linha já existe ---- + + [Fact] + public void Detached_entity_with_existing_row_updates_the_row() + { + var id = Seed("Ana", 30); + + var detached = new Customer { Name = "Ana Maria", Age = 31 }; + detached.SetId(id); + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var result = repository.SaveOrUpdate(detached); + + result.Should().BeSameAs(detached, "o contrato devolve a instância recebida"); + context.SaveChanges(); + } + + using var verify = CreateContext(); + var reloaded = verify.Customers.Single(c => c.Id == id); + reloaded.Name.Should().Be("Ana Maria"); + reloaded.Age.Should().Be(31); + verify.Customers.Count().Should().Be(1, "UPDATE, não um segundo INSERT"); + } + + [Fact] + public void Detached_update_copies_values_into_the_tracked_instance_not_the_argument() + { + var id = Seed("Ana"); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var detached = new Customer { Name = "Nova" }; + detached.SetId(id); + + repository.SaveOrUpdate(detached); + + // Quem fica tracked é a instância carregada pelo Find (existing); a recebida + // permanece Detached. Comportamento documentado do fix 6.3.0 (preserva + // OriginalValues/tokens de concorrência do banco). + context.Entry(detached).State.Should().Be(EntityState.Detached); + + var trackedEntry = context.ChangeTracker.Entries().Single(); + trackedEntry.State.Should().Be(EntityState.Modified); + trackedEntry.Entity.Name.Should().Be("Nova"); + } + + // ---- Entidade já rastreada ---- + + [Fact] + public void Tracked_unchanged_entity_is_not_forced_to_modified() + { + var id = Seed("Ana"); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var tracked = repository.Get(id); + repository.SaveOrUpdate(tracked); + + context.Entry(tracked).State.Should().Be(EntityState.Unchanged, + "mudança de comportamento 6.3.0: confia no change tracker, sem force full update"); + } + + [Fact] + public void Tracked_entity_with_mutation_persists_via_change_tracker() + { + var id = Seed("Ana"); + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var tracked = repository.Get(id); + tracked.Name = "Mudada"; + + repository.SaveOrUpdate(tracked); + context.SaveChanges(); + } + + using var verify = CreateContext(); + verify.Customers.Single(c => c.Id == id).Name.Should().Be("Mudada"); + } + + [Fact] + public void SaveOrUpdate_throws_on_null_entity() + { + using var context = CreateContext(); + var repository = new EFRepository(context); + + FluentActions.Invoking(() => repository.SaveOrUpdate(null!)) + .Should().Throw(); + } + + // ---- Variantes assíncronas ---- + + [Fact] + public async Task SaveOrUpdateAsync_inserts_detached_new_entity_with_preassigned_id() + { + var customer = new Customer { Name = "Async", Age = 1 }; + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + await repository.SaveOrUpdateAsync(customer); + + context.Entry(customer).State.Should().Be(EntityState.Added); + await context.SaveChangesAsync(); + } + + using var verify = CreateContext(); + (await verify.Customers.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task SaveOrUpdateAsync_updates_existing_row_from_detached_entity() + { + var id = Seed("Ana", 30); + + var detached = new Customer { Name = "Async Update", Age = 99 }; + detached.SetId(id); + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + await repository.SaveOrUpdateAsync(detached); + await context.SaveChangesAsync(); + } + + using var verify = CreateContext(); + var reloaded = await verify.Customers.SingleAsync(c => c.Id == id); + reloaded.Name.Should().Be("Async Update"); + reloaded.Age.Should().Be(99); + } + + [Fact] + public async Task SaveOrUpdateAsync_honors_a_cancelled_token_when_a_lookup_is_needed() + { + var id = Seed("Ana"); + + var detached = new Customer { Name = "X" }; + detached.SetId(id); + + using var context = CreateContext(); + var repository = new EFRepository(context); + var cancelled = new CancellationToken(canceled: true); + + var act = () => repository.SaveOrUpdateAsync(detached, cancelled); + + await act.Should().ThrowAsync( + "fix 6.3.0: o FindAsync respeita o CancellationToken recebido"); + } + + [Fact] + public async Task SaveOrUpdateAsync_throws_on_null_entity() + { + using var context = CreateContext(); + var repository = new EFRepository(context); + + await FluentActions.Awaiting(() => repository.SaveOrUpdateAsync(null!)) + .Should().ThrowAsync(); + } +} diff --git a/tests/Codout.Framework.EF.Tests/EFUnitOfWorkTests.cs b/tests/Codout.Framework.EF.Tests/EFUnitOfWorkTests.cs new file mode 100644 index 0000000..5db6be8 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/EFUnitOfWorkTests.cs @@ -0,0 +1,262 @@ +using System.Data; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// EFUnitOfWork: commit/rollback (sync e async), transações explícitas, +/// InTransaction/InTransactionAsync e o ciclo de vida do DbContext. +/// +public class EFUnitOfWorkTests : SqliteTestBase +{ + private sealed class TestUnitOfWork(TestDbContext context) : EFUnitOfWork(context); + + private TestUnitOfWork CreateUnitOfWork() => new(CreateContext()); + + private int CountCustomers() + { + using var context = CreateContext(); + return context.Customers.Count(); + } + + [Fact] + public void Commit_without_transaction_just_saves_changes() + { + using (var uow = CreateUnitOfWork()) + { + uow.DbContext.Add(new Customer { Name = "Ana" }); + uow.Commit(); + } + + CountCustomers().Should().Be(1); + } + + [Fact] + public void Commit_inside_transaction_persists() + { + using (var uow = CreateUnitOfWork()) + { + uow.BeginTransaction(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + uow.Commit(); + } + + CountCustomers().Should().Be(1); + } + + [Fact] + public void Rollback_discards_pending_transaction_work() + { + using (var uow = CreateUnitOfWork()) + { + uow.BeginTransaction(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + uow.DbContext.SaveChanges(); + uow.Rollback(); + } + + CountCustomers().Should().Be(0); + } + + [Fact] + public void Rollback_without_transaction_is_a_noop() + { + using var uow = CreateUnitOfWork(); + var act = uow.Rollback; + act.Should().NotThrow(); + } + + [Fact] + public void BeginTransaction_twice_throws() + { + using var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + var act = () => uow.BeginTransaction(); + + act.Should().Throw("transações não são aninháveis"); + uow.Rollback(); + } + + [Fact] + public void Transaction_can_be_restarted_after_commit() + { + using var uow = CreateUnitOfWork(); + + uow.BeginTransaction(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + uow.Commit(); + + var act = () => uow.BeginTransaction(); + act.Should().NotThrow("o commit limpa a transação corrente"); + uow.Rollback(); + } + + [Fact] + public async Task CommitAsync_persists_within_async_transaction() + { + await using (var uow = CreateUnitOfWork()) + { + await uow.BeginTransactionAsync(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + await uow.CommitAsync(); + } + + CountCustomers().Should().Be(1); + } + + [Fact] + public async Task CommitAsync_without_transaction_throws_unlike_sync_Commit() + { + // BUG?: assimetria de contrato — Commit() sem transação faz SaveChanges + // (auto-commit), mas CommitAsync() sem transação lança InvalidOperationException. + // Characterization test do comportamento atual — ver tests/FINDINGS-B.md. + await using var uow = CreateUnitOfWork(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + + var act = () => uow.CommitAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RollbackAsync_discards_pending_transaction_work() + { + await using (var uow = CreateUnitOfWork()) + { + await uow.BeginTransactionAsync(); + uow.DbContext.Add(new Customer { Name = "Ana" }); + await uow.DbContext.SaveChangesAsync(); + await uow.RollbackAsync(); + } + + CountCustomers().Should().Be(0); + } + + [Fact] + public async Task RollbackAsync_without_transaction_is_a_noop() + { + await using var uow = CreateUnitOfWork(); + var act = () => uow.RollbackAsync(); + await act.Should().NotThrowAsync(); + } + + [Fact] + public void InTransaction_commits_and_returns_the_result() + { + Customer result; + using (var uow = CreateUnitOfWork()) + { + result = uow.InTransaction(() => + { + var customer = new Customer { Name = "Ana" }; + uow.DbContext.Add(customer); + return customer; + }); + } + + result.Name.Should().Be("Ana"); + CountCustomers().Should().Be(1); + } + + [Fact] + public void InTransaction_rolls_back_when_work_throws() + { + using (var uow = CreateUnitOfWork()) + { + var act = () => uow.InTransaction(() => + { + uow.DbContext.Add(new Customer { Name = "Ana" }); + uow.DbContext.SaveChanges(); + throw new InvalidOperationException("boom"); + }); + + act.Should().Throw().WithMessage("boom"); + } + + CountCustomers().Should().Be(0, "a exceção dispara rollback"); + } + + [Fact] + public void InTransaction_throws_on_null_work() + { + using var uow = CreateUnitOfWork(); + var act = () => uow.InTransaction(null!); + act.Should().Throw(); + } + + [Fact] + public async Task InTransactionAsync_commits_and_returns_the_result() + { + await using (var uow = CreateUnitOfWork()) + { + var result = await uow.InTransactionAsync(() => + { + var customer = new Customer { Name = "Ana" }; + uow.DbContext.Add(customer); + return Task.FromResult(customer); + }); + + result.Name.Should().Be("Ana"); + } + + CountCustomers().Should().Be(1); + } + + [Fact] + public async Task InTransactionAsync_rolls_back_when_work_throws() + { + await using (var uow = CreateUnitOfWork()) + { + var act = () => uow.InTransactionAsync(async () => + { + uow.DbContext.Add(new Customer { Name = "Ana" }); + await uow.DbContext.SaveChangesAsync(); + throw new InvalidOperationException("boom"); + }); + + await act.Should().ThrowAsync(); + } + + CountCustomers().Should().Be(0); + } + + [Fact] + public async Task InTransactionAsync_reuses_an_externally_managed_transaction() + { + await using var uow = CreateUnitOfWork(); + await uow.BeginTransactionAsync(); + + await uow.InTransactionAsync(() => + { + uow.DbContext.Add(new Customer { Name = "Ana" }); + return Task.FromResult(new Customer()); + }); + + CountCustomers().Should().Be(0, "a transação externa ainda não foi commitada"); + + await uow.CommitAsync(); + CountCustomers().Should().Be(1); + } + + [Fact] + public void Dispose_disposes_the_underlying_context() + { + var uow = CreateUnitOfWork(); + var context = uow.DbContext; + + uow.Dispose(); + + var act = () => context.Set().Count(); + act.Should().Throw("o ciclo de vida do DbContext pertence ao UnitOfWork"); + } + + [Fact] + public void Constructor_throws_on_null_context() + { + var act = () => new TestUnitOfWork(null!); + act.Should().Throw(); + } +} diff --git a/tests/Codout.Framework.EF.Tests/InterceptorTests.cs b/tests/Codout.Framework.EF.Tests/InterceptorTests.cs new file mode 100644 index 0000000..d5cb244 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/InterceptorTests.cs @@ -0,0 +1,165 @@ +using Codout.Framework.EF.Interceptors; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// AuditableInterceptor e SoftDeleteInterceptor sobre SaveChanges/SaveChangesAsync. +/// +public class InterceptorTests : SqliteTestBase +{ + private sealed class FixedUserProvider(string? userId) : ICurrentUserProvider + { + public string? GetCurrentUserId() => userId; + } + + // ---- AuditableInterceptor ---- + + [Fact] + public void Insert_stamps_CreatedAt_and_CreatedBy() + { + var before = DateTime.UtcNow.AddSeconds(-1); + + using var context = CreateContext(new AuditableInterceptor(new FixedUserProvider("ana"))); + var document = new AuditedDocument { Title = "doc" }; + context.Add(document); + context.SaveChanges(); + + document.CreatedAt.Should().BeOnOrAfter(before); + document.CreatedBy.Should().Be("ana"); + document.UpdatedAt.Should().BeNull("insert não marca update"); + document.UpdatedBy.Should().BeNull(); + } + + [Fact] + public async Task Update_stamps_UpdatedAt_and_UpdatedBy_async() + { + Guid id; + using (var seed = CreateContext(new AuditableInterceptor(new FixedUserProvider("ana")))) + { + var document = new AuditedDocument { Title = "doc" }; + seed.Add(document); + await seed.SaveChangesAsync(); + id = document.Id!.Value; + } + + using var context = CreateContext(new AuditableInterceptor(new FixedUserProvider("bia"))); + var loaded = await context.AuditedDocuments.SingleAsync(d => d.Id == id); + loaded.Title = "doc v2"; + await context.SaveChangesAsync(); + + loaded.UpdatedAt.Should().NotBeNull(); + loaded.UpdatedBy.Should().Be("bia"); + loaded.CreatedBy.Should().Be("ana", "o CreatedBy original é preservado"); + } + + [Fact] + public void Interceptor_works_without_a_current_user_provider() + { + using var context = CreateContext(new AuditableInterceptor()); + var document = new AuditedDocument { Title = "doc" }; + context.Add(document); + context.SaveChanges(); + + document.CreatedAt.Should().NotBe(default); + document.CreatedBy.Should().BeNull(); + } + + [Fact] + public void Entity_implementing_only_the_Data_Auditing_IAuditable_is_not_audited() + { + // BUG?: existem DUAS interfaces IAuditable no ecossistema — a abstração pública + // Codout.Framework.Data.Auditing.IAuditable e a duplicata local + // Codout.Framework.EF.Interceptors.IAuditable. Por resolução de nomes, o + // AuditableInterceptor enxerga apenas a duplicata LOCAL; entidades que + // implementam somente a abstração pública são silenciosamente ignoradas. + // Characterization test do comportamento atual — ver tests/FINDINGS-B.md. + typeof(Data.Auditing.IAuditable).Should().NotBe(typeof(Interceptors.IAuditable)); + + using var context = CreateContext(new AuditableInterceptor(new FixedUserProvider("ana"))); + var document = new DataAuditedDocument { Title = "doc" }; + context.Add(document); + context.SaveChanges(); + + document.CreatedAt.Should().Be(default, "a entidade não implementa a IAuditable local do interceptor"); + document.CreatedBy.Should().BeNull(); + } + + // ---- SoftDeleteInterceptor ---- + + [Fact] + public void Delete_becomes_soft_delete_keeping_the_row() + { + Guid id; + using (var seed = CreateContext()) + { + var item = new SoftItem { Name = "item" }; + seed.Add(item); + seed.SaveChanges(); + id = item.Id!.Value; + } + + using (var context = CreateContext(new SoftDeleteInterceptor(new FixedUserProvider("ana")))) + { + var item = context.SoftItems.Single(i => i.Id == id); + context.Remove(item); + context.SaveChanges(); + + item.IsDeleted.Should().BeTrue(); + item.DeletedAt.Should().NotBeNull(); + item.DeletedBy.Should().Be("ana"); + } + + using var verify = CreateContext(); + var row = verify.SoftItems.Single(i => i.Id == id); + row.IsDeleted.Should().BeTrue("a linha permanece no banco, marcada como deletada"); + } + + [Fact] + public async Task Async_delete_is_also_soft() + { + Guid id; + using (var seed = CreateContext()) + { + var item = new SoftItem { Name = "item" }; + seed.Add(item); + await seed.SaveChangesAsync(); + id = item.Id!.Value; + } + + using (var context = CreateContext(new SoftDeleteInterceptor())) + { + var item = await context.SoftItems.SingleAsync(i => i.Id == id); + context.Remove(item); + await context.SaveChangesAsync(); + } + + using var verify = CreateContext(); + (await verify.SoftItems.CountAsync()).Should().Be(1); + (await verify.SoftItems.SingleAsync(i => i.Id == id)).IsDeleted.Should().BeTrue(); + } + + [Fact] + public void Without_the_interceptor_delete_is_physical() + { + Guid id; + using (var seed = CreateContext()) + { + var item = new SoftItem { Name = "item" }; + seed.Add(item); + seed.SaveChanges(); + id = item.Id!.Value; + } + + using (var context = CreateContext()) + { + context.Remove(context.SoftItems.Single(i => i.Id == id)); + context.SaveChanges(); + } + + using var verify = CreateContext(); + verify.SoftItems.Should().BeEmpty("baseline: sem o interceptor o DELETE é físico"); + } +} diff --git a/tests/Codout.Framework.EF.Tests/SpecificationTests.cs b/tests/Codout.Framework.EF.Tests/SpecificationTests.cs new file mode 100644 index 0000000..30b5c13 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/SpecificationTests.cs @@ -0,0 +1,158 @@ +using Codout.Framework.EF.Specifications; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.EF.Tests; + +/// +/// Specification Pattern: Specification<T>, SpecificationEvaluator e as extensões +/// GetBySpecification / ListAsync / FirstOrDefaultAsync / CountAsync / AnyAsync. +/// +public class SpecificationTests : SqliteTestBase +{ + private sealed class AdultsByNameSpec : Specification + { + public AdultsByNameSpec(bool noTracking = false, int? skip = null, int? take = null) + { + AddCriteria(c => c.Age >= 18); + ApplyOrderBy(q => q.OrderBy(c => c.Name)); + + if (noTracking) + ApplyNoTracking(); + + if (skip.HasValue && take.HasValue) + ApplyPaging(skip.Value, take.Value); + } + } + + private sealed class BlogWithPostsSpec : Specification + { + public BlogWithPostsSpec(string title, bool useStringInclude = false) + { + AddCriteria(b => b.Title == title); + + if (useStringInclude) + AddInclude("Posts"); + else + AddInclude(b => b.Posts); + } + } + + private void SeedCustomers() + { + using var context = CreateContext(); + context.Customers.AddRange( + new Customer { Name = "Caio", Age = 65 }, + new Customer { Name = "Ana", Age = 30 }, + new Customer { Name = "Lia", Age = 10 }, + new Customer { Name = "Bia", Age = 40 }); + context.SaveChanges(); + } + + [Fact] + public void Criteria_and_ordering_are_applied() + { + SeedCustomers(); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var result = repository.GetBySpecification(new AdultsByNameSpec()).ToList(); + + result.Select(c => c.Name).Should().Equal("Ana", "Bia", "Caio"); + } + + [Fact] + public void Paging_applies_after_ordering() + { + SeedCustomers(); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + var page = repository.GetBySpecification(new AdultsByNameSpec(skip: 1, take: 1)).ToList(); + + page.Should().ContainSingle().Which.Name.Should().Be("Bia"); + } + + [Fact] + public void AsNoTracking_specification_does_not_track() + { + SeedCustomers(); + + using var context = CreateContext(); + var repository = new EFRepository(context); + + _ = repository.GetBySpecification(new AdultsByNameSpec(noTracking: true)).ToList(); + + context.ChangeTracker.Entries().Should().BeEmpty(); + } + + [Fact] + public void Includes_load_related_entities_by_expression_and_by_string() + { + using (var seed = CreateContext()) + { + var blog = new Blog { Title = "b1" }; + blog.Posts.Add(new Post { BlogId = blog.Id!.Value, Content = "p1" }); + blog.Posts.Add(new Post { BlogId = blog.Id!.Value, Content = "p2" }); + seed.Blogs.Add(blog); + seed.SaveChanges(); + } + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var loaded = repository.GetBySpecification(new BlogWithPostsSpec("b1")).Single(); + loaded.Posts.Should().HaveCount(2, "include por expressão"); + } + + using (var context = CreateContext()) + { + var repository = new EFRepository(context); + var loaded = repository.GetBySpecification(new BlogWithPostsSpec("b1", useStringInclude: true)).Single(); + loaded.Posts.Should().HaveCount(2, "include por string"); + } + } + + [Fact] + public async Task Async_extension_helpers_evaluate_the_specification() + { + SeedCustomers(); + + using var context = CreateContext(); + var repository = new EFRepository(context); + var spec = new AdultsByNameSpec(); + + (await repository.ListAsync(spec)).Should().HaveCount(3); + (await repository.FirstOrDefaultAsync(spec))!.Name.Should().Be("Ana"); + (await repository.CountAsync(spec)).Should().Be(3); + (await repository.AnyAsync(spec)).Should().BeTrue(); + } + + [Fact] + public async Task Async_extension_helpers_honor_a_cancelled_token() + { + SeedCustomers(); + + using var context = CreateContext(); + var repository = new EFRepository(context); + var cancelled = new CancellationToken(canceled: true); + + await FluentActions.Awaiting(() => repository.ListAsync(new AdultsByNameSpec(), cancelled)) + .Should().ThrowAsync(); + } + + [Fact] + public void Specification_defaults_are_off() + { + var spec = new AdultsByNameSpec(); + + spec.IsPagingEnabled.Should().BeFalse(); + spec.AsNoTracking.Should().BeFalse(); + spec.Skip.Should().Be(0); + spec.Take.Should().Be(0); + spec.Includes.Should().BeEmpty(); + spec.IncludeStrings.Should().BeEmpty(); + } +} diff --git a/tests/Codout.Framework.EF.Tests/TestInfrastructure.cs b/tests/Codout.Framework.EF.Tests/TestInfrastructure.cs new file mode 100644 index 0000000..1aac4b4 --- /dev/null +++ b/tests/Codout.Framework.EF.Tests/TestInfrastructure.cs @@ -0,0 +1,126 @@ +using Codout.Framework.Domain.Entities; +using Codout.Framework.EF.Conventions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Codout.Framework.EF.Tests; + +// --------------------------------------------------------------------------- +// Modelo compartilhado pelos testes de EFRepository / EFUnitOfWork / +// Interceptors / Specifications, sobre SQLite in-memory. +// --------------------------------------------------------------------------- + +/// Agregado simples com Guid client-generated. +public class Customer : ClientGeneratedEntity +{ + public string Name { get; set; } = ""; + public int Age { get; set; } +} + +/// Entidade com PK int store-generated (autoincrement) — chave default = 0. +public class Invoice : Entity +{ + public string Number { get; set; } = ""; +} + +/// Agregado com coleção, para Include / specifications. +public class Blog : ClientGeneratedEntity +{ + public string Title { get; set; } = ""; + public List Posts { get; set; } = []; +} + +public class Post : ClientGeneratedEntity +{ + public Guid BlogId { get; set; } + public string Content { get; set; } = ""; +} + +/// Implementa a IAuditable LOCAL do pacote EF (Codout.Framework.EF.Interceptors). +public class AuditedDocument : ClientGeneratedEntity, Interceptors.IAuditable +{ + public string Title { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +/// +/// Implementa SOMENTE a abstração pública Codout.Framework.Data.Auditing.IAuditable — +/// usada para caracterizar que o AuditableInterceptor NÃO a reconhece (ver FINDINGS-B.md). +/// +public class DataAuditedDocument : ClientGeneratedEntity, Data.Auditing.IAuditable +{ + public string Title { get; set; } = ""; + public DateTime CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } +} + +/// Entidade soft-deletable (abstração pública Data.Auditing.ISoftDeletable). +public class SoftItem : ClientGeneratedEntity, Data.Auditing.ISoftDeletable +{ + public string Name { get; set; } = ""; + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } + public string? DeletedBy { get; set; } +} + +public class TestDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + public DbSet Invoices => Set(); + public DbSet Blogs => Set(); + public DbSet Posts => Set(); + public DbSet AuditedDocuments => Set(); + public DbSet DataAuditedDocuments => Set(); + public DbSet SoftItems => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.AddCodoutClientGeneratedIdConvention(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(b => b.Posts) + .WithOne() + .HasForeignKey(p => p.BlogId); + } +} + +/// +/// Base dos testes: abre uma conexão SQLite in-memory por classe de teste (o banco +/// vive enquanto a conexão estiver aberta) e fabrica DbContexts independentes que +/// compartilham o mesmo banco. +/// +public abstract class SqliteTestBase : IDisposable +{ + private readonly SqliteConnection _connection; + + protected SqliteTestBase() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + using var context = CreateContext(); + context.Database.EnsureCreated(); + } + + protected TestDbContext CreateContext(params IInterceptor[] interceptors) + { + var builder = new DbContextOptionsBuilder().UseSqlite(_connection); + + if (interceptors.Length > 0) + builder.AddInterceptors(interceptors); + + return new TestDbContext(builder.Options); + } + + public void Dispose() => _connection.Dispose(); +} diff --git a/tests/Codout.Framework.Mongo.Tests/Codout.Framework.Mongo.Tests.csproj b/tests/Codout.Framework.Mongo.Tests/Codout.Framework.Mongo.Tests.csproj new file mode 100644 index 0000000..a578d51 --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/Codout.Framework.Mongo.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Mongo.Tests/ConfigureServicesTests.cs b/tests/Codout.Framework.Mongo.Tests/ConfigureServicesTests.cs new file mode 100644 index 0000000..eb0aabe --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/ConfigureServicesTests.cs @@ -0,0 +1,99 @@ +using Codout.Framework.Data.Repository; +using Codout.Framework.Mongo.Configuration; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +/// +/// Testes do AddMongoDb (validação de opções + registros de DI). Nenhum deles +/// precisa de servidor: o MongoClient não abre conexão na construção. +/// +public class ConfigureServicesTests +{ + private const string ConnectionString = "mongodb://localhost:27017/?serverSelectionTimeoutMS=100"; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddMongoDb_ComConnectionStringInvalida_DeveLancarArgumentNullException(string? connectionString) + { + var services = new ServiceCollection(); + + var act = () => services.AddMongoDb(connectionString!, "db"); + + act.Should().Throw().WithParameterName("connectionString"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddMongoDb_ComDatabaseNameInvalido_DeveLancarArgumentNullException(string? databaseName) + { + var services = new ServiceCollection(); + + var act = () => services.AddMongoDb(ConnectionString, databaseName!); + + act.Should().Throw().WithParameterName("databaseName"); + } + + [Fact] + public void AddMongoDb_DeveRetornarAMesmaColecaoDeServicos() + { + var services = new ServiceCollection(); + + services.AddMongoDb(ConnectionString, "db").Should().BeSameAs(services); + } + + [Fact] + public void AddMongoDb_DeveRegistrarClientEDatabaseComoSingleton() + { + var services = new ServiceCollection(); + services.AddMongoDb(ConnectionString, "minha_base"); + + using var provider = services.BuildServiceProvider(); + + var client = provider.GetRequiredService(); + provider.GetRequiredService().Should().BeSameAs(client); + + var database = provider.GetRequiredService(); + database.DatabaseNamespace.DatabaseName.Should().Be("minha_base"); + provider.GetRequiredService().Should().BeSameAs(database); + } + + [Fact] + public void AddMongoDb_DeveRegistrarIRepositoryGenericoComoMongoRepository() + { + var services = new ServiceCollection(); + services.AddMongoDb(ConnectionString, "db"); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var repository = scope.ServiceProvider.GetRequiredService>(); + + repository.Should().BeOfType>(); + } + + [Fact] + public void AddMongoDb_RepositorioDeveSerScoped() + { + var services = new ServiceCollection(); + services.AddMongoDb(ConnectionString, "db"); + + using var provider = services.BuildServiceProvider(); + + using var scope1 = provider.CreateScope(); + var a = scope1.ServiceProvider.GetRequiredService>(); + var b = scope1.ServiceProvider.GetRequiredService>(); + a.Should().BeSameAs(b, "dentro do mesmo scope a instância é reutilizada"); + + using var scope2 = provider.CreateScope(); + var c = scope2.ServiceProvider.GetRequiredService>(); + c.Should().NotBeSameAs(a, "scopes diferentes têm instâncias diferentes"); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoRepositoryAsyncTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryAsyncTests.cs new file mode 100644 index 0000000..7a853b1 --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryAsyncTests.cs @@ -0,0 +1,216 @@ +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +[Collection("Mongo")] +public class MongoRepositoryAsyncTests +{ + private readonly MongoFixture _fixture; + private readonly MongoRepository _repository; + + public MongoRepositoryAsyncTests(MongoFixture fixture) + { + _fixture = fixture; + if (_fixture.IsAvailable) + _fixture.ResetCollection(); + _repository = _fixture.IsAvailable ? _fixture.CreateRepository() : null!; + } + + [SkippableFact] + public async Task SaveAsync_DevePersistirEGerarObjectId() + { + _fixture.EnsureAvailable(); + + var gadget = await _repository.SaveAsync(new Gadget { Name = "Async", Price = 1 }); + + gadget.Id.Should().NotBe(ObjectId.Empty); + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Id == gadget.Id)).Should().Be(1); + } + + [SkippableFact] + public async Task GetAsync_PorChave_DeveRetornarEntidade() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("BuscaAsync", 9); + + var found = await _repository.GetAsync(seeded.Id); + + found.Should().NotBeNull(); + found!.Price.Should().Be(9); + } + + [SkippableFact] + public async Task GetAsync_PorChaveInexistente_DeveRetornarNull() + { + _fixture.EnsureAvailable(); + + (await _repository.GetAsync(ObjectId.GenerateNewId())).Should().BeNull(); + } + + [SkippableFact] + public async Task GetAsync_PorPredicado_DeveRetornarPrimeiro() + { + _fixture.EnsureAvailable(); + _fixture.Seed("PredAsync", 3); + + var found = await _repository.GetAsync(g => g.Name == "PredAsync"); + + found.Should().NotBeNull(); + found.Price.Should().Be(3); + } + + // BUG?: a versão síncrona Get(predicate) usa SingleOrDefault (lança com + // múltiplos resultados), mas GetAsync(predicate) usa FirstOrDefaultAsync — + // comportamentos divergentes entre sync e async para o mesmo contrato. + // Registrado em tests/FINDINGS-D.md. + [SkippableFact] + public async Task GetAsync_PorPredicadoComMultiplosResultados_NaoLanca_DiferenteDoSync() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Duplo", 1); + _fixture.Seed("Duplo", 2); + + var found = await _repository.GetAsync(g => g.Name == "Duplo"); + + found.Should().NotBeNull("comportamento atual: FirstOrDefault, sem validar unicidade"); + } + + [SkippableFact] + public async Task LoadAsync_DeveDelegarParaGetAsync() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("LoadAsync", 2); + + (await _repository.LoadAsync(seeded.Id))!.Name.Should().Be("LoadAsync"); + (await _repository.LoadAsync(ObjectId.GenerateNewId())).Should().BeNull(); + } + + [SkippableFact] + public async Task FirstOrDefaultAsync_DeveRetornarPrimeiroOuNull() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Primeiro", 1); + + (await _repository.FirstOrDefaultAsync(g => g.Name == "Primeiro")).Should().NotBeNull(); + (await _repository.FirstOrDefaultAsync(g => g.Name == "Inexistente")).Should().BeNull(); + } + + [SkippableFact] + public async Task AnyAsync_DeveIndicarExistencia() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Existe", 1); + + (await _repository.AnyAsync(g => g.Name == "Existe")).Should().BeTrue(); + (await _repository.AnyAsync(g => g.Name == "NaoExiste")).Should().BeFalse(); + } + + [SkippableFact] + public async Task CountAsync_DeveContarPeloPredicado() + { + _fixture.EnsureAvailable(); + _fixture.Seed("A", 1); + _fixture.Seed("B", 5); + _fixture.Seed("C", 8); + + (await _repository.CountAsync(g => g.Price >= 5)).Should().Be(2); + } + + [SkippableFact] + public async Task ToListAsync_DeveMaterializarPeloPredicado() + { + _fixture.EnsureAvailable(); + _fixture.Seed("L1", 1); + _fixture.Seed("L2", 2); + _fixture.Seed("L3", 3); + + var list = await _repository.ToListAsync(g => g.Price > 1); + + list.Should().HaveCount(2); + list.Should().OnlyContain(g => g.Price > 1); + } + + [SkippableFact] + public async Task UpdateAsync_DeveSubstituirODocumento() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("UpAsync", 1); + seeded.Price = 55; + + await _repository.UpdateAsync(seeded); + + (await _repository.GetAsync(seeded.Id))!.Price.Should().Be(55); + } + + [SkippableFact] + public async Task SaveOrUpdateAsync_TransienteInsere_PersistidaSubstitui() + { + _fixture.EnsureAvailable(); + + var inserted = await _repository.SaveOrUpdateAsync(new Gadget { Name = "SoU", Price = 1 }); + inserted.Id.Should().NotBe(ObjectId.Empty); + + inserted.Price = 22; + await _repository.SaveOrUpdateAsync(inserted); + + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Name == "SoU")).Should().Be(1); + (await _repository.GetAsync(inserted.Id))!.Price.Should().Be(22); + } + + [SkippableFact] + public async Task MergeAsync_DeveSubstituirERetornarEntidade() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("MergeAsync", 1); + seeded.Price = 33; + + var merged = await _repository.MergeAsync(seeded); + + merged.Should().BeSameAs(seeded); + (await _repository.GetAsync(seeded.Id))!.Price.Should().Be(33); + } + + [SkippableFact] + public async Task RefreshAsync_DeveRecarregarDoBanco() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("RefAsync", 10); + + await _fixture.GadgetCollection.UpdateOneAsync( + g => g.Id == seeded.Id, + Builders.Update.Set(g => g.Price, 321)); + + var refreshed = await _repository.RefreshAsync(seeded); + + refreshed.Should().NotBeSameAs(seeded); + refreshed.Price.Should().Be(321); + } + + [SkippableFact] + public async Task DeleteAsync_DeveRemoverODocumento() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("DelAsync", 1); + + await _repository.DeleteAsync(seeded); + + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Id == seeded.Id)).Should().Be(0); + } + + [SkippableFact] + public async Task DeleteAsync_PorPredicado_DeveRemoverApenasCorrespondentes() + { + _fixture.EnsureAvailable(); + _fixture.Seed("DelPred", 1); + _fixture.Seed("DelPred", 2); + _fixture.Seed("Fica", 3); + + await _repository.DeleteAsync(g => g.Name == "DelPred"); + + var remaining = _repository.All().ToList(); + remaining.Should().ContainSingle(g => g.Name == "Fica"); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoRepositoryCrudTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryCrudTests.cs new file mode 100644 index 0000000..54ce82b --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryCrudTests.cs @@ -0,0 +1,210 @@ +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +[Collection("Mongo")] +public class MongoRepositoryCrudTests +{ + private readonly MongoFixture _fixture; + private readonly MongoRepository _repository; + + public MongoRepositoryCrudTests(MongoFixture fixture) + { + _fixture = fixture; + if (_fixture.IsAvailable) + _fixture.ResetCollection(); + _repository = _fixture.IsAvailable ? _fixture.CreateRepository() : null!; + } + + [SkippableFact] + public void Save_DevePersistirEGerarObjectId() + { + _fixture.EnsureAvailable(); + + var gadget = _repository.Save(new Gadget { Name = "Sensor", Price = 10 }); + + gadget.Id.Should().NotBe(ObjectId.Empty, "o driver atribui o _id no InsertOne"); + gadget.IsTransient().Should().BeFalse(); + + _fixture.GadgetCollection.CountDocuments(g => g.Id == gadget.Id).Should().Be(1); + } + + [SkippableFact] + public void Save_DeveUsarColecaoComNomeDoTipoEmMinusculas() + { + _fixture.EnsureAvailable(); + + _repository.Save(new Gadget { Name = "Nome" }); + + // MongoRepository resolve a coleção como typeof(T).Name.ToLowerInvariant(). + var names = _fixture.Database!.ListCollectionNames().ToList(); + names.Should().Contain("gadget"); + } + + [SkippableFact] + public void Get_PorChaveString_DeveRetornarEntidade() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Chip", 5); + + var found = _repository.Get(seeded.Id.ToString()); + + found.Should().NotBeNull(); + found.Name.Should().Be("Chip"); + } + + [SkippableFact] + public void Get_PorChaveObjectId_DeveRetornarEntidade() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Placa", 7); + + var found = _repository.Get(seeded.Id); + + found.Should().NotBeNull(); + found.Price.Should().Be(7); + } + + [SkippableFact] + public void Get_PorChaveInexistente_DeveRetornarNull() + { + _fixture.EnsureAvailable(); + + _repository.Get(ObjectId.GenerateNewId()).Should().BeNull(); + } + + [SkippableFact] + public void Get_PorPredicado_DeveRetornarEntidadeUnica() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Cabo", 1); + _fixture.Seed("Fonte", 2); + + var found = _repository.Get(g => g.Name == "Fonte"); + + found.Should().NotBeNull(); + found.Price.Should().Be(2); + } + + [SkippableFact] + public void Get_PorPredicadoComMultiplosResultados_DeveLancarExcecao() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Duplicado", 1); + _fixture.Seed("Duplicado", 2); + + var act = () => _repository.Get(g => g.Name == "Duplicado"); + + // Usa SingleOrDefault — mais de um resultado é erro. + act.Should().Throw(); + } + + [SkippableFact] + public void Load_DeveDelegarParaGet() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Conector", 3); + + _repository.Load(seeded.Id.ToString())!.Name.Should().Be("Conector"); + _repository.Load(ObjectId.GenerateNewId()).Should().BeNull(); + } + + [SkippableFact] + public void Update_DeveSubstituirODocumento() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Antigo", 1); + seeded.Name = "Novo"; + seeded.Price = 99; + + _repository.Update(seeded); + + var persisted = _repository.Get(seeded.Id); + persisted.Name.Should().Be("Novo"); + persisted.Price.Should().Be(99); + } + + [SkippableFact] + public void SaveOrUpdate_ComEntidadeTransiente_DeveInserir() + { + _fixture.EnsureAvailable(); + + var gadget = _repository.SaveOrUpdate(new Gadget { Name = "Inserido", Price = 1 }); + + gadget.Id.Should().NotBe(ObjectId.Empty); + _fixture.GadgetCollection.CountDocuments(g => g.Name == "Inserido").Should().Be(1); + } + + [SkippableFact] + public void SaveOrUpdate_ComEntidadePersistida_DeveSubstituir() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Persistido", 1); + seeded.Price = 50; + + _repository.SaveOrUpdate(seeded); + + _fixture.GadgetCollection.CountDocuments(FilterDefinition.Empty).Should().Be(1); + _repository.Get(seeded.Id).Price.Should().Be(50); + } + + [SkippableFact] + public void Merge_DeveSubstituirERetornarEntidade() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Mesclado", 1); + seeded.Price = 33; + + var merged = _repository.Merge(seeded); + + merged.Should().BeSameAs(seeded); + _repository.Get(seeded.Id).Price.Should().Be(33); + } + + [SkippableFact] + public void Refresh_DeveRecarregarDoBanco() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Original", 10); + + // Altera o documento por fora do repositório. + _fixture.GadgetCollection.UpdateOne( + g => g.Id == seeded.Id, + Builders.Update.Set(g => g.Price, 123)); + + var refreshed = _repository.Refresh(seeded); + + // Refresh devolve uma NOVA instância lida do banco (não atualiza a atual). + refreshed.Should().NotBeSameAs(seeded); + refreshed.Price.Should().Be(123); + seeded.Price.Should().Be(10); + } + + [SkippableFact] + public void Delete_DeveRemoverODocumento() + { + _fixture.EnsureAvailable(); + var seeded = _fixture.Seed("Remover", 1); + + _repository.Delete(seeded); + + _fixture.GadgetCollection.CountDocuments(g => g.Id == seeded.Id).Should().Be(0); + } + + [SkippableFact] + public void Delete_PorPredicado_DeveRemoverApenasCorrespondentes() + { + _fixture.EnsureAvailable(); + _fixture.Seed("Apagar", 1); + _fixture.Seed("Apagar", 2); + _fixture.Seed("Manter", 3); + + _repository.Delete(g => g.Name == "Apagar"); + + var remaining = _repository.All().ToList(); + remaining.Should().ContainSingle(g => g.Name == "Manter"); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoRepositoryQueryTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryQueryTests.cs new file mode 100644 index 0000000..e63f940 --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryQueryTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +[Collection("Mongo")] +public class MongoRepositoryQueryTests +{ + private readonly MongoFixture _fixture; + private readonly MongoRepository _repository; + + public MongoRepositoryQueryTests(MongoFixture fixture) + { + _fixture = fixture; + if (_fixture.IsAvailable) + _fixture.ResetCollection(); + _repository = _fixture.IsAvailable ? _fixture.CreateRepository() : null!; + } + + private void SeedMany(int count, string prefix = "Item") + { + for (var i = 1; i <= count; i++) + _fixture.Seed($"{prefix}-{i:D2}", i); + } + + [SkippableFact] + public void All_DeveRetornarTodosOsDocumentos() + { + _fixture.EnsureAvailable(); + SeedMany(3); + + _repository.All().ToList().Should().HaveCount(3); + } + + [SkippableFact] + public void All_SemDados_DeveRetornarVazio() + { + _fixture.EnsureAvailable(); + + _repository.All().ToList().Should().BeEmpty(); + } + + [SkippableFact] + public void AllReadOnly_DeveRetornarTodosOsDocumentos() + { + _fixture.EnsureAvailable(); + SeedMany(3); + + _repository.AllReadOnly().ToList().Should().HaveCount(3); + } + + [SkippableFact] + public void Where_DeveFiltrarPeloPredicado() + { + _fixture.EnsureAvailable(); + SeedMany(5); + + var result = _repository.Where(g => g.Price > 3).ToList(); + + result.Should().HaveCount(2); + result.Should().OnlyContain(g => g.Price > 3); + } + + [SkippableFact] + public void Where_DeveComporComOperadoresLinq() + { + _fixture.EnsureAvailable(); + SeedMany(5); + + var name = _repository.Where(g => g.Price >= 2) + .OrderByDescending(g => g.Price) + .Select(g => g.Name) + .First(); + + name.Should().Be("Item-05"); + } + + [SkippableFact] + public void WhereReadOnly_DeveFiltrarPeloPredicado() + { + _fixture.EnsureAvailable(); + SeedMany(4); + + _repository.WhereReadOnly(g => g.Price <= 2).ToList().Should().HaveCount(2); + } + + // BUG?: MongoRepository.WherePaged calcula `total` DEPOIS de aplicar + // Skip/Take — ou seja, `total` devolve o tamanho da página (<= size), e não + // o total de registros que satisfazem o filtro (como faz o NHRepository, + // que conta antes de paginar). Um chamador que use `total` para montar o + // paginador verá sempre "1 página". Registrado em tests/FINDINGS-D.md. + [SkippableFact] + public void WherePaged_Caracterizacao_TotalRetornaTamanhoDaPaginaENaoOTotalDoFiltro() + { + _fixture.EnsureAvailable(); + SeedMany(7); + + var page = _repository.WherePaged(g => g.Price >= 3, out var total, index: 0, size: 2).ToList(); + + page.Should().HaveCount(2); + // Comportamento atual (incorreto): 5 documentos satisfazem o filtro, + // mas total reporta apenas o tamanho da página. + total.Should().Be(2, "comportamento atual: Count() é aplicado após Skip/Take"); + } + + [SkippableFact] + public void WherePaged_SegundaPagina_DevePularItensAnteriores() + { + _fixture.EnsureAvailable(); + SeedMany(7); + + var page = _repository.WherePaged(g => g.Price >= 1, out _, index: 2, size: 3) + .OrderBy(g => g.Price) + .ToList(); + + page.Should().HaveCount(1, "a última página contém só o resto"); + page[0].Price.Should().Be(7); + } + + [SkippableFact] + public void WherePaged_ComPaginaMaiorQueOFiltro_TotalCoincideComOFiltro() + { + _fixture.EnsureAvailable(); + SeedMany(4); + + var page = _repository.WherePaged(g => g.Price > 1, out var total, index: 0, size: 50).ToList(); + + page.Should().HaveCount(3); + total.Should().Be(3, "quando a página cobre todos os resultados o total 'parece' certo"); + } + + [SkippableFact] + public void IncludeMany_DeveRetornarQueryableSemFalhar() + { + _fixture.EnsureAvailable(); + SeedMany(2); + + // MongoDB não suporta Include — o método devolve o queryable da coleção. + _repository.IncludeMany(g => g.Name).ToList().Should().HaveCount(2); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoRepositoryUnitTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryUnitTests.cs new file mode 100644 index 0000000..184deac --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoRepositoryUnitTests.cs @@ -0,0 +1,192 @@ +using System.Reflection; +using FluentAssertions; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +/// +/// Testes de unidade do MongoRepository que NÃO precisam de servidor: o driver +/// só abre conexão quando uma operação de I/O é executada, então construção, +/// validação de argumentos e resolução de nome de coleção são verificáveis +/// com um client apontando para um endereço inválido. +/// +public class MongoRepositoryUnitTests +{ + private static MongoRepository CreateOfflineRepository(out IMongoDatabase database) + { + // Endereço inerte + timeout curto: nenhuma operação aqui faz I/O. + var client = new MongoClient("mongodb://localhost:27017/?serverSelectionTimeoutMS=100"); + database = client.GetDatabase("offline_db"); + return new MongoRepository(database); + } + + [Fact] + public void Construtor_DeveResolverColecaoComNomeDoTipoEmMinusculas() + { + var repository = CreateOfflineRepository(out _); + + // O nome da coleção é detalhe interno — inspecionado via reflection. + var field = typeof(MongoRepository) + .GetField("_collection", BindingFlags.Instance | BindingFlags.NonPublic); + field.Should().NotBeNull(); + + var collection = (IMongoCollection)field!.GetValue(repository)!; + collection.CollectionNamespace.CollectionName.Should().Be("gadget"); + } + + [Fact] + public void Save_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.Save(null!); + act.Should().Throw(); + } + + [Fact] + public void SaveOrUpdate_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.SaveOrUpdate(null!); + act.Should().Throw(); + } + + [Fact] + public void Update_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.Update(null!); + act.Should().Throw(); + } + + [Fact] + public void Delete_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.Delete((Gadget)null!); + act.Should().Throw(); + } + + [Fact] + public void Merge_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.Merge(null!); + act.Should().Throw(); + } + + [Fact] + public void Refresh_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.Refresh(null!); + act.Should().Throw(); + } + + [Fact] + public async Task SaveAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.SaveAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveOrUpdateAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.SaveOrUpdateAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.UpdateAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.DeleteAsync((Gadget)null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task MergeAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.MergeAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RefreshAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var repository = CreateOfflineRepository(out _); + + var act = () => repository.RefreshAsync(null!); + await act.Should().ThrowAsync(); + } + + // BUG?: Get(key)/GetAsync(key) só funcionam com chaves que parseiam como + // ObjectId. Qualquer outra chave (int, Guid, string arbitrária — formatos + // legítimos de _id no MongoDB) retorna null SILENCIOSAMENTE, sem tocar o + // servidor e sem lançar erro. Registrado em tests/FINDINGS-D.md. + [Fact] + public void Get_ComChaveNaoObjectId_RetornaNullSemConsultarOServidor() + { + var repository = CreateOfflineRepository(out _); + + // Não há servidor — se a chamada tentasse consultar, lançaria timeout. + repository.Get("nao-e-objectid").Should().BeNull(); + repository.Get(12345).Should().BeNull(); + repository.Get(Guid.NewGuid()).Should().BeNull(); + repository.Get((object)null!).Should().BeNull(); + } + + [Fact] + public async Task GetAsync_ComChaveNaoObjectId_RetornaNullSemConsultarOServidor() + { + var repository = CreateOfflineRepository(out _); + + (await repository.GetAsync("nao-e-objectid")).Should().BeNull(); + (await repository.GetAsync((object)null!)).Should().BeNull(); + } + + [Fact] + public async Task LoadAsync_ComChaveNaoObjectId_RetornaNullSemConsultarOServidor() + { + var repository = CreateOfflineRepository(out _); + + (await repository.LoadAsync("nao-e-objectid")).Should().BeNull(); + } + + [Fact] + public void Dispose_DeveSerIdempotente() + { + var repository = CreateOfflineRepository(out _); + + var act = () => + { + repository.Dispose(); + repository.Dispose(); + }; + + act.Should().NotThrow(); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkTests.cs new file mode 100644 index 0000000..91565ba --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkTests.cs @@ -0,0 +1,256 @@ +using FluentAssertions; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +/// +/// Testes de integração do MongoUnitOfWork — exigem replica set (a fixture +/// sobe o mongod efêmero com UseSingleNodeReplicaSet). +/// +[Collection("Mongo")] +public class MongoUnitOfWorkTests +{ + private readonly MongoFixture _fixture; + + public MongoUnitOfWorkTests(MongoFixture fixture) + { + _fixture = fixture; + if (_fixture.IsAvailable) + _fixture.ResetCollection(); + } + + private MongoUnitOfWork CreateUnitOfWork() => new(_fixture.Client!); + + [SkippableFact] + public void BeginTransaction_DeveAbrirSessaoComTransacaoAtiva() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + + uow.CurrentSession.Should().BeNull(); + + uow.BeginTransaction(); + + uow.CurrentSession.Should().NotBeNull(); + uow.CurrentSession!.IsInTransaction.Should().BeTrue(); + } + + [SkippableFact] + public void BeginTransaction_DuasVezes_DeveLancarInvalidOperationException() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + var act = () => uow.BeginTransaction(); + act.Should().Throw(); + } + + [SkippableFact] + public void Commit_DeveEncerrarASessao() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + // Operação dentro da sessão da transação. + _fixture.GadgetCollection.InsertOne(uow.CurrentSession, new Gadget { Name = "NaSessao", Price = 1 }); + + uow.Commit(); + + uow.CurrentSession.Should().BeNull(); + _fixture.GadgetCollection.CountDocuments(g => g.Name == "NaSessao").Should().Be(1); + } + + [SkippableFact] + public void Rollback_DeveDescartarOperacoesFeitasNaSessao() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + _fixture.GadgetCollection.InsertOne(uow.CurrentSession, new Gadget { Name = "Descartado", Price = 1 }); + + uow.Rollback(); + + uow.CurrentSession.Should().BeNull(); + _fixture.GadgetCollection.CountDocuments(g => g.Name == "Descartado").Should().Be(0); + } + + // BUG?: o MongoRepository NUNCA usa a sessão do MongoUnitOfWork + // (CurrentSession) — todas as operações do repositório executam FORA da + // transação. Rollback no UnitOfWork NÃO desfaz um Save feito pelo + // repositório. Registrado em tests/FINDINGS-D.md. + [SkippableFact] + public void Rollback_Caracterizacao_NaoDesfazOperacoesDoRepositorio() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + var repository = _fixture.CreateRepository(); + + uow.BeginTransaction(); + repository.Save(new Gadget { Name = "ForaDaTransacao", Price = 1 }); + uow.Rollback(); + + // Comportamento atual: o documento sobrevive ao rollback, pois o + // repositório não participa da sessão transacional. + _fixture.GadgetCollection.CountDocuments(g => g.Name == "ForaDaTransacao") + .Should().Be(1, "o repositório não usa a sessão do UnitOfWork"); + } + + [SkippableFact] + public void Commit_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + + var act = () => uow.Commit(); + act.Should().Throw(); + } + + [SkippableFact] + public void Rollback_SemTransacaoAtiva_DeveSerNoOp() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + + var act = () => uow.Rollback(); + act.Should().NotThrow(); + } + + [SkippableFact] + public void InTransaction_DeveComitarERetornarResultado() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + + var result = uow.InTransaction(() => + { + var gadget = new Gadget { Name = "InTx", Price = 1 }; + _fixture.GadgetCollection.InsertOne(uow.CurrentSession, gadget); + return gadget; + }); + + result.Should().NotBeNull(); + uow.CurrentSession.Should().BeNull(); + _fixture.GadgetCollection.CountDocuments(g => g.Name == "InTx").Should().Be(1); + } + + [SkippableFact] + public void InTransaction_ComExcecao_DeveReverterERelancar() + { + _fixture.EnsureAvailable(); + using var uow = CreateUnitOfWork(); + + var act = () => uow.InTransaction(() => + { + _fixture.GadgetCollection.InsertOne(uow.CurrentSession, new Gadget { Name = "Falha", Price = 1 }); + throw new InvalidDataException("boom"); + }); + + act.Should().Throw(); + _fixture.GadgetCollection.CountDocuments(g => g.Name == "Falha").Should().Be(0); + } + + [SkippableFact] + public async Task CommitAsync_DeveEncerrarASessao() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + await uow.BeginTransactionAsync(); + + await _fixture.GadgetCollection.InsertOneAsync( + uow.CurrentSession, new Gadget { Name = "AsyncCommit", Price = 1 }); + + await uow.CommitAsync(); + + uow.CurrentSession.Should().BeNull(); + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Name == "AsyncCommit")).Should().Be(1); + } + + [SkippableFact] + public async Task RollbackAsync_DeveDescartarOperacoesFeitasNaSessao() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + await uow.BeginTransactionAsync(); + + await _fixture.GadgetCollection.InsertOneAsync( + uow.CurrentSession, new Gadget { Name = "AsyncRollback", Price = 1 }); + + await uow.RollbackAsync(); + + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Name == "AsyncRollback")).Should().Be(0); + } + + [SkippableFact] + public async Task CommitAsync_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [SkippableFact] + public async Task BeginTransactionAsync_DuasVezes_DeveLancarInvalidOperationException() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + await uow.BeginTransactionAsync(); + + var act = () => uow.BeginTransactionAsync(); + await act.Should().ThrowAsync(); + } + + [SkippableFact] + public async Task InTransactionAsync_DeveComitarERetornarResultado() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + + var result = await uow.InTransactionAsync(async () => + { + var gadget = new Gadget { Name = "InTxAsync", Price = 1 }; + await _fixture.GadgetCollection.InsertOneAsync(uow.CurrentSession, gadget); + return gadget; + }); + + result.Should().NotBeNull(); + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Name == "InTxAsync")).Should().Be(1); + } + + [SkippableFact] + public async Task InTransactionAsync_ComExcecao_DeveReverterERelancar() + { + _fixture.EnsureAvailable(); + await using var uow = CreateUnitOfWork(); + + var act = () => uow.InTransactionAsync(async () => + { + await _fixture.GadgetCollection.InsertOneAsync( + uow.CurrentSession, new Gadget { Name = "FalhaAsync", Price = 1 }); + throw new InvalidDataException("boom"); + }); + + await act.Should().ThrowAsync(); + (await _fixture.GadgetCollection.CountDocumentsAsync(g => g.Name == "FalhaAsync")).Should().Be(0); + } + + [SkippableFact] + public void Dispose_ComTransacaoPendente_DeveDescartarASessao() + { + _fixture.EnsureAvailable(); + var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + _fixture.GadgetCollection.InsertOne(uow.CurrentSession, new Gadget { Name = "Pendente", Price = 1 }); + + uow.Dispose(); + + // A sessão é descartada sem commit — a transação morre com ela. + _fixture.GadgetCollection.CountDocuments(g => g.Name == "Pendente").Should().Be(0); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkUnitTests.cs b/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkUnitTests.cs new file mode 100644 index 0000000..8bacd4a --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/MongoUnitOfWorkUnitTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +/// +/// Testes de unidade do MongoUnitOfWork que não precisam de servidor +/// (construção, estados sem transação e dispose). +/// +public class MongoUnitOfWorkUnitTests +{ + private static MongoUnitOfWork CreateOfflineUnitOfWork() => + new(new MongoClient("mongodb://localhost:27017/?serverSelectionTimeoutMS=100")); + + [Fact] + public void Construtor_ComClientNulo_DeveLancarArgumentNullException() + { + var act = () => new MongoUnitOfWork(null!); + act.Should().Throw(); + } + + [Fact] + public void CurrentSession_SemTransacao_DeveSerNull() + { + using var uow = CreateOfflineUnitOfWork(); + + uow.CurrentSession.Should().BeNull(); + } + + [Fact] + public void Commit_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.Commit(); + act.Should().Throw(); + } + + [Fact] + public async Task CommitAsync_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + await using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Rollback_SemTransacaoAtiva_DeveSerNoOp() + { + using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.Rollback(); + act.Should().NotThrow(); + } + + [Fact] + public async Task RollbackAsync_SemTransacaoAtiva_DeveSerNoOp() + { + await using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.RollbackAsync(); + await act.Should().NotThrowAsync(); + } + + [Fact] + public void InTransaction_ComWorkNulo_DeveLancarArgumentNullException() + { + using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.InTransaction(null!); + act.Should().Throw(); + } + + [Fact] + public async Task InTransactionAsync_ComWorkNulo_DeveLancarArgumentNullException() + { + await using var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.InTransactionAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public void Dispose_DeveSerIdempotente() + { + var uow = CreateOfflineUnitOfWork(); + + var act = () => + { + uow.Dispose(); + uow.Dispose(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public async Task DisposeAsync_DeveSerSeguroSemTransacao() + { + var uow = CreateOfflineUnitOfWork(); + + var act = () => uow.DisposeAsync().AsTask(); + await act.Should().NotThrowAsync(); + } +} diff --git a/tests/Codout.Framework.Mongo.Tests/TestInfrastructure.cs b/tests/Codout.Framework.Mongo.Tests/TestInfrastructure.cs new file mode 100644 index 0000000..9b3e88f --- /dev/null +++ b/tests/Codout.Framework.Mongo.Tests/TestInfrastructure.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using Codout.Framework.Data.Entity; +using EphemeralMongo; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; + +namespace Codout.Framework.Mongo.Tests; + +// --------------------------------------------------------------------------- +// Infraestrutura compartilhada pelos testes de MongoRepository / MongoUnitOfWork. +// Os testes de integração usam EphemeralMongo (baixa o binário do mongod em +// runtime) com replica set de nó único — necessário para transações. Se o +// mongod não subir neste ambiente, a fixture captura a falha e os testes de +// integração ([SkippableFact]) são marcados como SKIPPED com a razão. +// --------------------------------------------------------------------------- + +/// +/// Entidade de teste com Id ObjectId — o MongoRepository só consegue resolver +/// chaves que parseiam como ObjectId (ver FINDINGS-D.md). +/// +public class Gadget : IEntity +{ + public ObjectId Id { get; set; } + public string Name { get; set; } = ""; + public int Price { get; set; } + + public IEnumerable GetSignatureProperties() => []; + + public bool IsTransient() => Id == ObjectId.Empty; +} + +/// +/// Fixture de coleção: sobe um único mongod efêmero (replica set de nó único) +/// para todos os testes de integração. Indisponibilidade vira SkipReason. +/// +public sealed class MongoFixture : IDisposable +{ + private readonly IMongoRunner? _runner; + + public IMongoClient? Client { get; } + public IMongoDatabase? Database { get; } + public string SkipReason { get; } = ""; + + public bool IsAvailable => Database != null; + + public MongoFixture() + { + try + { + var options = new MongoRunnerOptions + { + UseSingleNodeReplicaSet = true, // transações exigem replica set + StandardOutputLogger = _ => { }, + StandardErrorLogger = _ => { }, + }; + + _runner = MongoRunner.Run(options); + var client = new MongoClient(_runner.ConnectionString); + var database = client.GetDatabase("codout_mongo_tests"); + + // Garante que o servidor responde antes de liberar os testes. + database.RunCommand(new BsonDocument("ping", 1)); + + Client = client; + Database = database; + } + catch (Exception ex) + { + SkipReason = "mongod efêmero (EphemeralMongo) indisponível neste ambiente: " + + $"{ex.GetType().Name}: {ex.Message}"; + _runner?.Dispose(); + _runner = null; + } + } + + /// Marca o teste como SKIPPED quando o mongod não subiu. + public void EnsureAvailable() => Skip.IfNot(IsAvailable, SkipReason); + + /// Coleção usada pelo MongoRepository<Gadget> (nome em minúsculas). + public IMongoCollection GadgetCollection => + Database!.GetCollection("gadget"); + + public MongoRepository CreateRepository() => new(Database!); + + public void ResetCollection() => + GadgetCollection.DeleteMany(FilterDefinition.Empty); + + /// Insere um Gadget direto na coleção, por fora do repositório. + public Gadget Seed(string name, int price = 0) + { + var gadget = new Gadget { Name = name, Price = price }; + GadgetCollection.InsertOne(gadget); + return gadget; + } + + public void Dispose() => _runner?.Dispose(); +} + +[CollectionDefinition("Mongo")] +public class MongoCollection : ICollectionFixture; diff --git a/tests/Codout.Framework.NH.Tests/Codout.Framework.NH.Tests.csproj b/tests/Codout.Framework.NH.Tests/Codout.Framework.NH.Tests.csproj new file mode 100644 index 0000000..dbf4378 --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/Codout.Framework.NH.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.NH.Tests/NHRepositoryAsyncTests.cs b/tests/Codout.Framework.NH.Tests/NHRepositoryAsyncTests.cs new file mode 100644 index 0000000..9eba7c8 --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/NHRepositoryAsyncTests.cs @@ -0,0 +1,225 @@ +using FluentAssertions; +using NHibernate; +using Xunit; + +namespace Codout.Framework.NH.Tests; + +[Collection("NH")] +public class NHRepositoryAsyncTests : IDisposable +{ + private readonly NHSqliteFixture _fixture; + private readonly ISession _session; + private readonly NHRepository _repository; + + public NHRepositoryAsyncTests(NHSqliteFixture fixture) + { + _fixture = fixture; + _fixture.ResetDatabase(); + _session = _fixture.OpenSession(); + _repository = new NHRepository(_session); + } + + public void Dispose() => _session.Dispose(); + + [Fact] + public async Task SaveAsync_DevePersistirEntidade() + { + var widget = await _repository.SaveAsync(new Widget { Name = "Async", Stock = 1 }); + + widget.Id.Should().BePositive(); + + using var otherSession = _fixture.OpenSession(); + (await otherSession.GetAsync(widget.Id)).Should().NotBeNull(); + } + + [Fact] + public async Task SaveAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.SaveAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetAsync_PorChave_DeveRetornarEntidade() + { + var seeded = _fixture.Seed("BuscaAsync", 9); + + var found = await _repository.GetAsync(seeded.Id); + + found.Should().NotBeNull(); + found!.Stock.Should().Be(9); + } + + [Fact] + public async Task GetAsync_PorChaveInexistente_DeveRetornarNull() + { + (await _repository.GetAsync(99999)).Should().BeNull(); + } + + [Fact] + public async Task GetAsync_PorPredicado_DeveRetornarEntidadeUnica() + { + _fixture.Seed("Unico", 3); + + var found = await _repository.GetAsync(w => w.Name == "Unico"); + + found.Should().NotBeNull(); + found.Stock.Should().Be(3); + } + + [Fact] + public async Task LoadAsync_DeveRetornarEntidadeExistente() + { + var seeded = _fixture.Seed("LoadAsync", 2); + + var loaded = await _repository.LoadAsync(seeded.Id); + + loaded!.Name.Should().Be("LoadAsync"); + } + + [Fact] + public async Task FirstOrDefaultAsync_DeveRetornarPrimeiroOuNull() + { + _fixture.Seed("Primeiro", 1); + _fixture.Seed("Primeiro", 2); + + var found = await _repository.FirstOrDefaultAsync(w => w.Name == "Primeiro"); + var missing = await _repository.FirstOrDefaultAsync(w => w.Name == "Inexistente"); + + found.Should().NotBeNull(); + missing.Should().BeNull(); + } + + [Fact] + public async Task AnyAsync_DeveIndicarExistencia() + { + _fixture.Seed("Existe", 1); + + (await _repository.AnyAsync(w => w.Name == "Existe")).Should().BeTrue(); + (await _repository.AnyAsync(w => w.Name == "NaoExiste")).Should().BeFalse(); + } + + [Fact] + public async Task CountAsync_DeveContarPeloPredicado() + { + _fixture.Seed("A", 1); + _fixture.Seed("B", 5); + _fixture.Seed("C", 8); + + (await _repository.CountAsync(w => w.Stock >= 5)).Should().Be(2); + } + + [Fact] + public async Task ToListAsync_DeveMaterializarPeloPredicado() + { + _fixture.Seed("L1", 1); + _fixture.Seed("L2", 2); + _fixture.Seed("L3", 3); + + var list = await _repository.ToListAsync(w => w.Stock > 1); + + list.Should().HaveCount(2); + list.Should().OnlyContain(w => w.Stock > 1); + } + + [Fact] + public async Task UpdateAsync_DevePersistirAlteracoes() + { + var seeded = _fixture.Seed("UpAsync", 1); + + var entity = (await _repository.GetAsync(seeded.Id))!; + entity.Stock = 55; + await _repository.UpdateAsync(entity); + await _session.FlushAsync(); + + using var otherSession = _fixture.OpenSession(); + (await otherSession.GetAsync(seeded.Id)).Stock.Should().Be(55); + } + + [Fact] + public async Task SaveOrUpdateAsync_TransienteInsere_DestacadaAtualiza() + { + var inserted = await _repository.SaveOrUpdateAsync(new Widget { Name = "SoU", Stock = 1 }); + await _session.FlushAsync(); + inserted.Id.Should().BePositive(); + + var seeded = _fixture.Seed("SoU2", 1); + seeded.Stock = 22; + await _repository.SaveOrUpdateAsync(seeded); + await _session.FlushAsync(); + + using var otherSession = _fixture.OpenSession(); + (await otherSession.GetAsync(seeded.Id)).Stock.Should().Be(22); + } + + [Fact] + public async Task DeleteAsync_DeveRemoverEntidade() + { + var seeded = _fixture.Seed("DelAsync", 1); + + var entity = (await _repository.GetAsync(seeded.Id))!; + await _repository.DeleteAsync(entity); + await _session.FlushAsync(); + + using var otherSession = _fixture.OpenSession(); + (await otherSession.GetAsync(seeded.Id)).Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_PorPredicado_DeveRemoverApenasCorrespondentes() + { + _fixture.Seed("DelPred", 1); + _fixture.Seed("DelPred", 2); + _fixture.Seed("Fica", 3); + + await _repository.DeleteAsync(w => w.Name == "DelPred"); + await _session.FlushAsync(); + + using var otherSession = _fixture.OpenSession(); + var remaining = otherSession.Query().ToList(); + remaining.Should().ContainSingle(w => w.Name == "Fica"); + } + + [Fact] + public async Task MergeAsync_DeveAtualizarAPartirDeInstanciaDestacada() + { + var seeded = _fixture.Seed("MergeAsync", 1); + seeded.Stock = 33; + + var merged = await _repository.MergeAsync(seeded); + await _session.FlushAsync(); + + merged.Stock.Should().Be(33); + + using var otherSession = _fixture.OpenSession(); + (await otherSession.GetAsync(seeded.Id)).Stock.Should().Be(33); + } + + [Fact] + public async Task RefreshAsync_DeveRecarregarEstadoDoBanco() + { + var seeded = _fixture.Seed("RefAsync", 10); + + var entity = (await _repository.GetAsync(seeded.Id))!; + + using (var otherSession = _fixture.OpenSession()) + using (var tx = otherSession.BeginTransaction()) + { + var other = await otherSession.GetAsync(seeded.Id); + other.Stock = 321; + await tx.CommitAsync(); + } + + var refreshed = await _repository.RefreshAsync(entity); + + refreshed.Should().BeSameAs(entity); + refreshed.Stock.Should().Be(321); + } + + [Fact] + public async Task DeleteAsync_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.DeleteAsync((Widget)null!); + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Codout.Framework.NH.Tests/NHRepositoryCrudTests.cs b/tests/Codout.Framework.NH.Tests/NHRepositoryCrudTests.cs new file mode 100644 index 0000000..e6af21d --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/NHRepositoryCrudTests.cs @@ -0,0 +1,267 @@ +using FluentAssertions; +using NHibernate; +using Xunit; + +namespace Codout.Framework.NH.Tests; + +[Collection("NH")] +public class NHRepositoryCrudTests : IDisposable +{ + private readonly NHSqliteFixture _fixture; + private readonly ISession _session; + private readonly NHRepository _repository; + + public NHRepositoryCrudTests(NHSqliteFixture fixture) + { + _fixture = fixture; + _fixture.ResetDatabase(); + _session = _fixture.OpenSession(); + _repository = new NHRepository(_session); + } + + public void Dispose() => _session.Dispose(); + + [Fact] + public void Construtor_ComSessionNula_DeveLancarArgumentNullException() + { + var act = () => new NHRepository(null!); + act.Should().Throw(); + } + + [Fact] + public void Save_DevePersistirEntidadeEGerarId() + { + var widget = _repository.Save(new Widget { Name = "Parafuso", Stock = 10 }); + + widget.Id.Should().BePositive("o generator identity atribui o Id no Save"); + widget.IsTransient().Should().BeFalse(); + + using var otherSession = _fixture.OpenSession(); + var persisted = otherSession.Get(widget.Id); + persisted.Should().NotBeNull(); + persisted.Name.Should().Be("Parafuso"); + persisted.Stock.Should().Be(10); + } + + [Fact] + public void Save_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.Save(null!); + act.Should().Throw(); + } + + [Fact] + public void Get_PorChave_DeveRetornarEntidade() + { + var seeded = _fixture.Seed("Porca", 5); + + var found = _repository.Get(seeded.Id); + + found.Should().NotBeNull(); + found.Name.Should().Be("Porca"); + } + + [Fact] + public void Get_PorChaveInexistente_DeveRetornarNull() + { + _repository.Get(99999).Should().BeNull(); + } + + [Fact] + public void Get_PorPredicado_DeveRetornarEntidadeUnica() + { + _fixture.Seed("Arruela", 1); + _fixture.Seed("Prego", 2); + + var found = _repository.Get(w => w.Name == "Prego"); + + found.Should().NotBeNull(); + found.Stock.Should().Be(2); + } + + [Fact] + public void Get_PorPredicadoComMultiplosResultados_DeveLancarExcecao() + { + _fixture.Seed("Duplicado", 1); + _fixture.Seed("Duplicado", 2); + + var act = () => _repository.Get(w => w.Name == "Duplicado"); + + // Usa SingleOrDefault — mais de um resultado é erro. + act.Should().Throw(); + } + + [Fact] + public void Load_ChaveExistente_DeveRetornarEntidade() + { + var seeded = _fixture.Seed("Martelo", 3); + + var loaded = _repository.Load(seeded.Id); + + loaded.Name.Should().Be("Martelo"); + } + + [Fact] + public void Load_ChaveInexistente_DeveLancarAoAcessarProxy() + { + var proxy = _repository.Load(99999); + + // Load retorna proxy sem ir ao banco; o acesso a um membro dispara a carga. + var act = () => _ = proxy.Name; + act.Should().Throw(); + } + + [Fact] + public void Update_DevePersistirAlteracoes() + { + var seeded = _fixture.Seed("Chave", 1); + + var entity = _repository.Get(seeded.Id); + entity.Stock = 42; + _repository.Update(entity); + _session.Flush(); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(seeded.Id).Stock.Should().Be(42); + } + + [Fact] + public void Update_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.Update(null!); + act.Should().Throw(); + } + + [Fact] + public void Delete_DeveRemoverEntidade() + { + var seeded = _fixture.Seed("Lixa", 1); + + var entity = _repository.Get(seeded.Id); + _repository.Delete(entity); + _session.Flush(); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(seeded.Id).Should().BeNull(); + } + + [Fact] + public void Delete_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.Delete((Widget)null!); + act.Should().Throw(); + } + + [Fact] + public void Delete_PorPredicado_DeveRemoverApenasCorrespondentes() + { + _fixture.Seed("Remover", 1); + _fixture.Seed("Remover", 2); + _fixture.Seed("Manter", 3); + + _repository.Delete(w => w.Name == "Remover"); + _session.Flush(); + + using var otherSession = _fixture.OpenSession(); + var remaining = otherSession.Query().ToList(); + remaining.Should().ContainSingle(w => w.Name == "Manter"); + } + + [Fact] + public void SaveOrUpdate_ComEntidadeTransiente_DeveInserir() + { + var widget = _repository.SaveOrUpdate(new Widget { Name = "Novo", Stock = 1 }); + _session.Flush(); + + widget.Id.Should().BePositive(); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(widget.Id).Should().NotBeNull(); + } + + [Fact] + public void SaveOrUpdate_ComEntidadeDestacada_DeveAtualizar() + { + var seeded = _fixture.Seed("Velho", 1); + seeded.Stock = 99; // instância destacada (sessão da Seed já foi fechada) + + _repository.SaveOrUpdate(seeded); + _session.Flush(); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(seeded.Id).Stock.Should().Be(99); + } + + [Fact] + public void SaveOrUpdate_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.SaveOrUpdate(null!); + act.Should().Throw(); + } + + [Fact] + public void Merge_ComEntidadeDestacada_DeveRetornarInstanciaPersistenteAtualizada() + { + var seeded = _fixture.Seed("Mesclar", 1); + + var attached = _repository.Get(seeded.Id); + seeded.Stock = 77; + + var merged = _repository.Merge(seeded); + _session.Flush(); + + merged.Should().BeSameAs(attached, "Merge devolve a instância já associada à sessão"); + merged.Stock.Should().Be(77); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(seeded.Id).Stock.Should().Be(77); + } + + [Fact] + public void Merge_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.Merge(null!); + act.Should().Throw(); + } + + [Fact] + public void Refresh_DeveRecarregarEstadoDoBanco() + { + var seeded = _fixture.Seed("Atualizar", 10); + + var entity = _repository.Get(seeded.Id); + + // Altera o banco por fora da sessão corrente. + using (var otherSession = _fixture.OpenSession()) + using (var tx = otherSession.BeginTransaction()) + { + var other = otherSession.Get(seeded.Id); + other.Stock = 123; + tx.Commit(); + } + + var refreshed = _repository.Refresh(entity); + + refreshed.Should().BeSameAs(entity); + refreshed.Stock.Should().Be(123); + } + + [Fact] + public void Refresh_ComEntidadeNula_DeveLancarArgumentNullException() + { + var act = () => _repository.Refresh(null!); + act.Should().Throw(); + } + + [Fact] + public void Dispose_DeveSerIdempotenteENaoFecharASessao() + { + var repository = new NHRepository(_session); + + repository.Dispose(); + repository.Dispose(); + + // A sessão pertence ao UnitOfWork — o repositório não a fecha. + _session.IsOpen.Should().BeTrue(); + } +} diff --git a/tests/Codout.Framework.NH.Tests/NHRepositoryQueryTests.cs b/tests/Codout.Framework.NH.Tests/NHRepositoryQueryTests.cs new file mode 100644 index 0000000..c2c5872 --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/NHRepositoryQueryTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using NHibernate; +using Xunit; + +namespace Codout.Framework.NH.Tests; + +[Collection("NH")] +public class NHRepositoryQueryTests : IDisposable +{ + private readonly NHSqliteFixture _fixture; + private readonly ISession _session; + private readonly NHRepository _repository; + + public NHRepositoryQueryTests(NHSqliteFixture fixture) + { + _fixture = fixture; + _fixture.ResetDatabase(); + _session = _fixture.OpenSession(); + _repository = new NHRepository(_session); + } + + public void Dispose() => _session.Dispose(); + + private void SeedMany(int count, string prefix = "Item") + { + for (var i = 1; i <= count; i++) + _fixture.Seed($"{prefix}-{i:D2}", i); + } + + [Fact] + public void All_DeveRetornarTodasAsEntidades() + { + SeedMany(3); + + _repository.All().ToList().Should().HaveCount(3); + } + + [Fact] + public void All_SemDados_DeveRetornarVazio() + { + _repository.All().ToList().Should().BeEmpty(); + } + + [Fact] + public void Where_DeveFiltrarPeloPredicado() + { + SeedMany(5); + + var result = _repository.Where(w => w.Stock > 3).ToList(); + + result.Should().HaveCount(2); + result.Should().OnlyContain(w => w.Stock > 3); + } + + [Fact] + public void Where_DeveComporComOperadoresLinq() + { + SeedMany(5); + + var result = _repository.Where(w => w.Stock >= 2) + .OrderByDescending(w => w.Stock) + .Select(w => w.Name) + .First(); + + result.Should().Be("Item-05"); + } + + [Fact] + public void WherePaged_DeveRetornarTotalDoFiltroEPaginar() + { + SeedMany(7); + + var page = _repository.WherePaged(w => w.Stock >= 3, out var total, index: 0, size: 2) + .OrderBy(w => w.Stock) + .ToList(); + + total.Should().Be(5, "o total reflete todos os registros do filtro, não só a página"); + page.Should().HaveCount(2); + page.Select(w => w.Stock).Should().ContainInOrder(3, 4); + } + + [Fact] + public void WherePaged_SegundaPagina_DevePularItensAnteriores() + { + SeedMany(7); + + var page = _repository.WherePaged(w => w.Stock >= 1, out var total, index: 2, size: 3) + .OrderBy(w => w.Stock) + .ToList(); + + total.Should().Be(7); + page.Should().HaveCount(1, "a última página contém só o resto"); + page[0].Stock.Should().Be(7); + } + + // BUG?: WherePaged aplica Skip/Take na ordem natural da query sem exigir + // OrderBy — em SQL o resultado de paginação sem ORDER BY não é determinístico. + // Aqui apenas caracterizamos que a chamada funciona sem ordenação explícita. + [Fact] + public void WherePaged_SemOrdenacaoExplicita_NaoFalha() + { + SeedMany(4); + + var page = _repository.WherePaged(w => w.Stock > 0, out var total, index: 0, size: 10).ToList(); + + total.Should().Be(4); + page.Should().HaveCount(4); + } + + [Fact] + public void AllReadOnly_DeveRetornarTodasAsEntidades() + { + SeedMany(3); + + _repository.AllReadOnly().ToList().Should().HaveCount(3); + } + + [Fact] + public void AllReadOnly_DeveMarcarASessaoComoReadOnly() + { + _session.DefaultReadOnly.Should().BeFalse(); + + _repository.AllReadOnly(); + + _session.DefaultReadOnly.Should().BeTrue(); + } + + // BUG?: AllReadOnly/WhereReadOnly setam Session.DefaultReadOnly = true e NUNCA + // restauram o valor — a sessão INTEIRA vira read-only dali em diante. Qualquer + // entidade carregada depois (mesmo via All()/Get()) deixa de ser rastreada + // pelo dirty-check e alterações silenciosamente não são persistidas no Flush. + // Registrado em tests/FINDINGS-D.md. + [Fact] + public void AllReadOnly_EfeitoColateral_EntidadesCarregadasDepoisNaoSaoPersistidas() + { + var seeded = _fixture.Seed("Colateral", 1); + + _repository.AllReadOnly(); // liga o modo read-only da sessão + + var entity = _repository.Get(seeded.Id); // carregada APÓS o AllReadOnly + entity.Stock = 999; + _session.Flush(); + + using var otherSession = _fixture.OpenSession(); + otherSession.Get(seeded.Id).Stock + .Should().Be(1, "a sessão ficou read-only e o dirty-check foi desligado"); + } + + [Fact] + public void WhereReadOnly_DeveFiltrarEMarcarASessaoComoReadOnly() + { + SeedMany(4); + + var result = _repository.WhereReadOnly(w => w.Stock <= 2).ToList(); + + result.Should().HaveCount(2); + _session.DefaultReadOnly.Should().BeTrue(); + } + + [Fact] + public void IncludeMany_DeveRetornarQueryableSemFalhar() + { + SeedMany(2); + + // NHibernate não suporta Include — o método devolve All(). + _repository.IncludeMany(w => w.Name).ToList().Should().HaveCount(2); + } +} diff --git a/tests/Codout.Framework.NH.Tests/NHUnitOfWorkTests.cs b/tests/Codout.Framework.NH.Tests/NHUnitOfWorkTests.cs new file mode 100644 index 0000000..0947cac --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/NHUnitOfWorkTests.cs @@ -0,0 +1,261 @@ +using System.Data; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.NH.Tests; + +[Collection("NH")] +public class NHUnitOfWorkTests(NHSqliteFixture fixture) +{ + private readonly NHSqliteFixture _fixture = Reset(fixture); + + private static NHSqliteFixture Reset(NHSqliteFixture fixture) + { + fixture.ResetDatabase(); + return fixture; + } + + private NHUnitOfWork CreateUnitOfWork() => new(_fixture.OpenSession()); + + private int CountWidgets() + { + using var session = _fixture.OpenSession(); + return session.Query().Count(); + } + + [Fact] + public void Commit_DevePersistirAlteracoesDaTransacao() + { + using (var uow = new NHUnitOfWork(_fixture.OpenSession())) + { + var repository = new NHRepository(uow.Session); + uow.BeginTransaction(); + repository.Save(new Widget { Name = "Comitado", Stock = 1 }); + uow.Commit(); + } + + CountWidgets().Should().Be(1); + } + + [Fact] + public void Rollback_DeveDescartarAlteracoesDaTransacao() + { + using (var uow = CreateUnitOfWork()) + { + var repository = new NHRepository(uow.Session); + uow.BeginTransaction(); + repository.Save(new Widget { Name = "Descartado", Stock = 1 }); + uow.Rollback(); + } + + CountWidgets().Should().Be(0); + } + + [Fact] + public void Rollback_SemTransacaoAtiva_DeveSerNoOp() + { + using var uow = CreateUnitOfWork(); + + var act = () => uow.Rollback(); + act.Should().NotThrow(); + } + + [Fact] + public void Commit_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + using var uow = CreateUnitOfWork(); + + var act = () => uow.Commit(); + act.Should().Throw(); + } + + [Fact] + public void BeginTransaction_DuasVezes_DeveLancarInvalidOperationException() + { + using var uow = CreateUnitOfWork(); + uow.BeginTransaction(); + + var act = () => uow.BeginTransaction(); + act.Should().Throw(); + } + + [Fact] + public void BeginTransaction_AposCommit_DevePermitirNovaTransacao() + { + using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + uow.BeginTransaction(); + repository.Save(new Widget { Name = "Primeira", Stock = 1 }); + uow.Commit(); + + uow.BeginTransaction(); + repository.Save(new Widget { Name = "Segunda", Stock = 2 }); + uow.Commit(); + + CountWidgets().Should().Be(2); + } + + [Fact] + public void Commit_ComIsolationLevel_DeveDelegarParaCommit() + { + using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + uow.BeginTransaction(IsolationLevel.Serializable); + repository.Save(new Widget { Name = "Isolado", Stock = 1 }); + uow.Commit(IsolationLevel.Serializable); + + CountWidgets().Should().Be(1); + } + + [Fact] + public void InTransaction_DeveComitarERetornarResultado() + { + using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + var result = uow.InTransaction(() => repository.Save(new Widget { Name = "InTx", Stock = 1 })); + + result.Id.Should().BePositive(); + CountWidgets().Should().Be(1); + } + + [Fact] + public void InTransaction_ComExcecao_DeveReverterERelancar() + { + using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + var act = () => uow.InTransaction(() => + { + repository.Save(new Widget { Name = "Falha", Stock = 1 }); + throw new InvalidDataException("boom"); + }); + + act.Should().Throw(); + CountWidgets().Should().Be(0); + } + + [Fact] + public void InTransaction_ComWorkNulo_DeveLancarArgumentNullException() + { + using var uow = CreateUnitOfWork(); + + var act = () => uow.InTransaction(null!); + act.Should().Throw(); + } + + [Fact] + public void Dispose_ComTransacaoPendente_DeveReverterEFecharASessao() + { + var uow = CreateUnitOfWork(); + var session = uow.Session; + var repository = new NHRepository(session); + + uow.BeginTransaction(); + repository.Save(new Widget { Name = "Pendente", Stock = 1 }); + uow.Dispose(); + + session.IsOpen.Should().BeFalse("o UnitOfWork é dono da sessão"); + CountWidgets().Should().Be(0, "alterações não comitadas são revertidas no Dispose"); + } + + [Fact] + public async Task CommitAsync_DevePersistirAlteracoesDaTransacao() + { + await using (var uow = CreateUnitOfWork()) + { + var repository = new NHRepository(uow.Session); + await uow.BeginTransactionAsync(); + await repository.SaveAsync(new Widget { Name = "ComitadoAsync", Stock = 1 }); + await uow.CommitAsync(); + } + + CountWidgets().Should().Be(1); + } + + [Fact] + public async Task RollbackAsync_DeveDescartarAlteracoesDaTransacao() + { + await using (var uow = CreateUnitOfWork()) + { + var repository = new NHRepository(uow.Session); + await uow.BeginTransactionAsync(); + await repository.SaveAsync(new Widget { Name = "DescartadoAsync", Stock = 1 }); + await uow.RollbackAsync(); + } + + CountWidgets().Should().Be(0); + } + + [Fact] + public async Task CommitAsync_SemTransacaoAtiva_DeveLancarInvalidOperationException() + { + await using var uow = CreateUnitOfWork(); + + var act = () => uow.CommitAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RollbackAsync_SemTransacaoAtiva_DeveSerNoOp() + { + await using var uow = CreateUnitOfWork(); + + var act = () => uow.RollbackAsync(); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task BeginTransactionAsync_DuasVezes_DeveLancarInvalidOperationException() + { + await using var uow = CreateUnitOfWork(); + await uow.BeginTransactionAsync(); + + var act = () => uow.BeginTransactionAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InTransactionAsync_DeveComitarERetornarResultado() + { + await using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + var result = await uow.InTransactionAsync(() => + repository.SaveAsync(new Widget { Name = "InTxAsync", Stock = 1 })); + + result.Id.Should().BePositive(); + CountWidgets().Should().Be(1); + } + + [Fact] + public async Task InTransactionAsync_ComExcecao_DeveReverterERelancar() + { + await using var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + var act = () => uow.InTransactionAsync(async () => + { + await repository.SaveAsync(new Widget { Name = "FalhaAsync", Stock = 1 }); + throw new InvalidDataException("boom"); + }); + + await act.Should().ThrowAsync(); + CountWidgets().Should().Be(0); + } + + [Fact] + public async Task DisposeAsync_ComTransacaoPendente_DeveReverter() + { + var uow = CreateUnitOfWork(); + var repository = new NHRepository(uow.Session); + + await uow.BeginTransactionAsync(); + await repository.SaveAsync(new Widget { Name = "PendenteAsync", Stock = 1 }); + await uow.DisposeAsync(); + + CountWidgets().Should().Be(0); + } +} diff --git a/tests/Codout.Framework.NH.Tests/TestInfrastructure.cs b/tests/Codout.Framework.NH.Tests/TestInfrastructure.cs new file mode 100644 index 0000000..ac2ca7f --- /dev/null +++ b/tests/Codout.Framework.NH.Tests/TestInfrastructure.cs @@ -0,0 +1,119 @@ +using Codout.Framework.Domain.Entities; +using FluentNHibernate.Cfg; +using FluentNHibernate.Cfg.Db; +using FluentNHibernate.Mapping; +using NHibernate; +using NHibernate.Driver; +using NHibernate.Tool.hbm2ddl; +using Xunit; + +namespace Codout.Framework.NH.Tests; + +// --------------------------------------------------------------------------- +// Infraestrutura compartilhada pelos testes de NHRepository / NHUnitOfWork: +// entidade de teste, mapeamento FluentNHibernate e fixture com SessionFactory +// sobre SQLite (arquivo temporário, via MicrosoftDataSqliteDriver). +// --------------------------------------------------------------------------- + +/// Entidade simples com PK int identity (transient quando Id == 0). +public class Widget : Entity +{ + public virtual string Name { get; set; } = ""; + public virtual int Stock { get; set; } +} + +/// +/// Driver NHibernate para Microsoft.Data.Sqlite, usado somente nos testes. +/// O NHibernate 5.6.0 NÃO traz um MicrosoftDataSqliteDriver embutido (apenas +/// SQLite20Driver, que reflete sobre System.Data.SQLite) — ver FINDINGS-D.md. +/// +public class MicrosoftDataSqliteTestDriver : ReflectionBasedDriver +{ + public MicrosoftDataSqliteTestDriver() + : base( + "Microsoft.Data.Sqlite", + "Microsoft.Data.Sqlite", + "Microsoft.Data.Sqlite.SqliteConnection", + "Microsoft.Data.Sqlite.SqliteCommand") + { + } + + public override bool UseNamedPrefixInSql => true; + public override bool UseNamedPrefixInParameter => true; + public override string NamedPrefix => "@"; + public override bool SupportsMultipleOpenReaders => false; +} + +/// Mapeamento FluentNHibernate básico da entidade de teste. +public class WidgetMap : ClassMap +{ + public WidgetMap() + { + Table("widgets"); + Id(x => x.Id).GeneratedBy.Identity(); + Map(x => x.Name).Not.Nullable(); + Map(x => x.Stock); + } +} + +/// +/// Fixture de coleção: constrói um único ISessionFactory sobre um banco SQLite +/// em arquivo temporário (NHibernate abre/fecha conexões por sessão, então +/// :memory: não serve — o banco sumiria entre sessões). Cada classe de teste +/// limpa a tabela no construtor para garantir isolamento. +/// +public sealed class NHSqliteFixture : IDisposable +{ + private readonly string _dbFile; + + public ISessionFactory Factory { get; } + + public NHSqliteFixture() + { + _dbFile = Path.Combine(Path.GetTempPath(), $"codout-nh-tests-{Guid.NewGuid():N}.db"); + + Factory = Fluently.Configure() + .Database(SQLiteConfiguration.Standard + .Driver() + .ConnectionString($"Data Source={_dbFile}")) + .Mappings(m => m.FluentMappings.Add()) + .ExposeConfiguration(cfg => + { + // Microsoft.Data.Sqlite não implementa GetSchema("DataTypes"), + // usado pelo auto-import de keywords no build da factory. + cfg.SetProperty(NHibernate.Cfg.Environment.Hbm2ddlKeyWords, "none"); + new SchemaExport(cfg).Create(useStdOut: false, execute: true); + }) + .BuildSessionFactory(); + } + + public ISession OpenSession() => Factory.OpenSession(); + + /// Remove todas as linhas da tabela de teste. + public void ResetDatabase() + { + using var session = Factory.OpenSession(); + session.CreateSQLQuery("DELETE FROM widgets").ExecuteUpdate(); + } + + /// Insere um Widget já comitado, fora da sessão sob teste. + public Widget Seed(string name, int stock = 0) + { + using var session = Factory.OpenSession(); + using var tx = session.BeginTransaction(); + var widget = new Widget { Name = name, Stock = stock }; + session.Save(widget); + tx.Commit(); + return widget; + } + + public void Dispose() + { + Factory.Dispose(); + if (File.Exists(_dbFile)) + File.Delete(_dbFile); + } +} + +[CollectionDefinition("NH")] +public class NHCollection : ICollectionFixture; diff --git a/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageArgumentValidationTests.cs b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageArgumentValidationTests.cs new file mode 100644 index 0000000..0aadb83 --- /dev/null +++ b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageArgumentValidationTests.cs @@ -0,0 +1,174 @@ +using Codout.Framework.Storage.Azure; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Azure.Tests; + +/// +/// As validações de argumentos do AzureStorage ocorrem antes de qualquer I/O, +/// então são testáveis sem Azurite/conta Azure. Operações que exigem requisição +/// real (Exists/Download/List/SAS de blob existente etc.) não são cobertas aqui +/// — veja tests/FINDINGS-C.md. +/// +public class AzureStorageArgumentValidationTests +{ + private readonly AzureStorage _storage = new(TestConnectionStrings.Development); + + private static MemoryStream AnyStream() => new([1, 2, 3]); + + [Fact] + public async Task UploadAsync_ComStreamNulo_LancaArgumentNullException() + { + var acao = () => _storage.UploadAsync(null!, "docs", "a.txt"); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task UploadAsync_ComMetadata_ComStreamNulo_LancaArgumentNullException() + { + var acao = () => _storage.UploadAsync(null!, "docs", "a.txt", + new Dictionary()); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task UploadAsync_ComProgress_ComStreamNulo_LancaArgumentNullException() + { + var acao = () => _storage.UploadAsync(null!, "docs", "a.txt", new Progress()); + + await acao.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null, "a.txt", "container")] + [InlineData("", "a.txt", "container")] + [InlineData(" ", "a.txt", "container")] + [InlineData("docs", null, "fileName")] + [InlineData("docs", "", "fileName")] + [InlineData("docs", " ", "fileName")] + public async Task UploadAsync_ComContainerOuArquivoInvalido_LancaArgumentException( + string? container, string? fileName, string paramName) + { + using var stream = AnyStream(); + + var acao = () => _storage.UploadAsync(stream, container!, fileName!); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be(paramName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task DownloadAsync_ComContainerInvalido_LancaArgumentException(string? container) + { + var acao = () => _storage.DownloadAsync(container!, "a.txt"); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task GetStreamAsync_ComArquivoInvalido_LancaArgumentException() + { + var acao = () => _storage.GetStreamAsync("docs", " "); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_ComArquivoInvalido_LancaArgumentException() + { + var acao = () => _storage.DeleteAsync("docs", ""); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteManyAsync_ComListaNula_LancaArgumentNullException() + { + var acao = () => _storage.DeleteManyAsync("docs", null!); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteManyAsync_ComContainerInvalido_LancaArgumentException() + { + var acao = () => _storage.DeleteManyAsync(" ", ["a.txt"]); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be("container"); + } + + [Fact] + public async Task MoveToAsync_ComDestinoInvalido_LancaArgumentException() + { + var acao = () => _storage.MoveToAsync("origem", "", "a.txt"); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be("toContainer"); + } + + [Fact] + public async Task CopyToAsync_ComDestinoInvalido_LancaArgumentException() + { + var acao = () => _storage.CopyToAsync("origem", " ", "a.txt"); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be("toContainer"); + } + + [Fact] + public async Task CopyToAsync_ComOrigemInvalida_LancaArgumentException() + { + var acao = () => _storage.CopyToAsync("", "destino", "a.txt"); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be("container"); + } + + [Fact] + public async Task ListAsync_ComContainerInvalido_LancaArgumentException() + { + var acao = () => _storage.ListAsync(""); + + (await acao.Should().ThrowAsync()) + .And.ParamName.Should().Be("container"); + } + + [Fact] + public async Task GetMetadataAsync_ComArquivoInvalido_LancaArgumentException() + { + var acao = () => _storage.GetMetadataAsync("docs", ""); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task SetMetadataAsync_ComMetadataNula_LancaArgumentNullException() + { + var acao = () => _storage.SetMetadataAsync("docs", "a.txt", null!); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task GetSasUriAsync_ComContainerInvalido_LancaArgumentException() + { + var acao = () => _storage.GetSasUriAsync("", "a.txt", TimeSpan.FromHours(1)); + + await acao.Should().ThrowAsync(); + } + + [Theory] + [InlineData("", "a.txt")] + [InlineData("docs", "")] + public void GetBlobUri_ComArgumentosInvalidos_LancaArgumentException(string container, string fileName) + { + var acao = () => _storage.GetBlobUri(container, fileName); + + acao.Should().Throw(); + } +} diff --git a/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageBlobUriTests.cs b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageBlobUriTests.cs new file mode 100644 index 0000000..9f17b13 --- /dev/null +++ b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageBlobUriTests.cs @@ -0,0 +1,96 @@ +using Codout.Framework.Storage.Azure; +using Codout.Framework.Storage.Configuration; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Azure.Tests; + +/// +/// GetBlobUri é construído localmente pelo SDK (sem requisição), o que permite +/// testar a montagem de URIs (incluindo CDN) totalmente offline. +/// +public class AzureStorageBlobUriTests +{ + [Fact] + public void GetBlobUri_ComContaFicticia_DeveMontarUriDoBlobEndpoint() + { + var storage = new AzureStorage(TestConnectionStrings.FakeAccount); + + var uri = storage.GetBlobUri("docs", "relatorio.pdf"); + + uri.Should().Be(new Uri("https://contateste.blob.core.windows.net/docs/relatorio.pdf")); + } + + [Fact] + public void GetBlobUri_DeveNormalizarContainerParaMinusculas() + { + var storage = new AzureStorage(TestConnectionStrings.FakeAccount); + + var uri = storage.GetBlobUri("MeuContainer", "a.txt"); + + uri.AbsolutePath.Should().StartWith("/meucontainer/"); + } + + [Fact] + public void GetBlobUri_ComEmuladorDeDesenvolvimento_DeveUsarEndpointLocal() + { + var storage = new AzureStorage(TestConnectionStrings.Development); + + var uri = storage.GetBlobUri("docs", "a.txt"); + + uri.Should().Be(new Uri("http://127.0.0.1:10000/devstoreaccount1/docs/a.txt")); + } + + [Fact] + public void GetBlobUri_ComCdnHabilitado_DeveUsarEndpointDoCdn() + { + var storage = new AzureStorage(new AzureStorageOptions + { + ConnectionString = TestConnectionStrings.FakeAccount, + EnableCdn = true, + CdnEndpoint = "https://cdn.exemplo.com/" + }); + + var uri = storage.GetBlobUri("Docs", "relatorio.pdf"); + + // Trailing slash do endpoint é aparado e o container é normalizado. + uri.Should().Be(new Uri("https://cdn.exemplo.com/docs/relatorio.pdf")); + } + + [Fact] + public void GetBlobUri_ComCdnHabilitadoMasSemEndpoint_DeveCairNoBlobEndpoint() + { + var storage = new AzureStorage(new AzureStorageOptions + { + ConnectionString = TestConnectionStrings.FakeAccount, + EnableCdn = true, + CdnEndpoint = " " + }); + + var uri = storage.GetBlobUri("docs", "a.txt"); + + uri.Host.Should().Be("contateste.blob.core.windows.net"); + } + + [Fact] + public void GetBlobUri_ComCdn_NaoEscapaNomeDoArquivo() + { + // BUG?: o caminho do CDN é interpolado sem URL-encoding do fileName, + // enquanto o blob endpoint (SDK) escapa caracteres especiais — as duas + // formas geram URIs divergentes para o mesmo blob (ex.: espaço). + // Teste de caracterização do comportamento atual. + var comCdn = new AzureStorage(new AzureStorageOptions + { + ConnectionString = TestConnectionStrings.FakeAccount, + EnableCdn = true, + CdnEndpoint = "https://cdn.exemplo.com" + }); + var semCdn = new AzureStorage(TestConnectionStrings.FakeAccount); + + var uriCdn = comCdn.GetBlobUri("docs", "meu arquivo.txt"); + var uriBlob = semCdn.GetBlobUri("docs", "meu arquivo.txt"); + + uriCdn.OriginalString.Should().Be("https://cdn.exemplo.com/docs/meu arquivo.txt"); + uriBlob.AbsoluteUri.Should().Contain("meu%20arquivo.txt"); + } +} diff --git a/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageConstructionTests.cs b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageConstructionTests.cs new file mode 100644 index 0000000..cba7ff6 --- /dev/null +++ b/tests/Codout.Framework.Storage.Azure.Tests/AzureStorageConstructionTests.cs @@ -0,0 +1,93 @@ +using Codout.Framework.Storage.Azure; +using Codout.Framework.Storage.Configuration; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Azure.Tests; + +/// +/// Testes unitários puros — nenhum teste aqui toca rede ou conta Azure real. +/// O AzureStorage cria o BlobServiceClient de forma lazy, então construção e +/// validação de argumentos são totalmente testáveis offline. +/// +public class AzureStorageConstructionTests +{ + [Fact] + public void Ctor_ComOptionsNulas_LancaArgumentNullException() + { + var acao = () => new AzureStorage((AzureStorageOptions)null!); + + acao.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Ctor_SemConnectionString_LancaArgumentException(string? connectionString) + { + var acao = () => new AzureStorage(new AzureStorageOptions { ConnectionString = connectionString }); + + acao.Should().Throw() + .WithMessage("ConnectionString is required.*") + .And.ParamName.Should().Be("options"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Ctor_StringOverload_SemConnectionString_LancaArgumentException(string connectionString) + { + var acao = () => new AzureStorage(connectionString); + + acao.Should().Throw(); + } + + [Fact] + public void Ctor_ComConnectionStringDeDesenvolvimento_NaoLanca() + { + var acao = () => new AzureStorage(TestConnectionStrings.Development); + + acao.Should().NotThrow(); + } + + [Fact] + public void Ctor_ComConnectionStringInvalida_NaoLancaPorqueClientELazy() + { + // Caracterização: o BlobServiceClient só é criado no primeiro uso (Lazy), + // então uma connection string sintaticamente inválida não falha na construção. + var acao = () => new AzureStorage("isto-nao-e-uma-connection-string"); + + acao.Should().NotThrow(); + } + + [Fact] + public void GetBlobUri_ComConnectionStringInvalida_FalhaAoMaterializarClient() + { + // Caracterização: o erro de connection string inválida só aparece quando o + // client é materializado (primeiro acesso), e escapa como FormatException + // do SDK em vez de StorageException da abstração. + var storage = new AzureStorage("isto-nao-e-uma-connection-string"); + + var acao = () => storage.GetBlobUri("docs", "a.txt"); + + acao.Should().Throw(); + } +} + +internal static class TestConnectionStrings +{ + /// + /// Connection string padrão do emulador (Azurite). Nenhum teste conecta de fato: + /// é usada apenas para o SDK construir URIs localmente. + /// + public const string Development = "UseDevelopmentStorage=true"; + + /// + /// Connection string sintaticamente válida de conta fictícia (chave é base64 dummy). + /// + public static readonly string FakeAccount = + "DefaultEndpointsProtocol=https;AccountName=contateste;AccountKey=" + + Convert.ToBase64String("chave-de-teste-nao-e-um-segredo-real-0001"u8.ToArray()) + + ";EndpointSuffix=core.windows.net"; +} diff --git a/tests/Codout.Framework.Storage.Azure.Tests/Codout.Framework.Storage.Azure.Tests.csproj b/tests/Codout.Framework.Storage.Azure.Tests/Codout.Framework.Storage.Azure.Tests.csproj new file mode 100644 index 0000000..83a8bd6 --- /dev/null +++ b/tests/Codout.Framework.Storage.Azure.Tests/Codout.Framework.Storage.Azure.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Storage.Azure.Tests/ServiceCollectionExtensionsTests.cs b/tests/Codout.Framework.Storage.Azure.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..c53b09e --- /dev/null +++ b/tests/Codout.Framework.Storage.Azure.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,100 @@ +using Codout.Framework.Storage; +using Codout.Framework.Storage.Azure; +using Codout.Framework.Storage.Configuration; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codout.Framework.Storage.Azure.Tests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddAzureStorage_ComConfiguration_SemConnectionString_LancaInvalidOperationException() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var acao = () => new ServiceCollection().AddAzureStorage(configuration); + + acao.Should().Throw() + .WithMessage("AzureStorage connection string not found in configuration."); + } + + [Fact] + public void AddAzureStorage_ComConfiguration_DeveRegistrarIStorageSingleton() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:AzureStorage"] = TestConnectionStrings.Development + }) + .Build(); + + var provider = new ServiceCollection() + .AddAzureStorage(configuration) + .BuildServiceProvider(); + + var storage = provider.GetRequiredService(); + + storage.Should().BeOfType(); + provider.GetRequiredService().Should().BeSameAs(storage, "deve ser singleton"); + } + + [Fact] + public void AddAzureStorage_ComConnectionString_DeveRegistrarIStorage() + { + var provider = new ServiceCollection() + .AddAzureStorage(TestConnectionStrings.Development) + .BuildServiceProvider(); + + provider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void AddAzureStorage_ComOptionsNulas_LancaArgumentNullException() + { + var acao = () => new ServiceCollection().AddAzureStorage((AzureStorageOptions)null!); + + acao.Should().Throw(); + } + + [Fact] + public void AddAzureStorage_ComOptionsSemConnectionString_FalhaNoRegistro() + { + // Caracterização: a instância de AzureStorage é criada eagerly no momento + // do registro (AddSingleton com instância), então options inválidas falham + // já no AddAzureStorage, não no primeiro resolve. + var acao = () => new ServiceCollection().AddAzureStorage(new AzureStorageOptions()); + + acao.Should().Throw(); + } + + [Fact] + public void AddAzureStorage_ComDelegateNulo_LancaArgumentNullException() + { + var acao = () => new ServiceCollection().AddAzureStorage((Action)null!); + + acao.Should().Throw(); + } + + [Fact] + public void AddAzureStorage_ComDelegate_DeveAplicarConfiguracao() + { + var provider = new ServiceCollection() + .AddAzureStorage(options => + { + options.ConnectionString = TestConnectionStrings.FakeAccount; + options.EnableCdn = true; + options.CdnEndpoint = "https://cdn.exemplo.com"; + }) + .BuildServiceProvider(); + + var storage = (AzureStorage)provider.GetRequiredService(); + + storage.GetBlobUri("docs", "a.txt") + .Should().Be(new Uri("https://cdn.exemplo.com/docs/a.txt")); + } +} diff --git a/tests/Codout.Framework.Storage.Tests/Codout.Framework.Storage.Tests.csproj b/tests/Codout.Framework.Storage.Tests/Codout.Framework.Storage.Tests.csproj new file mode 100644 index 0000000..10cb3a5 --- /dev/null +++ b/tests/Codout.Framework.Storage.Tests/Codout.Framework.Storage.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Framework.Storage.Tests/InMemoryStorageContractTests.cs b/tests/Codout.Framework.Storage.Tests/InMemoryStorageContractTests.cs new file mode 100644 index 0000000..5f70f10 --- /dev/null +++ b/tests/Codout.Framework.Storage.Tests/InMemoryStorageContractTests.cs @@ -0,0 +1,284 @@ +using Codout.Framework.Storage.Exceptions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Tests; + +/// +/// O pacote Codout.Framework.Storage só define a abstração (não há implementação +/// local/file system no repositório). Estes testes usam um fake em memória para +/// verificar que o contrato IStorage é implementável de ponta a ponta e para +/// documentar a semântica esperada das operações (upload/download/exists/list/ +/// delete/copy/move/metadata/URIs). +/// +public class InMemoryStorageContractTests +{ + private readonly InMemoryStorage _storage = new(); + + private static MemoryStream StreamOf(string conteudo) => + new(System.Text.Encoding.UTF8.GetBytes(conteudo)); + + [Fact] + public async Task UploadAsync_DeveRetornarUriDoArquivo() + { + var uri = await _storage.UploadAsync(StreamOf("abc"), "docs", "a.txt"); + + uri.Should().Be(new Uri("memory://docs/a.txt")); + } + + [Fact] + public async Task DownloadAsync_DeveRetornarConteudoEnviado() + { + await _storage.UploadAsync(StreamOf("conteúdo"), "docs", "a.txt"); + + using var stream = await _storage.DownloadAsync("docs", "a.txt"); + using var reader = new StreamReader(stream); + + (await reader.ReadToEndAsync()).Should().Be("conteúdo"); + } + + [Fact] + public async Task DownloadAsync_ArquivoInexistente_LancaStorageNotFound() + { + var acao = () => _storage.DownloadAsync("docs", "nao-existe.txt"); + + await acao.Should().ThrowAsync(); + } + + [Fact] + public async Task ExistsAsync_DeveRefletirEstadoDoContainer() + { + (await _storage.ExistsAsync("docs", "a.txt")).Should().BeFalse(); + + await _storage.UploadAsync(StreamOf("x"), "docs", "a.txt"); + + (await _storage.ExistsAsync("docs", "a.txt")).Should().BeTrue(); + } + + [Fact] + public async Task DeleteAsync_DeveRemoverArquivo() + { + await _storage.UploadAsync(StreamOf("x"), "docs", "a.txt"); + + await _storage.DeleteAsync("docs", "a.txt"); + + (await _storage.ExistsAsync("docs", "a.txt")).Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_DeveRemoverVariosArquivos() + { + await _storage.UploadAsync(StreamOf("1"), "docs", "a.txt"); + await _storage.UploadAsync(StreamOf("2"), "docs", "b.txt"); + + await _storage.DeleteManyAsync("docs", ["a.txt", "b.txt"]); + + (await _storage.ListAsync("docs")).Should().BeEmpty(); + } + + [Fact] + public async Task ListAsync_ComPrefixo_DeveFiltrar() + { + await _storage.UploadAsync(StreamOf("1"), "docs", "rel/a.txt"); + await _storage.UploadAsync(StreamOf("2"), "docs", "img/b.png"); + + var itens = await _storage.ListAsync("docs", "rel/"); + + itens.Should().ContainSingle().Which.Name.Should().Be("rel/a.txt"); + } + + [Fact] + public async Task CopyToAsync_DeveManterOriginal() + { + await _storage.UploadAsync(StreamOf("x"), "origem", "a.txt"); + + await _storage.CopyToAsync("origem", "destino", "a.txt"); + + (await _storage.ExistsAsync("origem", "a.txt")).Should().BeTrue(); + (await _storage.ExistsAsync("destino", "a.txt")).Should().BeTrue(); + } + + [Fact] + public async Task MoveToAsync_DeveRemoverOriginal() + { + await _storage.UploadAsync(StreamOf("x"), "origem", "a.txt"); + + await _storage.MoveToAsync("origem", "destino", "a.txt"); + + (await _storage.ExistsAsync("origem", "a.txt")).Should().BeFalse(); + (await _storage.ExistsAsync("destino", "a.txt")).Should().BeTrue(); + } + + [Fact] + public async Task Metadata_DevePersistirCustomMetadata() + { + await _storage.UploadAsync(StreamOf("x"), "docs", "a.txt", + new Dictionary { ["autor"] = "codout" }); + + var metadata = await _storage.GetMetadataAsync("docs", "a.txt"); + metadata.CustomMetadata.Should().ContainKey("autor").WhoseValue.Should().Be("codout"); + metadata.Size.Should().Be(1); + + await _storage.SetMetadataAsync("docs", "a.txt", new Dictionary { ["v"] = "2" }); + + (await _storage.GetMetadataAsync("docs", "a.txt")) + .CustomMetadata.Should().ContainKey("v"); + } + + [Fact] + public async Task UploadAsync_ComProgress_DeveReportarTamanho() + { + long? reportado = null; + var progress = new Progress(v => reportado = v); + + await _storage.UploadAsync(StreamOf("12345"), "docs", "a.txt", progress); + + // Progress agenda callbacks; o fake reporta sincronamente via IProgress + await Task.Delay(50); + reportado.Should().Be(5); + } + + [Fact] + public void GetBlobUri_DeveMontarUriDeterministica() + { + _storage.GetBlobUri("docs", "a.txt").Should().Be(new Uri("memory://docs/a.txt")); + } + + [Fact] + public async Task GetSasUriAsync_DeveEmbutirExpiracao() + { + await _storage.UploadAsync(StreamOf("x"), "docs", "a.txt"); + + var uri = await _storage.GetSasUriAsync("docs", "a.txt", TimeSpan.FromHours(1)); + + uri.Query.Should().Contain("expires="); + } + + #region Fake em memória + + private sealed class InMemoryStorage : IStorage + { + private sealed record Entry(byte[] Content, Dictionary Metadata, DateTimeOffset LastModified); + + private readonly Dictionary> _containers = new(); + + private Dictionary Container(string container) + { + if (!_containers.TryGetValue(container, out var c)) + _containers[container] = c = new Dictionary(); + return c; + } + + private Entry GetEntryOrThrow(string container, string fileName) + { + if (!Container(container).TryGetValue(fileName, out var entry)) + throw new StorageNotFoundException(container, fileName); + return entry; + } + + private static Uri UriOf(string container, string fileName) => new($"memory://{container}/{fileName}"); + + public Task UploadAsync(Stream file, string container, string fileName, CancellationToken cancellationToken = default) + => UploadAsync(file, container, fileName, (IDictionary?)null, cancellationToken); + + public Task UploadAsync(Stream file, string container, string fileName, IDictionary? metadata, CancellationToken cancellationToken = default) + { + using var ms = new MemoryStream(); + file.CopyTo(ms); + Container(container)[fileName] = new Entry(ms.ToArray(), + new Dictionary(metadata ?? new Dictionary()), DateTimeOffset.UtcNow); + return Task.FromResult(UriOf(container, fileName)); + } + + public async Task UploadAsync(Stream file, string container, string fileName, IProgress? progress, CancellationToken cancellationToken = default) + { + var uri = await UploadAsync(file, container, fileName, (IDictionary?)null, cancellationToken); + progress?.Report(Container(container)[fileName].Content.LongLength); + return uri; + } + + public Task DownloadAsync(string container, string fileName, CancellationToken cancellationToken = default) + => Task.FromResult(new MemoryStream(GetEntryOrThrow(container, fileName).Content)); + + public Task GetStreamAsync(string container, string fileName, CancellationToken cancellationToken = default) + => DownloadAsync(container, fileName, cancellationToken); + + public Task DeleteAsync(string container, string fileName, CancellationToken cancellationToken = default) + { + Container(container).Remove(fileName); + return Task.CompletedTask; + } + + public Task DeleteManyAsync(string container, IEnumerable fileNames, CancellationToken cancellationToken = default) + { + foreach (var fileName in fileNames) + Container(container).Remove(fileName); + return Task.CompletedTask; + } + + public async Task MoveToAsync(string fromContainer, string toContainer, string fileName, CancellationToken cancellationToken = default) + { + var uri = await CopyToAsync(fromContainer, toContainer, fileName, cancellationToken); + await DeleteAsync(fromContainer, fileName, cancellationToken); + return uri; + } + + public Task CopyToAsync(string fromContainer, string toContainer, string fileName, CancellationToken cancellationToken = default) + { + var entry = GetEntryOrThrow(fromContainer, fileName); + Container(toContainer)[fileName] = entry with { LastModified = DateTimeOffset.UtcNow }; + return Task.FromResult(UriOf(toContainer, fileName)); + } + + public Task ExistsAsync(string container, string fileName, CancellationToken cancellationToken = default) + => Task.FromResult(Container(container).ContainsKey(fileName)); + + public Task> ListAsync(string container, CancellationToken cancellationToken = default) + => ListAsync(container, null, cancellationToken); + + public Task> ListAsync(string container, string? prefix, CancellationToken cancellationToken = default) + { + var itens = Container(container) + .Where(kvp => prefix == null || kvp.Key.StartsWith(prefix, StringComparison.Ordinal)) + .Select(kvp => new StorageItem + { + Name = kvp.Key, + Uri = UriOf(container, kvp.Key), + Size = kvp.Value.Content.LongLength, + LastModified = kvp.Value.LastModified + }) + .ToList(); + + return Task.FromResult>(itens); + } + + public Task GetMetadataAsync(string container, string fileName, CancellationToken cancellationToken = default) + { + var entry = GetEntryOrThrow(container, fileName); + return Task.FromResult(new StorageMetadata + { + Size = entry.Content.LongLength, + LastModified = entry.LastModified, + CustomMetadata = new Dictionary(entry.Metadata) + }); + } + + public Task SetMetadataAsync(string container, string fileName, IDictionary metadata, CancellationToken cancellationToken = default) + { + var entry = GetEntryOrThrow(container, fileName); + Container(container)[fileName] = entry with { Metadata = new Dictionary(metadata) }; + return Task.CompletedTask; + } + + public Uri GetBlobUri(string container, string fileName) => UriOf(container, fileName); + + public Task GetSasUriAsync(string container, string fileName, TimeSpan expiresIn, CancellationToken cancellationToken = default) + { + GetEntryOrThrow(container, fileName); + var expires = DateTimeOffset.UtcNow.Add(expiresIn).ToUnixTimeSeconds(); + return Task.FromResult(new Uri($"memory://{container}/{fileName}?expires={expires}")); + } + } + + #endregion +} diff --git a/tests/Codout.Framework.Storage.Tests/StorageExceptionsTests.cs b/tests/Codout.Framework.Storage.Tests/StorageExceptionsTests.cs new file mode 100644 index 0000000..28f3dc3 --- /dev/null +++ b/tests/Codout.Framework.Storage.Tests/StorageExceptionsTests.cs @@ -0,0 +1,106 @@ +using Codout.Framework.Storage.Exceptions; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Tests; + +public class StorageExceptionsTests +{ + [Fact] + public void StorageException_ComMensagem_DevePreservarMensagem() + { + var ex = new StorageException("falhou"); + + ex.Message.Should().Be("falhou"); + ex.Container.Should().BeNull(); + ex.FileName.Should().BeNull(); + } + + [Fact] + public void StorageException_ComInnerException_DevePreservarInner() + { + var inner = new InvalidOperationException("causa raiz"); + var ex = new StorageException("falhou", inner); + + ex.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public void StorageException_ComContainerEArquivo_DevePreencherPropriedades() + { + var ex = new StorageException("falhou", "meu-container", "arquivo.txt"); + + ex.Container.Should().Be("meu-container"); + ex.FileName.Should().Be("arquivo.txt"); + } + + [Fact] + public void StorageException_ComContainerArquivoEInner_DevePreencherTudo() + { + var inner = new TimeoutException(); + var ex = new StorageException("falhou", "c", "f.txt", inner); + + ex.Container.Should().Be("c"); + ex.FileName.Should().Be("f.txt"); + ex.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public void StorageNotFoundException_DeveMontarMensagemComContainerEArquivo() + { + var ex = new StorageNotFoundException("docs", "relatorio.pdf"); + + ex.Message.Should().Be("File 'relatorio.pdf' not found in container 'docs'."); + ex.Container.Should().Be("docs"); + ex.FileName.Should().Be("relatorio.pdf"); + ex.Should().BeAssignableTo(); + } + + [Fact] + public void StorageNotFoundException_ComInner_DevePreservarInner() + { + var inner = new Exception("404"); + var ex = new StorageNotFoundException("docs", "f.txt", inner); + + ex.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public void StorageFileAlreadyExistsException_DeveMontarMensagem() + { + var ex = new StorageFileAlreadyExistsException("docs", "f.txt"); + + ex.Message.Should().Be("File 'f.txt' already exists in container 'docs'."); + ex.Container.Should().Be("docs"); + ex.FileName.Should().Be("f.txt"); + } + + [Fact] + public void StorageContainerException_DeveUsarFileNameVazio() + { + var ex = new StorageContainerException("docs", "falha no container"); + + ex.Message.Should().Be("falha no container"); + ex.Container.Should().Be("docs"); + ex.FileName.Should().BeEmpty(); + } + + [Fact] + public void StorageContainerException_ComInner_DevePreservarInner() + { + var inner = new Exception("x"); + var ex = new StorageContainerException("docs", "falha", inner); + + ex.InnerException.Should().BeSameAs(inner); + } + + [Fact] + public void StorageQuotaExceededException_DevePreencherLimiteEUso() + { + var ex = new StorageQuotaExceededException(quotaLimit: 1000, currentUsage: 1500); + + ex.QuotaLimit.Should().Be(1000); + ex.CurrentUsage.Should().Be(1500); + ex.Message.Should().Be("Storage quota exceeded. Limit: 1000 bytes, Current: 1500 bytes."); + } +} diff --git a/tests/Codout.Framework.Storage.Tests/StorageModelsTests.cs b/tests/Codout.Framework.Storage.Tests/StorageModelsTests.cs new file mode 100644 index 0000000..dc4377d --- /dev/null +++ b/tests/Codout.Framework.Storage.Tests/StorageModelsTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Tests; + +public class StorageModelsTests +{ + [Fact] + public void StorageItem_DeveTerDefaultsSeguros() + { + var item = new StorageItem(); + + item.Name.Should().BeEmpty(); + item.ContentType.Should().BeEmpty(); + item.Size.Should().Be(0); + item.ETag.Should().BeNull(); + item.IsDirectory.Should().BeFalse(); + item.LastModified.Should().Be(default); + } + + [Fact] + public void StorageItem_DevePermitirPreencherTodasAsPropriedades() + { + var agora = DateTimeOffset.UtcNow; + var uri = new Uri("https://conta.blob.core.windows.net/c/f.txt"); + + var item = new StorageItem + { + Name = "f.txt", + Uri = uri, + ContentType = "text/plain", + Size = 42, + LastModified = agora, + ETag = "\"abc\"", + IsDirectory = false + }; + + item.Name.Should().Be("f.txt"); + item.Uri.Should().BeSameAs(uri); + item.ContentType.Should().Be("text/plain"); + item.Size.Should().Be(42); + item.LastModified.Should().Be(agora); + item.ETag.Should().Be("\"abc\""); + } + + [Fact] + public void StorageMetadata_DeveInicializarCustomMetadataVazio() + { + var metadata = new StorageMetadata(); + + metadata.CustomMetadata.Should().NotBeNull().And.BeEmpty(); + metadata.ContentType.Should().BeEmpty(); + metadata.Size.Should().Be(0); + metadata.ETag.Should().BeNull(); + metadata.ContentEncoding.Should().BeNull(); + metadata.CacheControl.Should().BeNull(); + } + + [Fact] + public void StorageMetadata_DeveAceitarMetadadosCustomizados() + { + var metadata = new StorageMetadata(); + metadata.CustomMetadata["autor"] = "codout"; + + metadata.CustomMetadata.Should().ContainKey("autor").WhoseValue.Should().Be("codout"); + } +} diff --git a/tests/Codout.Framework.Storage.Tests/StorageOptionsTests.cs b/tests/Codout.Framework.Storage.Tests/StorageOptionsTests.cs new file mode 100644 index 0000000..4fb90b5 --- /dev/null +++ b/tests/Codout.Framework.Storage.Tests/StorageOptionsTests.cs @@ -0,0 +1,59 @@ +using Codout.Framework.Storage.Configuration; +using FluentAssertions; +using Xunit; + +namespace Codout.Framework.Storage.Tests; + +public class StorageOptionsTests +{ + [Fact] + public void StorageOptions_DeveTerDefaultsSensatos() + { + var options = new StorageOptions(); + + options.ConnectionString.Should().BeNull(); + options.DefaultContainer.Should().BeNull(); + options.AutoCreateContainer.Should().BeTrue(); + options.MaxRetryAttempts.Should().Be(3); + options.RetryDelaySeconds.Should().Be(2); + options.EnableCdn.Should().BeFalse(); + options.CdnEndpoint.Should().BeNull(); + options.DefaultSasExpirationHours.Should().Be(24); + options.ValidateFileNames.Should().BeTrue(); + options.MaxFileSizeBytes.Should().Be(0); + } + + [Fact] + public void AzureStorageOptions_DeveHerdarDeStorageOptionsComDefaults() + { + var options = new AzureStorageOptions(); + + options.Should().BeAssignableTo(); + options.AccountName.Should().BeNull(); + options.AccountKey.Should().BeNull(); + options.UseManagedIdentity.Should().BeFalse(); + options.PublicAccessType.Should().Be("Blob"); + } + + [Fact] + public void AwsStorageOptions_DeveTerRegiaoEEncriptacaoPadrao() + { + var options = new AwsStorageOptions(); + + options.Should().BeAssignableTo(); + options.Region.Should().Be("us-east-1"); + options.UseServerSideEncryption.Should().BeTrue(); + options.BucketName.Should().BeNull(); + } + + [Fact] + public void FileSystemStorageOptions_DeveTerRootPathPadrao() + { + // Observação: existe FileSystemStorageOptions mas não há implementação + // IStorage de file system no repositório (veja tests/FINDINGS-C.md). + var options = new FileSystemStorageOptions(); + + options.RootPath.Should().Be("./storage"); + options.BaseUrl.Should().BeNull(); + } +} diff --git a/tests/Codout.Image.Extensions.Tests/Codout.Image.Extensions.Tests.csproj b/tests/Codout.Image.Extensions.Tests/Codout.Image.Extensions.Tests.csproj new file mode 100644 index 0000000..65931a0 --- /dev/null +++ b/tests/Codout.Image.Extensions.Tests/Codout.Image.Extensions.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + diff --git a/tests/Codout.Image.Extensions.Tests/ImageExtensionsTests.cs b/tests/Codout.Image.Extensions.Tests/ImageExtensionsTests.cs new file mode 100644 index 0000000..5f908b7 --- /dev/null +++ b/tests/Codout.Image.Extensions.Tests/ImageExtensionsTests.cs @@ -0,0 +1,148 @@ +using Codout.Image.Extensions; +using FluentAssertions; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace Codout.Image.Extensions.Tests; + +public class ImageExtensionsTests +{ + private static Image CreateBlackImage(int width = 100, int height = 100) + { + return new Image(width, height, new Rgba32(0, 0, 0, 255)); + } + + private static int CountWhitePixels(Image image) + { + var white = new Rgba32(255, 255, 255, 255); + var count = 0; + for (var y = 0; y < image.Height; y++) + for (var x = 0; x < image.Width; x++) + if (image[x, y] == white) + count++; + return count; + } + + [Fact] + public void Extract_RecortaAreaInformada() + { + using var source = CreateBlackImage(); + + using var extracted = source.Extract(new Rectangle(10, 10, 30, 20)); + + extracted.Width.Should().Be(30); + extracted.Height.Should().Be(20); + + // A imagem original não é alterada (Clone) + source.Width.Should().Be(100); + source.Height.Should().Be(100); + } + + [Fact] + public void Extract_PreservaConteudoDaArea() + { + using var source = CreateBlackImage(); + source[15, 15] = new Rgba32(255, 0, 0, 255); // pixel vermelho dentro da área + + using var raw = source.Extract(new Rectangle(10, 10, 20, 20)); + using var extracted = raw.CloneAs(); + + extracted[5, 5].Should().Be(new Rgba32(255, 0, 0, 255)); + } + + [Fact] + public void Extract_ComMaxEdge_ReduzMantendoProporcao() + { + using var source = CreateBlackImage(200, 200); + + using var extracted = source.Extract(new Rectangle(0, 0, 80, 40), extractedMaxEdgeSize: 40); + + extracted.Width.Should().Be(40); + extracted.Height.Should().Be(20); + } + + [Fact] + public void Extract_ComMaxEdgeMaiorQueArea_NaoAmpliaPorPadrao() + { + using var source = CreateBlackImage(200, 200); + + using var extracted = source.Extract(new Rectangle(0, 0, 20, 10), extractedMaxEdgeSize: 40); + + extracted.Width.Should().Be(20); + extracted.Height.Should().Be(10); + } + + [Fact] + public void Extract_ComScaleUp_AmpliaAteMaxEdge() + { + using var source = CreateBlackImage(200, 200); + + using var extracted = source.Extract(new Rectangle(0, 0, 20, 10), + extractedMaxEdgeSize: 40, scaleUpToMaxEdgeSize: true); + + extracted.Width.Should().Be(40); + extracted.Height.Should().Be(20); + } + + [Fact] + public void Extract_AreaForaDosLimites_EhRecortadaParaDentroDaImagem() + { + using var source = CreateBlackImage(100, 100); + + // Área parcialmente fora da imagem é intersectada com os limites + using var extracted = source.Extract(new Rectangle(80, 80, 50, 50), extractedMaxEdgeSize: 100); + + extracted.Width.Should().Be(20); + extracted.Height.Should().Be(20); + } + + [Fact] + public void DrawRectangles_MutaAImagemOriginal() + { + using var image = CreateBlackImage(); + CountWhitePixels(image).Should().Be(0); + + image.DrawRectangles(Brushes.Solid(Color.White), + new[] { new Rectangle(20, 20, 40, 40) }, thickness: 3); + + image.Width.Should().Be(100); + image.Height.Should().Be(100); + CountWhitePixels(image).Should().BeGreaterThan(0); + } + + [Fact] + public void DrawPoints_RetornaCopiaSemAlterarOriginal() + { + using var image = CreateBlackImage(); + + using var raw = image.DrawPoints(Brushes.Solid(Color.White), new[] { new Point(50, 50) }); + using var result = raw.CloneAs(); + + // Original permanece intacta + CountWhitePixels(image).Should().Be(0); + + // A cópia tem o ponto desenhado nas proximidades de (50,50) + result.Should().NotBeSameAs(image); + CountWhitePixels(result).Should().BeGreaterThan(0); + result[50, 50].Should().Be(new Rgba32(255, 255, 255, 255)); + } + + [Fact] + public void DrawRectanglesAndPoints_DesenhaAmbosNaCopia() + { + using var image = CreateBlackImage(); + + using var raw = image.DrawRectanglesAndPoints(Brushes.Solid(Color.White), + new[] { new RectangleF(10, 10, 30, 30) }, + new[] { new PointF(70, 70) }); + using var result = raw.CloneAs(); + + CountWhitePixels(image).Should().Be(0); + CountWhitePixels(result).Should().BeGreaterThan(0); + + // Ponto desenhado próximo de (70,70) + result[70, 70].Should().Be(new Rgba32(255, 255, 255, 255)); + } +} diff --git a/tests/Codout.Mailer.Razor.Tests/Codout.Mailer.Razor.Tests.csproj b/tests/Codout.Mailer.Razor.Tests/Codout.Mailer.Razor.Tests.csproj new file mode 100644 index 0000000..fac8b11 --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/Codout.Mailer.Razor.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + false + + true + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Mailer.Razor.Tests/ConfigureServicesTests.cs b/tests/Codout.Mailer.Razor.Tests/ConfigureServicesTests.cs new file mode 100644 index 0000000..93b6f97 --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/ConfigureServicesTests.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using Codout.Mailer.Interfaces; +using Codout.Mailer.Razor; +using Codout.Mailer.Razor.Configuration; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codout.Mailer.Razor.Tests; + +public class ConfigureServicesTests +{ + [Fact] + public void AddMailerRazor_SemTemplateAssembly_LancaInvalidOperationException() + { + var acao = () => new ServiceCollection().AddMailerRazor(options => + { + options.RootNamespace = "X"; + }); + + acao.Should().Throw() + .WithMessage("TemplateAssembly deve ser definido*"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AddMailerRazor_SemRootNamespace_LancaInvalidOperationException(string? rootNamespace) + { + var acao = () => new ServiceCollection().AddMailerRazor(options => + { + options.TemplateAssembly = typeof(ConfigureServicesTests).Assembly; + options.RootNamespace = rootNamespace!; + }); + + acao.Should().Throw() + .WithMessage("RootNamespace deve ser definido*"); + } + + [Fact] + public void AddMailerRazor_ComOpcoesValidas_DeveRegistrarTemplateEngineScoped() + { + var services = new ServiceCollection(); + + services.AddMailerRazor(options => + { + options.TemplateAssembly = typeof(ConfigureServicesTests).Assembly; + options.RootNamespace = "Codout.Mailer.Razor.Tests"; + }); + + services.Should().ContainSingle(d => + d.ServiceType == typeof(ITemplateEngine) && + d.ImplementationType == typeof(RazorViewTemplateEngine) && + d.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void RazorMailerOptions_DeveTerCacheHabilitadoPorPadrao() + { + var options = new RazorMailerOptions(); + + options.EnableCache.Should().BeTrue(); + options.TemplateAssembly.Should().BeNull(); + options.RootNamespace.Should().BeNull(); + } + + [Fact] + public void TemplateDeveEstarEmbarcadoNoAssemblyDeTeste() + { + // Sanidade: garante que o recurso embarcado usado nos testes de integração + // existe com o nome esperado pelo EmbeddedFileProvider. + var nomes = typeof(ConfigureServicesTests).Assembly.GetManifestResourceNames(); + + nomes.Should().Contain("Codout.Mailer.Razor.Tests.Templates.Welcome.cshtml"); + } +} diff --git a/tests/Codout.Mailer.Razor.Tests/RazorRenderingIntegrationTests.cs b/tests/Codout.Mailer.Razor.Tests/RazorRenderingIntegrationTests.cs new file mode 100644 index 0000000..8420839 --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/RazorRenderingIntegrationTests.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using System.Net.Mail; +using Codout.Mailer.Interfaces; +using Codout.Mailer.Razor.Configuration; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Codout.Mailer.Razor.Tests; + +/// +/// Renderização Razor REAL (compilação em runtime) sem host ASP.NET Core completo: +/// monta-se um ServiceProvider mínimo com IWebHostEnvironment/DiagnosticListener +/// fakes e usa-se o AddMailerRazor do pacote com o template embarcado neste +/// assembly de teste (requer PreserveCompilationContext no csproj de teste). +/// +public class RazorRenderingIntegrationTests +{ + private static ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + + var environment = new FakeWebHostEnvironment(); + services.AddSingleton(environment); + services.AddSingleton(environment); + + var diagnosticListener = new DiagnosticListener("Microsoft.AspNetCore"); + services.AddSingleton(diagnosticListener); + services.AddSingleton(diagnosticListener); + + services.AddLogging(); + + services.AddMailerRazor(options => + { + options.TemplateAssembly = typeof(RazorRenderingIntegrationTests).Assembly; + options.RootNamespace = "Codout.Mailer.Razor.Tests"; + }); + + return services.BuildServiceProvider(); + } + + [Fact] + public async Task RenderAsync_DeveCompilarERenderizarTemplateEmbarcadoComModel() + { + await using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var engine = scope.ServiceProvider.GetRequiredService(); + + var model = new WelcomeModel + { + Nome = "Maria", + To = new MailAddress("maria@exemplo.com") + }; + + var html = await engine.RenderAsync("/Templates/Welcome.cshtml", model); + + html.Should().Contain("Olá, Maria!"); + html.Should().Contain("maria@exemplo.com"); + html.Should().Contain(""); + } + + [Fact] + public async Task RenderAsync_TemplateInexistente_LancaInvalidOperationException() + { + await using var provider = BuildProvider(); + using var scope = provider.CreateScope(); + var engine = scope.ServiceProvider.GetRequiredService(); + + var acao = () => engine.RenderAsync("/Templates/NaoExiste.cshtml", new WelcomeModel()); + + (await acao.Should().ThrowAsync()) + .WithMessage("*'/Templates/NaoExiste.cshtml'*não encontrado*"); + } + + private sealed class FakeWebHostEnvironment : IWebHostEnvironment + { + public string ApplicationName { get; set; } = + typeof(RazorRenderingIntegrationTests).Assembly.GetName().Name!; + + public string EnvironmentName { get; set; } = "Production"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + public string WebRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/Codout.Mailer.Razor.Tests/RazorViewTemplateEngineTests.cs b/tests/Codout.Mailer.Razor.Tests/RazorViewTemplateEngineTests.cs new file mode 100644 index 0000000..0a5711e --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/RazorViewTemplateEngineTests.cs @@ -0,0 +1,114 @@ +using Codout.Mailer.Razor; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Codout.Mailer.Razor.Tests; + +/// +/// Testes unitários do RazorViewTemplateEngine usando IRazorViewEngine fake — +/// cobrem a lógica de resolução de view e do pipeline de renderização sem +/// precisar de host ASP.NET Core nem compilação Razor real (esta é coberta +/// pelos testes de integração em RazorRenderingIntegrationTests). +/// +public class RazorViewTemplateEngineTests +{ + private readonly Mock _viewEngine = new(); + private readonly Mock _tempDataProvider = new(); + private readonly IServiceProvider _serviceProvider = new ServiceCollection().BuildServiceProvider(); + + public RazorViewTemplateEngineTests() + { + _tempDataProvider + .Setup(p => p.LoadTempData(It.IsAny())) + .Returns(new Dictionary()); + } + + private RazorViewTemplateEngine CreateEngine() => + new(_viewEngine.Object, _tempDataProvider.Object, _serviceProvider); + + [Fact] + public async Task RenderAsync_QuandoViewNaoEncontrada_LancaComLocaisPesquisados() + { + _viewEngine + .Setup(e => e.GetView(null, "Inexistente", false)) + .Returns(ViewEngineResult.NotFound("Inexistente", ["/Views/Inexistente.cshtml"])); + _viewEngine + .Setup(e => e.FindView(It.IsAny(), "Inexistente", false)) + .Returns(ViewEngineResult.NotFound("Inexistente", ["/Templates/Inexistente.cshtml"])); + + var acao = () => CreateEngine().RenderAsync("Inexistente", new WelcomeModel()); + + (await acao.Should().ThrowAsync()) + .WithMessage("*'Inexistente'*") + .WithMessage("*/Templates/Inexistente.cshtml*"); + } + + [Fact] + public async Task RenderAsync_QuandoGetViewEncontra_DeveRenderizarEscrevendoNoWriter() + { + WelcomeModel? modelRecebido = null; + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Returns(async context => + { + modelRecebido = context.ViewData.Model as WelcomeModel; + await context.Writer.WriteAsync("

renderizado

"); + }); + + _viewEngine + .Setup(e => e.GetView(null, "/Templates/Welcome.cshtml", false)) + .Returns(ViewEngineResult.Found("/Templates/Welcome.cshtml", view.Object)); + + var model = new WelcomeModel { Nome = "Maria" }; + + var html = await CreateEngine().RenderAsync("/Templates/Welcome.cshtml", model); + + html.Should().Be("

renderizado

"); + modelRecebido.Should().BeSameAs(model); + _viewEngine.Verify(e => e.FindView(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "GetView resolveu, então FindView não deve ser consultado"); + } + + [Fact] + public async Task RenderAsync_QuandoGetViewFalha_DeveTentarFindView() + { + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Returns(context => context.Writer.WriteAsync("via FindView")); + + _viewEngine + .Setup(e => e.GetView(null, "Welcome", false)) + .Returns(ViewEngineResult.NotFound("Welcome", [])); + _viewEngine + .Setup(e => e.FindView(It.IsAny(), "Welcome", false)) + .Returns(ViewEngineResult.Found("Welcome", view.Object)); + + var html = await CreateEngine().RenderAsync("Welcome", new WelcomeModel()); + + html.Should().Be("via FindView"); + } + + [Fact] + public async Task RenderAsync_DevePropagarExcecaoDaView() + { + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("erro na view")); + + _viewEngine + .Setup(e => e.GetView(null, "Quebrada", false)) + .Returns(ViewEngineResult.Found("Quebrada", view.Object)); + + var acao = () => CreateEngine().RenderAsync("Quebrada", new WelcomeModel()); + + (await acao.Should().ThrowAsync()) + .WithMessage("erro na view"); + } +} diff --git a/tests/Codout.Mailer.Razor.Tests/Templates/Welcome.cshtml b/tests/Codout.Mailer.Razor.Tests/Templates/Welcome.cshtml new file mode 100644 index 0000000..2744681 --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/Templates/Welcome.cshtml @@ -0,0 +1,7 @@ +@model Codout.Mailer.Razor.Tests.WelcomeModel + + +

Olá, @Model.Nome!

+

Seu e-mail é @Model.To.Address

+ + diff --git a/tests/Codout.Mailer.Razor.Tests/WelcomeModel.cs b/tests/Codout.Mailer.Razor.Tests/WelcomeModel.cs new file mode 100644 index 0000000..c855788 --- /dev/null +++ b/tests/Codout.Mailer.Razor.Tests/WelcomeModel.cs @@ -0,0 +1,8 @@ +using Codout.Mailer.Models; + +namespace Codout.Mailer.Razor.Tests; + +public class WelcomeModel : MailerModelBase +{ + public string Nome { get; set; } = string.Empty; +} diff --git a/tests/Codout.Mailer.Tests/AwsDispatcherTests.cs b/tests/Codout.Mailer.Tests/AwsDispatcherTests.cs new file mode 100644 index 0000000..132920e --- /dev/null +++ b/tests/Codout.Mailer.Tests/AwsDispatcherTests.cs @@ -0,0 +1,113 @@ +using Codout.Mailer.AWS; +using Codout.Mailer.AWS.Configuration; +using Codout.Mailer.Interfaces; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Mailer.Tests; + +/// +/// O AWSDispatcher instancia o AmazonSimpleEmailServiceV2Client internamente +/// (não injetável), então não é possível testar a montagem da mensagem nem o +/// tratamento de resposta sem chamada de rede real. Aqui são testadas as +/// validações de configuração (que ocorrem antes de qualquer I/O) e o registro +/// no container de DI. Veja tests/FINDINGS-C.md. +/// +public class AwsDispatcherTests +{ + private static AWSDispatcher CreateDispatcher(string? accessKey, string? secretKey, string? region) + { + return new AWSDispatcher(Options.Create(new AWSSettings + { + AccessKey = accessKey!, + SecretKey = secretKey!, + RegionEndpoint = region! + })); + } + + private static System.Net.Mail.MailAddress Address(string email) => new(email); + + [Fact] + public async Task Send_SemAccessKey_LancaInvalidOperationException() + { + // BUG?: a validação de configuração lança exceção, enquanto qualquer outra + // falha dentro do try é convertida em MailerResponse { Sent = false } — + // comportamento inconsistente para o chamador. Caracterização do atual. + var dispatcher = CreateDispatcher(null, "secret", "us-east-1"); + + var acao = () => dispatcher.Send(Address("from@x.com"), Address("to@x.com"), "s", "

x

"); + + (await acao.Should().ThrowAsync()) + .WithMessage("AWS Access Key is not configured."); + } + + [Fact] + public async Task Send_SemRegionEndpoint_LancaInvalidOperationException() + { + var dispatcher = CreateDispatcher("access", "secret", " "); + + var acao = () => dispatcher.Send(Address("from@x.com"), Address("to@x.com"), "s", "

x

"); + + (await acao.Should().ThrowAsync()) + .WithMessage("AWS Region Endpoint is not configured."); + } + + [Fact] + public async Task Send_SemSecretKey_LancaInvalidOperationException() + { + var dispatcher = CreateDispatcher("access", "", "us-east-1"); + + var acao = () => dispatcher.Send(Address("from@x.com"), Address("to@x.com"), "s", "

x

"); + + (await acao.Should().ThrowAsync()) + .WithMessage("AWS Secret Key is not configured."); + } + + [Fact] + public async Task Send_ComTodasSettingsVazias_ValidaAccessKeyPrimeiro() + { + var dispatcher = CreateDispatcher(null, null, null); + + var acao = () => dispatcher.Send(Address("from@x.com"), Address("to@x.com"), "s", "

x

"); + + (await acao.Should().ThrowAsync()) + .WithMessage("AWS Access Key is not configured."); + } + + [Fact] + public void AddMailerWithAws_DeveRegistrarAwsDispatcherComoIMailerDispatcher() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AWSSettings:AccessKey"] = "AKIA-TEST", + ["AWSSettings:SecretKey"] = "secret-test", + ["AWSSettings:RegionEndpoint"] = "sa-east-1" + }) + .Build(); + + var provider = new ServiceCollection() + .AddLogging() + .AddMailerWithAws(configuration) + .BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .Should().BeOfType(); + + var settings = scope.ServiceProvider.GetRequiredService>().Value; + settings.AccessKey.Should().Be("AKIA-TEST"); + settings.SecretKey.Should().Be("secret-test"); + settings.RegionEndpoint.Should().Be("sa-east-1"); + } + + [Fact] + public void AwsSettings_SectionName_DeveSerAWSSettings() + { + AWSSettings.SectionName.Should().Be("AWSSettings"); + } +} diff --git a/tests/Codout.Mailer.Tests/Codout.Mailer.Tests.csproj b/tests/Codout.Mailer.Tests/Codout.Mailer.Tests.csproj new file mode 100644 index 0000000..69db735 --- /dev/null +++ b/tests/Codout.Mailer.Tests/Codout.Mailer.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Mailer.Tests/ConfigureServicesTests.cs b/tests/Codout.Mailer.Tests/ConfigureServicesTests.cs new file mode 100644 index 0000000..173b69a --- /dev/null +++ b/tests/Codout.Mailer.Tests/ConfigureServicesTests.cs @@ -0,0 +1,80 @@ +using Codout.Mailer.Configuration; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Mailer.Tests; + +public class ConfigureServicesTests +{ + private static IConfiguration BuildConfiguration(IDictionary? values = null) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(values ?? new Dictionary()) + .Build(); + } + + [Fact] + public void AddMailer_DeveBindarMailerSettingsDaConfiguracao() + { + var configuration = BuildConfiguration(new Dictionary + { + ["MailerSettings:DefaultFromName"] = "Codout", + ["MailerSettings:DefaultFromEmail"] = "noreply@codout.com" + }); + + var provider = new ServiceCollection() + .AddLogging() + .AddMailer(configuration) + .BuildServiceProvider(); + + var settings = provider.GetRequiredService>().Value; + + settings.DefaultFromName.Should().Be("Codout"); + settings.DefaultFromEmail.Should().Be("noreply@codout.com"); + } + + [Fact] + public void AddMailer_DeveRegistrarHealthCheckComNomeMailer() + { + var provider = new ServiceCollection() + .AddLogging() + .AddMailer(BuildConfiguration()) + .BuildServiceProvider(); + + var healthOptions = provider.GetRequiredService>().Value; + + healthOptions.Registrations.Should().ContainSingle(r => r.Name == "mailer"); + } + + [Fact] + public void AddMailer_DeveAplicarDelegateDeOpcoesSobreAConfiguracao() + { + var delegateInvocado = false; + + var provider = new ServiceCollection() + .AddLogging() + .AddMailer(BuildConfiguration(), _ => delegateInvocado = true) + .BuildServiceProvider(); + + _ = provider.GetRequiredService>().Value; + + delegateInvocado.Should().BeTrue(); + } + + [Fact] + public async Task MailerHealthCheck_DeveRetornarHealthy() + { + // MailerHealthCheck está no namespace global e não usa o dispatcher + // (campo _dispatcher nunca é atribuído) — sempre retorna Healthy. + var healthCheck = new MailerHealthCheck(); + + var resultado = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + + resultado.Status.Should().Be(HealthStatus.Healthy); + resultado.Description.Should().Be("Mailer service is healthy"); + } +} diff --git a/tests/Codout.Mailer.Tests/ExtensionsTests.cs b/tests/Codout.Mailer.Tests/ExtensionsTests.cs new file mode 100644 index 0000000..2db726c --- /dev/null +++ b/tests/Codout.Mailer.Tests/ExtensionsTests.cs @@ -0,0 +1,73 @@ +using Codout.Mailer.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Mailer.Tests; + +public class ExtensionsTests +{ + [Fact] + public void ReadFully_DeveLerTodoOConteudoDoStream() + { + var bytes = Enumerable.Range(0, 100_000).Select(i => (byte)(i % 256)).ToArray(); + using var stream = new MemoryStream(bytes); + + var resultado = stream.ReadFully(); + + resultado.Should().Equal(bytes); + } + + [Fact] + public void ReadFully_ComPosicaoNoFinal_DeveReposicionarELerTudo() + { + var bytes = "conteudo de teste"u8.ToArray(); + using var stream = new MemoryStream(bytes); + stream.Seek(0, SeekOrigin.End); + + var resultado = stream.ReadFully(); + + resultado.Should().Equal(bytes); + } + + [Fact] + public void ReadFully_ComStreamVazio_DeveRetornarArrayVazio() + { + using var stream = new MemoryStream(); + + stream.ReadFully().Should().BeEmpty(); + } + + [Fact] + public void ReadFully_ComStreamNaoSeekable_LancaNotSupportedException() + { + // BUG?: ReadFully força stream.Position = 0, o que exige stream seekable. + // Streams de rede/pipe (não-seekable) lançam NotSupportedException. + // Teste de caracterização do comportamento atual. + using var inner = new MemoryStream("abc"u8.ToArray()); + using var naoSeekable = new NonSeekableStream(inner); + + var acao = () => naoSeekable.ReadFully(); + + acao.Should().Throw(); + } + + private sealed class NonSeekableStream(Stream inner) : Stream + { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/tests/Codout.Mailer.Tests/HtmlUtilitiesTests.cs b/tests/Codout.Mailer.Tests/HtmlUtilitiesTests.cs new file mode 100644 index 0000000..988b7cc --- /dev/null +++ b/tests/Codout.Mailer.Tests/HtmlUtilitiesTests.cs @@ -0,0 +1,146 @@ +using Codout.Mailer.Helpers; +using FluentAssertions; +using Xunit; + +namespace Codout.Mailer.Tests; + +public class HtmlUtilitiesTests +{ + #region ConvertToPlainText + + [Fact] + public void ConvertToPlainText_DeveRemoverTagsHtml() + { + var resultado = HtmlUtilities.ConvertToPlainText("
Hello World
"); + + resultado.Should().Contain("Hello"); + resultado.Should().Contain("World"); + resultado.Should().NotContain("<"); + resultado.Should().NotContain(">"); + } + + [Fact] + public void ConvertToPlainText_DeveConverterParagrafoEmQuebraDeLinha() + { + var resultado = HtmlUtilities.ConvertToPlainText("

Linha1

Linha2

"); + + resultado.Should().Be("\r\nLinha1\r\nLinha2"); + } + + [Fact] + public void ConvertToPlainText_DeveConverterBrEmQuebraDeLinha() + { + var resultado = HtmlUtilities.ConvertToPlainText("Linha1
Linha2"); + + resultado.Should().Be("Linha1\r\nLinha2"); + } + + [Fact] + public void ConvertToPlainText_DeveIgnorarComentarios() + { + var resultado = HtmlUtilities.ConvertToPlainText("texto"); + + resultado.Should().Be("texto"); + } + + [Fact] + public void ConvertToPlainText_DeveIgnorarScriptEStyle() + { + var resultado = HtmlUtilities.ConvertToPlainText( + "conteudo"); + + resultado.Should().Be("conteudo"); + } + + [Fact] + public void ConvertToPlainText_DeveDecodificarEntidadesHtml() + { + var resultado = HtmlUtilities.ConvertToPlainText("a & b <c>"); + + resultado.Should().Be("a & b "); + } + + [Fact] + public void ConvertToPlainText_DeveIgnorarTextoApenasComEspacos() + { + var resultado = HtmlUtilities.ConvertToPlainText("
ok"); + + resultado.Should().Be("ok"); + } + + [Fact] + public void ConvertToPlainText_ComStringVazia_DeveRetornarVazio() + { + HtmlUtilities.ConvertToPlainText(string.Empty).Should().BeEmpty(); + } + + #endregion + + #region CountWords + + [Fact] + public void CountWords_ComTextoNuloOuVazio_DeveRetornarZero() + { + HtmlUtilities.CountWords(null!).Should().Be(0); + HtmlUtilities.CountWords(string.Empty).Should().Be(0); + } + + [Fact] + public void CountWords_ComDuasPalavras_DeveRetornarDois() + { + HtmlUtilities.CountWords("uma duas").Should().Be(2); + } + + [Fact] + public void CountWords_ComQuebrasDeLinha_DeveContarComoSeparador() + { + HtmlUtilities.CountWords("uma\nduas\ntres").Should().Be(3); + } + + [Fact] + public void CountWords_ComEspacosDuplos_ContaEntradasVazias() + { + // BUG?: Split(' ', '\n') sem StringSplitOptions.RemoveEmptyEntries conta + // entradas vazias — "uma duas" (dois espaços) retorna 3 em vez de 2. + // Teste de caracterização do comportamento atual. + HtmlUtilities.CountWords("uma duas").Should().Be(3); + } + + #endregion + + #region Cut + + [Fact] + public void Cut_ComTextoMenorQueLimite_DeveRetornarTextoOriginal() + { + HtmlUtilities.Cut("abc", 10).Should().Be("abc"); + } + + [Fact] + public void Cut_ComTextoNulo_DeveRetornarNulo() + { + HtmlUtilities.Cut(null!, 10).Should().BeNull(); + } + + [Fact] + public void Cut_ComTextoMaiorQueLimite_DeveTruncarComReticencias() + { + // text[..(length-4)] + " ..." => o resultado final tem exatamente 'length' chars. + var resultado = HtmlUtilities.Cut("abcdefghij", 8); + + resultado.Should().Be("abcd ..."); + resultado.Should().HaveLength(8); + } + + [Fact] + public void Cut_ComLimiteMenorQueQuatro_LancaArgumentOutOfRange() + { + // BUG?: para length < 4 o slice text[..(length - 4)] usa índice negativo + // e lança ArgumentOutOfRangeException. Teste de caracterização. + var acao = () => HtmlUtilities.Cut("abcdefghij", 3); + + acao.Should().Throw(); + } + + #endregion +} diff --git a/tests/Codout.Mailer.Tests/MailerServiceBaseTests.cs b/tests/Codout.Mailer.Tests/MailerServiceBaseTests.cs new file mode 100644 index 0000000..03fcb1a --- /dev/null +++ b/tests/Codout.Mailer.Tests/MailerServiceBaseTests.cs @@ -0,0 +1,149 @@ +using System.Net.Mail; +using Codout.Mailer.Configuration; +using Codout.Mailer.Interfaces; +using Codout.Mailer.Models; +using Codout.Mailer.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Codout.Mailer.Tests; + +public class MailerServiceBaseTests +{ + private const string FromEmail = "noreply@codout.com"; + private const string FromName = "Codout"; + + private readonly Mock _dispatcher = new(); + private readonly Mock _templateEngine = new(); + + private sealed class TestModel : MailerModelBase + { + public string Nome { get; set; } = string.Empty; + } + + private sealed class TestMailerService( + IOptions mailerSettings, + IMailerDispatcher dispatcher, + ITemplateEngine templateEngine) + : MailerServiceBase(mailerSettings, dispatcher, templateEngine, NullLogger.Instance); + + private TestMailerService CreateService() + { + var settings = Options.Create(new MailerSettings + { + DefaultFromEmail = FromEmail, + DefaultFromName = FromName + }); + + return new TestMailerService(settings, _dispatcher.Object, _templateEngine.Object); + } + + [Fact] + public async Task Send_DeveRenderizarTemplateEDespacharComRemetentePadrao() + { + var model = new TestModel { To = new MailAddress("dest@exemplo.com", "Destinatário"), Nome = "Fulano" }; + + _templateEngine + .Setup(t => t.RenderAsync("welcome", model)) + .ReturnsAsync("

Olá Fulano

"); + + MailAddress? capturedFrom = null; + MailAddress? capturedTo = null; + string? capturedSubject = null; + string? capturedHtml = null; + string? capturedPlainText = null; + + _dispatcher + .Setup(d => d.Send(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (from, to, subject, html, plain, _) => + { + capturedFrom = from; + capturedTo = to; + capturedSubject = subject; + capturedHtml = html; + capturedPlainText = plain; + }) + .ReturnsAsync(new MailerResponse { Sent = true }); + + var resultado = await CreateService().Send("welcome", model, "Bem-vindo"); + + resultado.Sent.Should().BeTrue(); + capturedFrom!.Address.Should().Be(FromEmail); + capturedFrom.DisplayName.Should().Be(FromName); + capturedTo.Should().BeSameAs(model.To); + capturedSubject.Should().Be("Bem-vindo"); + capturedHtml.Should().Be("

Olá Fulano

"); + capturedPlainText.Should().Contain("Olá Fulano").And.NotContain("

"); + } + + [Fact] + public async Task Send_DeveRepassarAttachmentsParaODispatcher() + { + var model = new TestModel { To = new MailAddress("dest@exemplo.com") }; + var attachments = new[] { new Attachment(new MemoryStream([1, 2, 3]), "arquivo.bin") }; + + _templateEngine.Setup(t => t.RenderAsync("tpl", model)).ReturnsAsync("

x

"); + _dispatcher + .Setup(d => d.Send(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), attachments)) + .ReturnsAsync(new MailerResponse { Sent = true }) + .Verifiable(); + + var resultado = await CreateService().Send("tpl", model, "Assunto", attachments); + + resultado.Sent.Should().BeTrue(); + _dispatcher.Verify(); + } + + [Fact] + public async Task Send_QuandoTemplateEngineLanca_DeveRetornarRespostaComErro() + { + var model = new TestModel { To = new MailAddress("dest@exemplo.com") }; + + _templateEngine + .Setup(t => t.RenderAsync("tpl", model)) + .ThrowsAsync(new InvalidOperationException("template não encontrado")); + + var resultado = await CreateService().Send("tpl", model, "Assunto"); + + resultado.Sent.Should().BeFalse(); + resultado.ErrorMessages.Should().ContainSingle().Which.Should().Be("template não encontrado"); + _dispatcher.Verify(d => d.Send(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Send_QuandoDispatcherLanca_DeveRetornarRespostaComErro() + { + var model = new TestModel { To = new MailAddress("dest@exemplo.com") }; + + _templateEngine.Setup(t => t.RenderAsync("tpl", model)).ReturnsAsync("

x

"); + _dispatcher + .Setup(d => d.Send(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("falha no envio")); + + var resultado = await CreateService().Send("tpl", model, "Assunto"); + + resultado.Sent.Should().BeFalse(); + resultado.ErrorMessages.Should().ContainSingle().Which.Should().Be("falha no envio"); + } + + [Fact] + public async Task Send_ComModelSemDestinatario_LancaNullReference() + { + // BUG?: model.To.Address é acessado no log ANTES do bloco try, então um model + // sem destinatário derruba o chamador com NullReferenceException em vez de + // retornar MailerResponse { Sent = false }. Teste de caracterização. + var model = new TestModel { To = null! }; + + var acao = () => CreateService().Send("tpl", model, "Assunto"); + + await acao.Should().ThrowAsync(); + } +} diff --git a/tests/Codout.Mailer.Tests/ModelsTests.cs b/tests/Codout.Mailer.Tests/ModelsTests.cs new file mode 100644 index 0000000..0cc5585 --- /dev/null +++ b/tests/Codout.Mailer.Tests/ModelsTests.cs @@ -0,0 +1,61 @@ +using System.Net.Mail; +using Codout.Mailer.Configuration; +using Codout.Mailer.Diagnostics; +using Codout.Mailer.Models; +using FluentAssertions; +using Xunit; + +namespace Codout.Mailer.Tests; + +public class ModelsTests +{ + [Fact] + public void MailerResponse_PorPadrao_NaoEnviadoESemErros() + { + var resposta = new MailerResponse(); + + resposta.Sent.Should().BeFalse(); + + // Observação: ErrorMessages não é inicializada — consumidores precisam + // checar null antes de iterar (em respostas de sucesso ela costuma vir null). + resposta.ErrorMessages.Should().BeNull(); + } + + [Fact] + public void MailerModelBase_DevePermitirDefinirDestinatario() + { + var model = new MailerModelBase { To = new MailAddress("a@b.com", "Nome") }; + + model.To.Address.Should().Be("a@b.com"); + model.To.DisplayName.Should().Be("Nome"); + } + + [Fact] + public void MailerSettings_SectionName_DeveSerMailerSettings() + { + MailerSettings.SectionName.Should().Be("MailerSettings"); + } + + [Fact] + public void MailerOptions_Validate_NaoDeveLancar() + { + var acao = () => new MailerOptions().Validate(); + + acao.Should().NotThrow(); + } + + [Fact] + public void MailerActivitySource_DeveExporNomeEVersao() + { + MailerActivitySource.ActivitySourceName.Should().Be("Codout.Mailer"); + MailerActivitySource.Version.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void MailerActivitySource_SemListeners_StartActivityRetornaNull() + { + var activity = MailerActivitySource.StartActivity("Teste.SemListener"); + + activity.Should().BeNull(); + } +} diff --git a/tests/Codout.Mailer.Tests/SendGridDispatcherTests.cs b/tests/Codout.Mailer.Tests/SendGridDispatcherTests.cs new file mode 100644 index 0000000..91770a3 --- /dev/null +++ b/tests/Codout.Mailer.Tests/SendGridDispatcherTests.cs @@ -0,0 +1,65 @@ +using Codout.Mailer.Interfaces; +using Codout.Mailer.SendGrid; +using Codout.Mailer.SendGrid.Configuration; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Mailer.Tests; + +/// +/// O SendGridDispatcher instancia o SendGridClient internamente (não injetável), +/// então não é possível testar a montagem do SendGridMessage nem o tratamento da +/// resposta HTTP sem chamada de rede real (o SDK tem ctor que recebe HttpClient, +/// mas o dispatcher não o expõe). Aqui são testados o registro de DI e o binding +/// das settings. Veja tests/FINDINGS-C.md. +/// +public class SendGridDispatcherTests +{ + [Fact] + public void AddMailerWithSendGrid_DeveRegistrarSendGridDispatcherComoIMailerDispatcher() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SendGridSettings:ApiKey"] = "SG.test-key", + ["SendGridSettings:StandBox"] = "true" + }) + .Build(); + + var provider = new ServiceCollection() + .AddLogging() + .AddMailerWithSendGrid(configuration) + .BuildServiceProvider(); + + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .Should().BeOfType(); + + var settings = scope.ServiceProvider.GetRequiredService>().Value; + settings.ApiKey.Should().Be("SG.test-key"); + + // Observação: a propriedade chama-se "StandBox" (provável typo de "Sandbox") + // e não é utilizada em nenhum lugar do dispatcher. + settings.StandBox.Should().BeTrue(); + } + + [Fact] + public void SendGridSettings_SectionName_DeveSerSendGridSettings() + { + SendGridSettings.SectionName.Should().Be("SendGridSettings"); + } + + [Fact] + public void SendGridDispatcher_DeveSerConstruivelComSettingsVazias() + { + // O dispatcher não valida ApiKey na construção (validação só ocorreria + // na chamada de rede, que não é exercitada em testes unitários). + var dispatcher = new SendGridDispatcher(Options.Create(new SendGridSettings())); + + dispatcher.Should().NotBeNull(); + } +} diff --git a/tests/Codout.Multitenancy.Tests/Codout.Multitenancy.Tests.csproj b/tests/Codout.Multitenancy.Tests/Codout.Multitenancy.Tests.csproj new file mode 100644 index 0000000..9889369 --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/Codout.Multitenancy.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Multitenancy.Tests/MemoryCacheTenantResolverTests.cs b/tests/Codout.Multitenancy.Tests/MemoryCacheTenantResolverTests.cs new file mode 100644 index 0000000..a6949b2 --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/MemoryCacheTenantResolverTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +namespace Codout.Multitenancy.Tests; + +public class MemoryCacheTenantResolverTests : IDisposable +{ + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + + public void Dispose() + { + _cache.Dispose(); + } + + private HostTenantResolver CreateResolver(MemoryCacheTenantResolverOptions? options = null) + { + var resolver = options == null + ? new HostTenantResolver(_cache) + : new HostTenantResolver(_cache, options); + + resolver.Tenants["tenant1.example.test"] = new TestTenant + { + TenantKey = "tenant1.example.test", + ConnectionString = "Server=db1", + DataBaseType = DataBaseType.Postgres + }; + + return resolver; + } + + [Fact] + public async Task ResolveAsync_ComHostConhecido_RetornaOTenantContext() + { + ITenantResolver resolver = CreateResolver(); + + var context = await resolver.ResolveAsync(HttpContextFactory.WithHost("tenant1.example.test")); + + context.Should().NotBeNull(); + ((TestTenant)context!.Tenant).ConnectionString.Should().Be("Server=db1"); + } + + [Fact] + public async Task ResolveAsync_ComHostDesconhecido_RetornaNulo() + { + ITenantResolver resolver = CreateResolver(); + + var context = await resolver.ResolveAsync(HttpContextFactory.WithHost("desconhecido.example.test")); + + context.Should().BeNull(); + } + + [Fact] + public async Task ResolveAsync_SemIdentificadorDeContexto_RetornaNuloSemResolver() + { + var resolver = CreateResolver(); + + // DefaultHttpContext sem Host definido → GetContextIdentifier retorna null. + var context = await ((ITenantResolver)resolver).ResolveAsync(new DefaultHttpContext()); + + context.Should().BeNull(); + resolver.ResolveCalls.Should().Be(0); + } + + [Fact] + public async Task ResolveAsync_SegundaChamada_UsaOCache() + { + var resolver = CreateResolver(); + ITenantResolver tenantResolver = resolver; + + var first = await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant1.example.test")); + var second = await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant1.example.test")); + + resolver.ResolveCalls.Should().Be(1, "a segunda chamada deve vir do cache"); + second.Should().BeSameAs(first); + } + + [Fact] + public async Task ResolveAsync_QuandoChaveDeBuscaDifereDaChaveDeGravacao_NuncaUsaOCache() + { + // BUG?: o resolver busca no cache pela chave de GetContextIdentifier, mas grava + // pela chave de GetTenantIdentifier. Se as duas convenções diferirem, o cache + // nunca é consultado com a chave gravada e o tenant é re-resolvido a cada request. + var resolver = CreateResolver(); + resolver.TenantIdentifierOverride = ctx => $"tenant:{((TestTenant)ctx.Tenant).TenantKey}"; + ITenantResolver tenantResolver = resolver; + + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant1.example.test")); + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant1.example.test")); + + resolver.ResolveCalls.Should().Be(2); + _cache.Get("tenant:tenant1.example.test").Should().NotBeNull("a gravação usa o tenant identifier"); + _cache.Get("tenant1.example.test").Should().BeNull("a busca usa o context identifier, que nunca foi gravado"); + } + + [Fact] + public async Task ResolveAsync_TenantNaoResolvido_NaoGravaNoCache() + { + var resolver = CreateResolver(); + ITenantResolver tenantResolver = resolver; + + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("desconhecido.example.test")); + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("desconhecido.example.test")); + + resolver.ResolveCalls.Should().Be(2, "tenants não resolvidos não são cacheados"); + } + + [Fact] + public async Task Evicao_ComDisposeOnEviction_DescartaOTenantContext() + { + var resolver = CreateResolver(); + var disposableTenant = new DisposableTenant { TenantKey = "tenant2.example.test" }; + resolver.Tenants["tenant2.example.test"] = disposableTenant; + ITenantResolver tenantResolver = resolver; + + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant2.example.test")); + _cache.Remove("tenant2.example.test"); + + // Callbacks de evicção do MemoryCache executam em background. + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!disposableTenant.Disposed && DateTime.UtcNow < deadline) + await Task.Delay(50); + + disposableTenant.Disposed.Should().BeTrue(); + } + + [Fact] + public async Task Evicao_ComDisposeOnEvictionDesligado_NaoDescartaOTenant() + { + var resolver = CreateResolver(new MemoryCacheTenantResolverOptions + { + DisposeOnEviction = false, + EvictAllEntriesOnExpiry = false + }); + var disposableTenant = new DisposableTenant { TenantKey = "tenant3.example.test" }; + resolver.Tenants["tenant3.example.test"] = disposableTenant; + ITenantResolver tenantResolver = resolver; + + await tenantResolver.ResolveAsync(HttpContextFactory.WithHost("tenant3.example.test")); + _cache.Remove("tenant3.example.test"); + + await Task.Delay(200); + + disposableTenant.Disposed.Should().BeFalse(); + } +} diff --git a/tests/Codout.Multitenancy.Tests/MiddlewareTests.cs b/tests/Codout.Multitenancy.Tests/MiddlewareTests.cs new file mode 100644 index 0000000..05a263c --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/MiddlewareTests.cs @@ -0,0 +1,251 @@ +using Codout.Multitenancy.Internal; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Codout.Multitenancy.Tests; + +public class TenantResolutionMiddlewareTests +{ + [Fact] + public async Task Invoke_QuandoResolverEncontraTenant_GravaNoHttpContext() + { + var tenantContext = new TenantContext(new TestTenant { TenantKey = "t1" }); + var resolver = new Mock(); + resolver.Setup(r => r.ResolveAsync(It.IsAny())).ReturnsAsync(tenantContext); + + var nextCalled = false; + var middleware = new TenantResolutionMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + var httpContext = new DefaultHttpContext(); + await middleware.Invoke(httpContext, resolver.Object); + + httpContext.GetTenantContext().Should().BeSameAs(tenantContext); + nextCalled.Should().BeTrue(); + } + + [Fact] + public async Task Invoke_QuandoResolverNaoEncontra_SegueSemTenantContext() + { + var resolver = new Mock(); + resolver.Setup(r => r.ResolveAsync(It.IsAny())).ReturnsAsync((TenantContext?)null); + + var nextCalled = false; + var middleware = new TenantResolutionMiddleware(_ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + var httpContext = new DefaultHttpContext(); + await middleware.Invoke(httpContext, resolver.Object); + + httpContext.GetTenantContext().Should().BeNull(); + nextCalled.Should().BeTrue(); + } +} + +public class TenantUnresolvedRedirectMiddlewareTests +{ + [Fact] + public async Task Invoke_SemTenant_RedirecionaTemporariamente() + { + var middleware = new TenantUnresolvedRedirectMiddleware( + _ => Task.CompletedTask, "https://landing.example.test/", false); + + var httpContext = new DefaultHttpContext(); + await middleware.Invoke(httpContext); + + httpContext.Response.StatusCode.Should().Be(StatusCodes.Status302Found); + httpContext.Response.Headers.Location.ToString().Should().Be("https://landing.example.test/"); + } + + [Fact] + public async Task Invoke_SemTenant_ComRedirectPermanente_Retorna301() + { + var middleware = new TenantUnresolvedRedirectMiddleware( + _ => Task.CompletedTask, "https://landing.example.test/", true); + + var httpContext = new DefaultHttpContext(); + await middleware.Invoke(httpContext); + + httpContext.Response.StatusCode.Should().Be(StatusCodes.Status301MovedPermanently); + } + + [Fact] + public async Task Invoke_ComTenant_ChamaOProximoMiddleware() + { + var nextCalled = false; + var middleware = new TenantUnresolvedRedirectMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, "https://landing.example.test/", false); + + var httpContext = new DefaultHttpContext(); + httpContext.SetTenantContext(new TenantContext(new TestTenant())); + await middleware.Invoke(httpContext); + + nextCalled.Should().BeTrue(); + httpContext.Response.Headers.Location.Should().BeEmpty(); + } +} + +public class PrimaryHostnameRedirectMiddlewareTests +{ + [Fact] + public async Task Invoke_ComHostDiferenteDoPrimario_Redireciona() + { + var middleware = new PrimaryHostnameRedirectMiddleware( + _ => Task.CompletedTask, _ => "primario.example.test", false); + + var httpContext = HttpContextFactory.WithHost("alias.example.test"); + httpContext.SetTenantContext(new TenantContext(new TestTenant())); + await middleware.Invoke(httpContext); + + httpContext.Response.Headers.Location.ToString() + .Should().Be("http://primario.example.test/"); + httpContext.Response.StatusCode.Should().Be(StatusCodes.Status302Found); + } + + [Fact] + public async Task Invoke_ComHostPrimario_NaoRedireciona() + { + var nextCalled = false; + var middleware = new PrimaryHostnameRedirectMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, _ => "primario.example.test", false); + + var httpContext = HttpContextFactory.WithHost("PRIMARIO.example.test"); + httpContext.SetTenantContext(new TenantContext(new TestTenant())); + await middleware.Invoke(httpContext); + + nextCalled.Should().BeTrue("a comparação de host é case-insensitive"); + } + + [Fact] + public async Task Invoke_SemTenant_NaoRedireciona() + { + var nextCalled = false; + var middleware = new PrimaryHostnameRedirectMiddleware( + _ => + { + nextCalled = true; + return Task.CompletedTask; + }, _ => "primario.example.test", false); + + await middleware.Invoke(HttpContextFactory.WithHost("alias.example.test")); + + nextCalled.Should().BeTrue(); + } +} + +public class TenantPipelineMiddlewareTests +{ + private static (RequestDelegate Pipeline, List Log) BuildPipeline() + { + var services = new ServiceCollection().BuildServiceProvider(); + var app = new ApplicationBuilder(services); + var log = new List(); + + app.UsePerTenant((context, branch) => + { + branch.Use(async (http, next) => + { + log.Add($"branch:{context.Tenant.TenantKey}"); + await next(); + }); + }); + + app.Run(_ => + { + log.Add("root"); + return Task.CompletedTask; + }); + + return (app.Build(), log); + } + + [Fact] + public async Task UsePerTenant_ComTenant_ExecutaOBranchEDepoisORoot() + { + var (pipeline, log) = BuildPipeline(); + + var httpContext = new DefaultHttpContext(); + httpContext.SetTenantContext(new TenantContext(new TestTenant { TenantKey = "t1" })); + await pipeline(httpContext); + + log.Should().ContainInOrder("branch:t1", "root"); + } + + [Fact] + public async Task UsePerTenant_SemTenant_CurtoCircuitaSemExecutarORoot() + { + // Observação de caracterização: sem TenantContext o middleware não chama _next, + // então o restante do pipeline (inclusive o root) nunca executa. + var (pipeline, log) = BuildPipeline(); + + await pipeline(new DefaultHttpContext()); + + log.Should().BeEmpty(); + } + + [Fact] + public async Task UsePerTenant_ReutilizaOPipelinePorTenant() + { + var services = new ServiceCollection().BuildServiceProvider(); + var app = new ApplicationBuilder(services); + var builds = 0; + + app.UsePerTenant((_, _) => builds++); + app.Run(_ => Task.CompletedTask); + var pipeline = app.Build(); + + var tenant = new TestTenant { TenantKey = "t1" }; + var context1 = new DefaultHttpContext(); + context1.SetTenantContext(new TenantContext(tenant)); + var context2 = new DefaultHttpContext(); + context2.SetTenantContext(new TenantContext(tenant)); + + await pipeline(context1); + await pipeline(context2); + + builds.Should().Be(1, "o branch é construído uma única vez por tenant"); + } +} + +public class MultitenancyApplicationBuilderExtensionsTests +{ + [Fact] + public async Task UseMultitenancy_ResolveEGravaOTenantNoContexto() + { + var tenantContext = new TenantContext(new TestTenant { TenantKey = "t1" }); + var resolver = new Mock(); + resolver.Setup(r => r.ResolveAsync(It.IsAny())).ReturnsAsync(tenantContext); + + var services = new ServiceCollection() + .AddScoped(_ => resolver.Object) + .BuildServiceProvider(); + + var app = new ApplicationBuilder(services); + app.UseMultitenancy(); + app.Run(_ => Task.CompletedTask); + var pipeline = app.Build(); + + var httpContext = new DefaultHttpContext { RequestServices = services }; + await pipeline(httpContext); + + httpContext.GetTenantContext().Should().BeSameAs(tenantContext); + } +} diff --git a/tests/Codout.Multitenancy.Tests/ServiceCollectionExtensionsTests.cs b/tests/Codout.Multitenancy.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..3dd3d31 --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,117 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codout.Multitenancy.Tests; + +public class MultitenancyServiceCollectionExtensionsTests +{ + [Fact] + public void AddMultitenancy_RegistraOResolverComoScoped() + { + var services = new ServiceCollection(); + + services.AddMultitenancy(); + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetService().Should().BeOfType(); + } + + [Fact] + public void AddMultitenancy_ComResolverDeMemoryCache_RegistraIMemoryCache() + { + var services = new ServiceCollection(); + + services.AddMultitenancy(); + using var provider = services.BuildServiceProvider(); + + provider.GetService().Should().NotBeNull(); + } + + [Fact] + public void AddMultitenancy_ComResolverSimples_NaoRegistraIMemoryCache() + { + var services = new ServiceCollection(); + + services.AddMultitenancy(); + using var provider = services.BuildServiceProvider(); + + provider.GetService().Should().BeNull(); + } + + [Fact] + public void AddMultitenancy_TenantContextVemDoHttpContextAtual() + { + var services = new ServiceCollection(); + services.AddMultitenancy(); + using var provider = services.BuildServiceProvider(); + + var tenant = new TestTenant { TenantKey = "t1" }; + var tenantContext = new TenantContext(tenant); + var httpContext = new DefaultHttpContext(); + httpContext.SetTenantContext(tenantContext); + provider.GetRequiredService().HttpContext = httpContext; + + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetService().Should().BeSameAs(tenantContext); + scope.ServiceProvider.GetService().Should().BeSameAs(tenant); + scope.ServiceProvider.GetRequiredService>().Value.Should().BeSameAs(tenant); + } + + [Fact] + public void AddMultitenancy_SemHttpContext_ResolveTenantContextNulo() + { + var services = new ServiceCollection(); + services.AddMultitenancy(); + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + scope.ServiceProvider.GetService().Should().BeNull(); + scope.ServiceProvider.GetService().Should().BeNull(); + } + + private sealed class SimpleResolver : ITenantResolver + { + public Task ResolveAsync(HttpContext context) + { + return Task.FromResult(null!); + } + } +} + +public class MultitenancyOptionsTests +{ + [Fact] + public void Tenants_PodeSerAtribuidoELido() + { + var options = new MultitenancyOptions + { + Tenants = [new TestTenant { TenantKey = "t1" }] + }; + + options.Tenants.Should().ContainSingle(t => t.TenantKey == "t1"); + } +} + +public class DataBaseTypeTests +{ + [Theory] + [InlineData(DataBaseType.Postgres, "Postgres")] + [InlineData(DataBaseType.MsSql, "Mssql")] + [InlineData(DataBaseType.Oracle, "Oracle")] + public void Valores_TemDisplayNameEDescription(DataBaseType value, string expected) + { + var member = typeof(DataBaseType).GetMember(value.ToString()).Single(); + + member.GetCustomAttributes(typeof(DisplayAttribute), false) + .Cast().Single().Name.Should().Be(expected); + member.GetCustomAttributes(typeof(DescriptionAttribute), false) + .Cast().Single().Description.Should().Be(expected); + } +} diff --git a/tests/Codout.Multitenancy.Tests/TenantContextTests.cs b/tests/Codout.Multitenancy.Tests/TenantContextTests.cs new file mode 100644 index 0000000..dd0ec7b --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/TenantContextTests.cs @@ -0,0 +1,155 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Codout.Multitenancy.Tests; + +public class TenantContextTests +{ + [Fact] + public void Construtor_DeveGerarIdUnicoEExporOTenant() + { + var tenant = new TestTenant { TenantKey = "t1" }; + + var context1 = new TenantContext(tenant); + var context2 = new TenantContext(tenant); + + context1.Tenant.Should().BeSameAs(tenant); + context1.Id.Should().NotBeNullOrWhiteSpace(); + context1.Id.Should().NotBe(context2.Id); + Guid.TryParse(context1.Id, out _).Should().BeTrue(); + } + + [Fact] + public void Properties_IniciaVazioEAceitaItens() + { + var context = new TenantContext(new TestTenant()); + + context.Properties.Should().BeEmpty(); + + context.Properties["chave"] = 123; + context.Properties["chave"].Should().Be(123); + } + + [Fact] + public void Dispose_DeveDescartarTenantDescartavel() + { + var tenant = new DisposableTenant(); + var context = new TenantContext(tenant); + + context.Dispose(); + + tenant.Disposed.Should().BeTrue(); + } + + [Fact] + public void Dispose_DeveDescartarPropriedadesDescartaveis() + { + var disposable = new DisposableTenant(); + var context = new TenantContext(new TestTenant()); + context.Properties["recurso"] = disposable; + context.Properties["naoDescartavel"] = "texto"; + + context.Dispose(); + + disposable.Disposed.Should().BeTrue(); + } + + [Fact] + public void Dispose_Duplo_NaoLancaExcecao() + { + var context = new TenantContext(new DisposableTenant()); + + context.Dispose(); + var act = () => context.Dispose(); + + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_ComPropriedadeJaDescartada_EngoleObjectDisposedException() + { + var context = new TenantContext(new TestTenant()); + context.Properties["stream"] = new ThrowOnDisposeStream(); + + var act = () => context.Dispose(); + + act.Should().NotThrow(); + } + + private sealed class ThrowOnDisposeStream : IDisposable + { + public void Dispose() + { + throw new ObjectDisposedException("stream"); + } + } +} + +public class TenantWrapperTests +{ + [Fact] + public void Value_RetornaOTenantInformado() + { + var tenant = new TestTenant { TenantKey = "abc" }; + + new TenantWrapper(tenant).Value.Should().BeSameAs(tenant); + } +} + +public class MemoryCacheTenantResolverOptionsTests +{ + [Fact] + public void Padrao_HabilitaEvictAllEDispose() + { + var options = new MemoryCacheTenantResolverOptions(); + + options.EvictAllEntriesOnExpiry.Should().BeTrue(); + options.DisposeOnEviction.Should().BeTrue(); + } +} + +public class MultitenancyHttpContextExtensionsTests +{ + [Fact] + public void SetGetTenantContext_FazRoundtripPeloItems() + { + var httpContext = new DefaultHttpContext(); + var tenantContext = new TenantContext(new TestTenant { TenantKey = "t1" }); + + httpContext.SetTenantContext(tenantContext); + + httpContext.GetTenantContext().Should().BeSameAs(tenantContext); + } + + [Fact] + public void GetTenantContext_SemContexto_RetornaNulo() + { + new DefaultHttpContext().GetTenantContext().Should().BeNull(); + } + + [Fact] + public void GetTenant_RetornaOTenantTipado() + { + var httpContext = new DefaultHttpContext(); + var tenant = new TestTenant { TenantKey = "t1" }; + httpContext.SetTenantContext(new TenantContext(tenant)); + + httpContext.GetTenant().Should().BeSameAs(tenant); + } + + [Fact] + public void GetTenant_SemContexto_RetornaDefault() + { + new DefaultHttpContext().GetTenant().Should().BeNull(); + } + + [Fact] + public void GetTenantContext_ComValorDeOutroTipoNoItems_RetornaNulo() + { + var httpContext = new DefaultHttpContext(); + httpContext.Items["softprime.TenantContext"] = "não é um TenantContext"; + + httpContext.GetTenantContext().Should().BeNull(); + } +} diff --git a/tests/Codout.Multitenancy.Tests/TestTenant.cs b/tests/Codout.Multitenancy.Tests/TestTenant.cs new file mode 100644 index 0000000..0acf619 --- /dev/null +++ b/tests/Codout.Multitenancy.Tests/TestTenant.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Memory; + +namespace Codout.Multitenancy.Tests; + +public class TestTenant : IAppTenant +{ + public string TenantKey { get; set; } = string.Empty; + + public DataBaseType DataBaseType { get; set; } + + public string ConnectionString { get; set; } = string.Empty; +} + +public class DisposableTenant : TestTenant, IDisposable +{ + public bool Disposed { get; private set; } + + public void Dispose() + { + Disposed = true; + } +} + +/// +/// Resolver concreto para exercitar MemoryCacheTenantResolver: resolve o tenant +/// pelo host da requisição a partir de um dicionário em memória. +/// +public class HostTenantResolver(IMemoryCache cache, MemoryCacheTenantResolverOptions options) + : MemoryCacheTenantResolver(cache, options) +{ + public HostTenantResolver(IMemoryCache cache) + : this(cache, new MemoryCacheTenantResolverOptions()) + { + } + + public Dictionary Tenants { get; } = new(); + + public int ResolveCalls { get; private set; } + + /// + /// Permite simular o cenário em que o identificador do contexto (chave de busca) + /// difere do identificador do tenant (chave de gravação). + /// + public Func? TenantIdentifierOverride { get; set; } + + protected override string GetContextIdentifier(HttpContext context) + { + var host = context.Request.Host.Value; + return string.IsNullOrEmpty(host) ? null! : host; + } + + protected override string GetTenantIdentifier(TenantContext context) + { + return TenantIdentifierOverride != null + ? TenantIdentifierOverride(context) + : ((TestTenant)context.Tenant).TenantKey; + } + + protected override Task ResolveAsync(HttpContext context) + { + ResolveCalls++; + + return Task.FromResult( + Tenants.TryGetValue(context.Request.Host.Value!, out var tenant) + ? new TenantContext(tenant) + : null!); + } +} + +public static class HttpContextFactory +{ + public static DefaultHttpContext WithHost(string host) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = "http"; + context.Request.Host = new HostString(host); + return context; + } +} diff --git a/tests/Codout.Security.Tests/Argon2Tests.cs b/tests/Codout.Security.Tests/Argon2Tests.cs new file mode 100644 index 0000000..fd518da --- /dev/null +++ b/tests/Codout.Security.Tests/Argon2Tests.cs @@ -0,0 +1,179 @@ +using Codout.Security.Argon2; +using Codout.Security.Core; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Security.Tests; + +public class Argon2Tests +{ + // Strength.Interactive (64 MiB / t=2) para manter os testes rápidos. + private static ArgonPasswordHash CreateInteractiveHasher() => + new(Options.Create(new ImprovedPasswordHasherOptions + { + Strength = PasswordHasherStrength.Interactive + })); + + [Fact] + public void HashPassword_GeraFormatoArgon2id() + { + var hash = CreateInteractiveHasher().HashPassword("minha-senha"); + hash.Should().StartWith("$argon2id$"); + hash.Should().NotContain("\0"); + } + + [Fact] + public void HashPassword_MesmaSenha_GeraHashesDiferentes() + { + var hasher = CreateInteractiveHasher(); + hasher.HashPassword("senha").Should().NotBe(hasher.HashPassword("senha")); + } + + [Fact] + public void VerifyHashedPassword_SenhaCorreta_ComLimitesCustomizados_RetornaSuccess() + { + // Com OpsLimit/MemLimit explícitos os parâmetros gerados e os esperados + // coincidem, então o roundtrip retorna Success. + var hasher = new ArgonPasswordHash( + argon2OptionsAccessor: Options.Create(new Argon2Options + { + OpsLimit = 3, + MemLimit = 32 * 1024 * 1024 + })); + + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-correta") + .Should().Be(PasswordVerificationResult.Success); + } + + [Fact] + public void VerifyHashedPassword_SenhaCorreta_ComStrength_ComportamentoAtual() + { + // BUG?: os parâmetros esperados em ArgonPasswordHash.GetExpectedParameters + // (Interactive: t=2/m=65536KiB) não batem com os que o Sodium.Core realmente + // usa (Interactive: t=4/m=32768KiB). Como m armazenado (32768) < m esperado + // (65536), TODO hash gerado via Strength se auto-reporta como + // SuccessRehashNeeded — nunca Success. O mesmo ocorre com Moderate + // (real t=6/m=131072 vs esperado t=3/m=262144). + var hasher = CreateInteractiveHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hash.Should().Contain("m=32768,t=4"); + hasher.VerifyHashedPassword(hash, "senha-correta") + .Should().Be(PasswordVerificationResult.SuccessRehashNeeded); + } + + [Fact] + public void VerifyHashedPassword_SenhaErrada_RetornaFailed() + { + var hasher = CreateInteractiveHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-errada") + .Should().Be(PasswordVerificationResult.Failed); + } + + [Fact] + public void VerifyHashedPassword_HashFraco_RetornaSuccessRehashNeeded() + { + // Hash gerado com Interactive (t=2, m=64MiB); verificador configurado + // com Moderate espera t=3 / m=256MiB → deve sinalizar rehash. + var weakHash = CreateInteractiveHasher().HashPassword("senha"); + + var strongHasher = new ArgonPasswordHash(Options.Create(new ImprovedPasswordHasherOptions + { + Strength = PasswordHasherStrength.Moderate + })); + + strongHasher.VerifyHashedPassword(weakHash, "senha") + .Should().Be(PasswordVerificationResult.SuccessRehashNeeded); + } + + [Fact] + public void HashPassword_ComOpsEMemLimitCustomizados_UsaParametrosInformados() + { + var hasher = new ArgonPasswordHash( + argon2OptionsAccessor: Options.Create(new Argon2Options + { + OpsLimit = 3, + MemLimit = 32 * 1024 * 1024 // 32 MiB + })); + + var hash = hasher.HashPassword("senha"); + + hash.Should().Contain("m=32768").And.Contain("t=3"); + hasher.VerifyHashedPassword(hash, "senha") + .Should().Be(PasswordVerificationResult.Success); + } + + [Fact] + public void HashPassword_SenhaNula_LancaArgumentNullException() + { + var hasher = CreateInteractiveHasher(); + var actNull = () => hasher.HashPassword(null!); + var actEmpty = () => hasher.HashPassword(""); + actNull.Should().Throw(); + actEmpty.Should().Throw(); + } + + [Fact] + public void VerifyHashedPassword_ArgumentosNulos_LancamArgumentNullException() + { + var hasher = CreateInteractiveHasher(); + var actHash = () => hasher.VerifyHashedPassword(null!, "x"); + var actPassword = () => hasher.VerifyHashedPassword("hash", null!); + actHash.Should().Throw(); + actPassword.Should().Throw(); + } + + [Fact] + public void UseArgon2_RegistraIPasswordHasherNoContainer() + { + var services = new ServiceCollection(); + services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Interactive) + .UseArgon2(o => + { + // Limites explícitos para um roundtrip Success (ver teste + // VerifyHashedPassword_SenhaCorreta_ComStrength_ComportamentoAtual) + o.OpsLimit = 3; + o.MemLimit = 32 * 1024 * 1024; + }); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var hasher = scope.ServiceProvider.GetRequiredService(); + hasher.Should().BeOfType(); + + var options = scope.ServiceProvider + .GetRequiredService>(); + options.Value.Strength.Should().Be(PasswordHasherStrength.Interactive); + + var hash = hasher.HashPassword("senha-via-di"); + hasher.VerifyHashedPassword(hash, "senha-via-di") + .Should().Be(PasswordVerificationResult.Success); + } + + [Fact] + public void UseArgon2_ComConfigure_AplicaOpcoes() + { + var services = new ServiceCollection(); + services.UpgradePasswordSecurity() + .UseArgon2(o => + { + o.OpsLimit = 3; + o.MemLimit = 32 * 1024 * 1024; + }); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var hasher = scope.ServiceProvider.GetRequiredService(); + var hash = hasher.HashPassword("senha"); + hash.Should().Contain("m=32768").And.Contain("t=3"); + } +} diff --git a/tests/Codout.Security.Tests/BcryptTests.cs b/tests/Codout.Security.Tests/BcryptTests.cs new file mode 100644 index 0000000..f278139 --- /dev/null +++ b/tests/Codout.Security.Tests/BcryptTests.cs @@ -0,0 +1,115 @@ +using Codout.Security.Bcrypt; +using Codout.Security.Core; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Security.Tests; + +public class BcryptTests +{ + // WorkFactor mínimo (4) para manter os testes rápidos. + private static BcryptPasswordHash CreateFastHasher( + int workFactor = 4, + BcryptSaltRevision revision = BcryptSaltRevision.Revision2B) => + new(Options.Create(new BcryptOptions { WorkFactor = workFactor, SaltRevision = revision })); + + [Fact] + public void HashPassword_GeraFormatoBcryptComWorkFactor() + { + var hash = CreateFastHasher().HashPassword("minha-senha"); + hash.Should().StartWith("$2b$04$"); + } + + [Fact] + public void HashPassword_ComRevision2Y_GeraPrefixoCorrespondente() + { + var hash = CreateFastHasher(revision: BcryptSaltRevision.Revision2Y).HashPassword("senha"); + hash.Should().StartWith("$2y$04$"); + } + + [Fact] + public void HashPassword_MesmaSenha_GeraHashesDiferentes() + { + var hasher = CreateFastHasher(); + hasher.HashPassword("senha").Should().NotBe(hasher.HashPassword("senha")); + } + + [Fact] + public void VerifyHashedPassword_SenhaCorreta_RetornaSuccess() + { + var hasher = CreateFastHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-correta") + .Should().Be(PasswordVerificationResult.Success); + } + + [Fact] + public void VerifyHashedPassword_SenhaErrada_RetornaFailed() + { + var hasher = CreateFastHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-errada") + .Should().Be(PasswordVerificationResult.Failed); + } + + [Fact] + public void VerifyHashedPassword_WorkFactorMenorQueConfigurado_RetornaSuccessRehashNeeded() + { + var weakHash = CreateFastHasher(workFactor: 4).HashPassword("senha"); + var strongHasher = CreateFastHasher(workFactor: 6); + + strongHasher.VerifyHashedPassword(weakHash, "senha") + .Should().Be(PasswordVerificationResult.SuccessRehashNeeded); + } + + [Fact] + public void HashPassword_SenhaNulaOuVazia_LancaArgumentNullException() + { + var hasher = CreateFastHasher(); + var actNull = () => hasher.HashPassword(null!); + var actEmpty = () => hasher.HashPassword(""); + actNull.Should().Throw(); + actEmpty.Should().Throw(); + } + + [Fact] + public void VerifyHashedPassword_ArgumentosNulos_LancamArgumentNullException() + { + var hasher = CreateFastHasher(); + var actHash = () => hasher.VerifyHashedPassword(null!, "x"); + var actPassword = () => hasher.VerifyHashedPassword("hash", null!); + actHash.Should().Throw(); + actPassword.Should().Throw(); + } + + [Fact] + public void Construtor_SemOptions_UsaPadroes() + { + // BcryptOptions padrão: WorkFactor 12, Revision2B + var hash = new BcryptPasswordHash().HashPassword("senha"); + hash.Should().StartWith("$2b$12$"); + } + + [Fact] + public void UseBcrypt_RegistraIPasswordHasherNoContainer() + { + var services = new ServiceCollection(); + services.UpgradePasswordSecurity() + .UseBcrypt(o => o.WorkFactor = 4); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var hasher = scope.ServiceProvider.GetRequiredService(); + hasher.Should().BeOfType(); + + var hash = hasher.HashPassword("senha-via-di"); + hash.Should().StartWith("$2b$04$"); + hasher.VerifyHashedPassword(hash, "senha-via-di") + .Should().Be(PasswordVerificationResult.Success); + } +} diff --git a/tests/Codout.Security.Tests/Codout.Security.Tests.csproj b/tests/Codout.Security.Tests/Codout.Security.Tests.csproj new file mode 100644 index 0000000..3d4457a --- /dev/null +++ b/tests/Codout.Security.Tests/Codout.Security.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Codout.Security.Tests/CoreTests.cs b/tests/Codout.Security.Tests/CoreTests.cs new file mode 100644 index 0000000..a1dbe39 --- /dev/null +++ b/tests/Codout.Security.Tests/CoreTests.cs @@ -0,0 +1,69 @@ +using Codout.Security.Core; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codout.Security.Tests; + +public class CoreTests +{ + [Fact] + public void ImprovedPasswordHasherOptions_PadraoEhSensitive() + { + new ImprovedPasswordHasherOptions().Strength.Should().Be(PasswordHasherStrength.Sensitive); + } + + [Fact] + public void PasswordHasherBuilder_ServicesNulo_LancaArgumentNullException() + { + var act = () => new PasswordHasherBuilder(null!); + act.Should().Throw(); + } + + [Fact] + public void PasswordHasherBuilder_ExpoeServicesRecebido() + { + var services = new ServiceCollection(); + var builder = new PasswordHasherBuilder(services); + builder.Services.Should().BeSameAs(services); + } + + [Fact] + public void WithStrength_AlteraOptionsERetornaMesmoBuilder() + { + var builder = new PasswordHasherBuilder(new ServiceCollection()); + + var result = builder.WithStrength(PasswordHasherStrength.Interactive); + + result.Should().BeSameAs(builder); + builder.Options.Strength.Should().Be(PasswordHasherStrength.Interactive); + } + + [Fact] + public void UseCustomHashPasswordBuilder_RetornaBuilderConfigurado() + { + var services = new ServiceCollection(); + var builder = services.UseCustomHashPasswordBuilder(); + + builder.Should().BeOfType(); + builder.Services.Should().BeSameAs(services); + } + + [Fact] + public void UpgradePasswordSecurity_RetornaBuilder() + { + var services = new ServiceCollection(); + services.UpgradePasswordSecurity().Should().NotBeNull(); + } + + [Fact] + public void PasswordVerificationResult_PossuiOsTresEstados() + { + Enum.GetValues().Should().BeEquivalentTo(new[] + { + PasswordVerificationResult.Failed, + PasswordVerificationResult.Success, + PasswordVerificationResult.SuccessRehashNeeded + }); + } +} diff --git a/tests/Codout.Security.Tests/ScryptTests.cs b/tests/Codout.Security.Tests/ScryptTests.cs new file mode 100644 index 0000000..7a753f4 --- /dev/null +++ b/tests/Codout.Security.Tests/ScryptTests.cs @@ -0,0 +1,106 @@ +using Codout.Security.Core; +using Codout.Security.Scrypt; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codout.Security.Tests; + +public class ScryptTests +{ + // Strength.Interactive (16 MiB) para manter os testes rápidos. + private static ScryptPasswordHash CreateInteractiveHasher() => + new(Options.Create(new ImprovedPasswordHasherOptions + { + Strength = PasswordHasherStrength.Interactive + })); + + [Fact] + public void HashPassword_GeraFormatoScrypt() + { + var hash = CreateInteractiveHasher().HashPassword("minha-senha"); + hash.Should().StartWith("$7$"); + } + + [Fact] + public void HashPassword_MesmaSenha_GeraHashesDiferentes() + { + var hasher = CreateInteractiveHasher(); + hasher.HashPassword("senha").Should().NotBe(hasher.HashPassword("senha")); + } + + [Fact] + public void VerifyHashedPassword_SenhaCorreta_RetornaSuccess() + { + var hasher = CreateInteractiveHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-correta") + .Should().Be(PasswordVerificationResult.Success); + } + + [Fact] + public void VerifyHashedPassword_SenhaErrada_RetornaFailed() + { + var hasher = CreateInteractiveHasher(); + var hash = hasher.HashPassword("senha-correta"); + + hasher.VerifyHashedPassword(hash, "senha-errada") + .Should().Be(PasswordVerificationResult.Failed); + } + + [Fact] + public void VerifyHashedPassword_HashFraco_RetornaSuccessRehashNeeded() + { + // Hash Interactive (N=2^14); verificador Sensitive espera N=2^20. + var weakHash = CreateInteractiveHasher().HashPassword("senha"); + + var strongHasher = new ScryptPasswordHash(Options.Create(new ImprovedPasswordHasherOptions + { + Strength = PasswordHasherStrength.Sensitive + })); + + strongHasher.VerifyHashedPassword(weakHash, "senha") + .Should().Be(PasswordVerificationResult.SuccessRehashNeeded); + } + + [Fact] + public void HashPassword_SenhaNulaOuVazia_LancaArgumentNullException() + { + var hasher = CreateInteractiveHasher(); + var actNull = () => hasher.HashPassword(null!); + var actEmpty = () => hasher.HashPassword(""); + actNull.Should().Throw(); + actEmpty.Should().Throw(); + } + + [Fact] + public void VerifyHashedPassword_ArgumentosNulos_LancamArgumentNullException() + { + var hasher = CreateInteractiveHasher(); + var actHash = () => hasher.VerifyHashedPassword(null!, "x"); + var actPassword = () => hasher.VerifyHashedPassword("hash", null!); + actHash.Should().Throw(); + actPassword.Should().Throw(); + } + + [Fact] + public void UseScrypt_RegistraIPasswordHasherNoContainer() + { + var services = new ServiceCollection(); + services.UpgradePasswordSecurity() + .WithStrength(PasswordHasherStrength.Interactive) + .UseScrypt(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var hasher = scope.ServiceProvider.GetRequiredService(); + hasher.Should().BeOfType(); + + var hash = hasher.HashPassword("senha-via-di"); + hasher.VerifyHashedPassword(hash, "senha-via-di") + .Should().Be(PasswordVerificationResult.Success); + } +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..220f554 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,22 @@ + + + + + + + false + none + + $(NoWarn);CS0618;CS0612 + false + false + + + + + + + diff --git a/tests/FINDINGS-A.md b/tests/FINDINGS-A.md new file mode 100644 index 0000000..0ab7fd9 --- /dev/null +++ b/tests/FINDINGS-A.md @@ -0,0 +1,86 @@ +# FINDINGS — Suítes de teste (Common, Security, Image.Extensions) + +Achados registrados durante a criação das suítes de teste. Nenhum código de +produção foi alterado; os comportamentos abaixo estão documentados nos testes +como *characterization tests* marcados com `// BUG?:`. + +## Codout.Framework.Common + +### 1. `StringExtensions.RemoveAccents` lança exceção em .NET moderno (e usa codepage errado) +- Arquivo: `Codout.Framework.Common/Extensions/StringExtensions.cs` +- O método usa `Encoding.GetEncoding("iso-8859-8")` (hebraico) para "remover + acentos". Em .NET (Core/5+) esse encoding não está registrado por padrão + (exigiria `CodePagesEncodingProvider`), então a chamada lança + `ArgumentException` em runtime. Mesmo se o encoding estivesse disponível, o + codepage correto para transliterar latinos seria outro (ex.: normalização + Unicode FormD, como o `SlugHelper` já faz). +- Efeito colateral: `RemoveCharactersSpecial(..., replaceAccents: true)` + (caminho padrão) também lança a mesma exceção. +- Teste: `StringExtensionsTests.RemoveAccents_ComportamentoAtual`. + +### 2. `StringExtensions.Chop(string backDownTo)` lança exceção quando o padrão não existe +- Arquivo: `Codout.Framework.Common/Extensions/StringExtensions.cs` +- Quando `LastIndexOf` retorna `-1`, o código chama `Remove(-1, 0)`, que lança + `ArgumentOutOfRangeException` em vez de retornar a string original. +- Teste: `StringExtensionsTests.Chop_ComStringAlvoInexistente_LancaExcecao`. + +### 3. `StringExtensions.HtmlEncode` faz duplo escape do `&` das entidades +- Arquivo: `Codout.Framework.Common/Extensions/StringExtensions.cs` +- A substituição de `&` por `&` acontece **depois** da geração das + entidades, então `é` vira `&eacute;` em vez de `é`. O roundtrip + com `HtmlDecode` ainda funciona porque o decode desfaz na ordem inversa, mas + a saída não é HTML-encoding válido para consumo externo. +- Teste: `StringExtensionsTests.HtmlEncode_HtmlDecode_SaoInversos`. + +### 4. `DateTimeExtensions.GetAge` erra a idade no dia do aniversário +- Arquivo: `Codout.Framework.Common/Extensions/DateTimeExtensions.cs` +- A comparação `dateOfBirth.DayOfYear < dateAsAt.DayOfYear` é estrita: no + próprio dia do aniversário a idade é subtraída em 1 (nascido em 15/06/2000, + em 15/06/2020 o método retorna 19 em vez de 20). A comparação por `DayOfYear` + também sofre desvio de 1 dia em anos bissextos. +- Teste: `DateTimeExtensionsTests.GetAge_NoDiaDoAniversario_ComportamentoAtual`. + +### 5. `ValidationExtensions.IsEmail` considera string vazia/whitespace válida +- Arquivo: `Codout.Framework.Common/Extensions/ValidationExtensions.cs` +- `string.IsNullOrWhiteSpace(email) || Regex.IsMatch(...)` faz com que `""` e + `" "` retornem `true`. Pode ser intencional (campo opcional), mas é + surpreendente para um validador chamado `IsEmail`. +- Teste: `ValidationExtensionsTests.IsEmail_StringVazia_RetornaTrue`. + +### 6. `EnumHelper.GetLocalizedName` lê `Description` em vez de `Name` do `DisplayAttribute` (observação) +- Arquivo: `Codout.Framework.Common/Helpers/EnumHelper.cs` +- O método chama `DisplayAttribute.GetDescription()`; se o atributo for usado + como `[Display(Name = "...")]` (uso mais comum), o retorno é `null`. Os + testes usam `[Display(Description = "...")]` para refletir o comportamento + atual. +- Teste: `EnumHelperTests.GetLocalizedName_ComDisplayAttribute_RetornaNome`. + +## Codout.Security (src/Security) + +### 7. `ArgonPasswordHash.NeedsRehash` usa tabela de parâmetros que não corresponde ao Sodium.Core +- Arquivo: `src/Security/Codout.Security.Argon2/ArgonPasswordHash.cs` +- `GetExpectedParameters()` assume as constantes do libsodium para argon2id + (`Interactive: t=2, m=65536 KiB; Moderate: t=3, m=262144 KiB`), mas o + Sodium.Core 1.4 gera hashes com outras constantes (verificado empiricamente): + - `Interactive`: `m=32768, t=4` + - `Moderate`: `m=131072, t=6` +- Como o `m` armazenado é sempre menor que o esperado, **todo** hash gerado via + `Strength` (Interactive/Moderate) retorna `SuccessRehashNeeded` ao ser + verificado pelo mesmo hasher — nunca `Success` — induzindo rehash perpétuo a + cada login. Com `Argon2Options.OpsLimit`/`MemLimit` explícitos o roundtrip + funciona corretamente (`Success`). +- Testes: + - `Argon2Tests.VerifyHashedPassword_SenhaCorreta_ComStrength_ComportamentoAtual` + (characterization do bug) + - `Argon2Tests.VerifyHashedPassword_SenhaCorreta_ComLimitesCustomizados_RetornaSuccess` + (caminho que funciona) +- Obs.: o equivalente em Scrypt (`ScryptPasswordHash.NeedsRehash`) está correto + — o roundtrip com `Strength.Interactive` retorna `Success` + (`ScryptTests.VerifyHashedPassword_SenhaCorreta_RetornaSuccess`). + +## Codout.Image.Extensions + +Nenhum bug encontrado nas APIs testadas (`Extract`, `DrawRectangles`, +`DrawPoints`, `DrawRectanglesAndPoints`). Os métodos de `GeometryExtensions` +são `internal` e não foram testados para não alterar o código de produção +(faltaria `InternalsVisibleTo`). diff --git a/tests/FINDINGS-B.md b/tests/FINDINGS-B.md new file mode 100644 index 0000000..d68714f --- /dev/null +++ b/tests/FINDINGS-B.md @@ -0,0 +1,68 @@ +# FINDINGS-B — Achados durante a escrita das suítes de teste + +Achados registrados durante a criação/ampliação das suítes de +`Codout.Framework.Data.Tests`, `Codout.Framework.Domain.Tests` e +`Codout.Framework.EF.Tests`. Nenhum código de produção foi alterado; cada item +abaixo tem um characterization test marcado com `// BUG?:` documentando o +comportamento ATUAL. + +## 1. `IAuditable` duplicada — o `AuditableInterceptor` ignora a abstração pública + +- **Caminho:** `Codout.Framework.EF/Interceptors/AuditableInterceptor.cs` (duplicata) e + `Codout.Framework.Data/Auditing/IAuditable.cs` (abstração pública). +- **Descrição:** existem duas interfaces `IAuditable` com a mesma forma: + `Codout.Framework.Data.Auditing.IAuditable` e + `Codout.Framework.EF.Interceptors.IAuditable` (declarada no fim de + `AuditableInterceptor.cs`). Embora o arquivo do interceptor importe + `Codout.Framework.Data.Auditing`, a resolução de nomes do C# prioriza o tipo do + namespace corrente — o check `e.Entity is IAuditable` usa a duplicata LOCAL. + Resultado: entidades que implementam somente a abstração pública + `Data.Auditing.IAuditable` são **silenciosamente ignoradas** pela auditoria + automática (CreatedAt/CreatedBy/UpdatedAt/UpdatedBy nunca são preenchidos). + O mesmo arquivo também duplica `ICurrentUserProvider` + (vs. `Codout.Framework.Data/Auditing/ICurrentUserProvider.cs`), de modo que o + ctor do interceptor exige a duplicata local, não a abstração do pacote Data. +- **Teste:** `tests/Codout.Framework.EF.Tests/InterceptorTests.cs` → + `Entity_implementing_only_the_Data_Auditing_IAuditable_is_not_audited`. +- **Sugestão (não aplicada):** remover as interfaces duplicadas de + `AuditableInterceptor.cs` e usar as de `Codout.Framework.Data.Auditing` + (mudança potencialmente breaking para quem referencia os tipos do namespace + `Codout.Framework.EF.Interceptors` — avaliar bump minor/major). + +## 2. `EFRepository.RefreshAsync(entity, cancellationToken)` é síncrono e ignora o token + +- **Caminho:** `Codout.Framework.EF/EFRepository.cs`. +- **Descrição:** o overload `RefreshAsync(T entity, CancellationToken cancellationToken)` + faz `Task.FromResult(Refresh(entity))`, ou seja, executa `Reload()` SÍNCRONO na + thread chamadora e ignora completamente o `CancellationToken` — mesmo um token + já cancelado não interrompe a operação. Inconsistente com o overload sem token, + que usa `ReloadAsync()`, e com o resto da superfície async (fix análogo já foi + feito no `SaveOrUpdateAsync` na 6.3.0). +- **Teste:** `tests/Codout.Framework.EF.Tests/EFRepositoryAsyncTests.cs` → + `RefreshAsync_with_token_currently_ignores_cancellation`. +- **Sugestão (não aplicada):** `await Context.Entry(entity).ReloadAsync(cancellationToken)`. + +## 3. Assimetria `Commit()` × `CommitAsync()` sem transação ativa + +- **Caminho:** `Codout.Framework.EF/EFUnitOfWork.cs`. +- **Descrição:** `Commit()` sem transação ativa faz apenas `SaveChanges()` + (auto-commit do EF Core), mas `CommitAsync()` na mesma situação lança + `InvalidOperationException("Nenhuma transação ativa para commit...")`. + Código que funciona na via síncrona quebra ao migrar para a assíncrona. +- **Teste:** `tests/Codout.Framework.EF.Tests/EFUnitOfWorkTests.cs` → + `CommitAsync_without_transaction_throws_unlike_sync_Commit`. +- **Sugestão (não aplicada):** alinhar os dois contratos (provavelmente fazer o + `CommitAsync` aceitar auto-commit via `SaveChangesAsync`). + +## Observações menores (sem teste dedicado) + +- `EFUnitOfWork.Commit(IsolationLevel)` ignora o parâmetro `isolationLevel` + (há comentário no código explicando que o isolation level pertence ao + `BeginTransaction`); a existência do overload no contrato `IUnitOfWork` induz + o consumidor a achar que tem efeito. +- Há também duas `ISoftDeletable` no ecossistema: + `Codout.Framework.Data.Auditing.ISoftDeletable` (IsDeleted/DeletedAt/DeletedBy, + usada pelo `SoftDeleteInterceptor`) e + `Codout.Framework.Domain.Interfaces.ISoftDeletable` (apenas `DeletedAt`). + Entidades que implementem só a versão do Domain não recebem soft delete + automático — mesmo padrão de armadilha do achado nº 1. diff --git a/tests/FINDINGS-C.md b/tests/FINDINGS-C.md new file mode 100644 index 0000000..c573f19 --- /dev/null +++ b/tests/FINDINGS-C.md @@ -0,0 +1,119 @@ +# FINDINGS — suítes de teste Mailer / Storage (sessão C) + +Achados registrados durante a criação das suítes `Codout.Mailer.Tests`, +`Codout.Mailer.Razor.Tests`, `Codout.Framework.Storage.Tests` e +`Codout.Framework.Storage.Azure.Tests`. Nenhum código de produção foi alterado; +os comportamentos abaixo estão cobertos por testes de caracterização +(marcados com `// BUG?:` quando aplicável). + +## Codout.Mailer + +- **`Codout.Mailer/Services/MailerHealthCheck.cs`** + - A classe está no **namespace global** (o arquivo não declara `namespace`), + embora esteja na pasta `Services`. + - O campo `_dispatcher` é declarado mas **nunca atribuído nem usado** (não há + construtor) — o health check não verifica conectividade nenhuma e sempre + retorna `Healthy` (o próprio código tem o comentário "Implementar verificação + de conectividade"). Gera warning CS0169 no build. + - Coberto em `ConfigureServicesTests.MailerHealthCheck_DeveRetornarHealthy`. + +- **`Codout.Mailer/Services/MailerServiceBase.cs`** + - `model.To.Address` é acessado no log **antes** do bloco `try`; um model sem + destinatário derruba o chamador com `NullReferenceException` em vez de + retornar `MailerResponse { Sent = false }` como as demais falhas. + - Caracterizado em `MailerServiceBaseTests.Send_ComModelSemDestinatario_LancaNullReference`. + +- **`Codout.Mailer/Helpers/HtmlUtilities.cs`** + - `Cut(text, length)` com `length < 4` lança `ArgumentOutOfRangeException` + (slice `text[..(length - 4)]` com índice negativo). + - `CountWords` usa `Split(' ', '\n')` sem `RemoveEmptyEntries`: espaços + consecutivos inflam a contagem (`"uma duas"` → 3). + - Caracterizados em `HtmlUtilitiesTests`. + +- **`Codout.Mailer/Helpers/Extensions.cs`** + - `ReadFully` força `stream.Position = 0`, exigindo stream *seekable*; streams + de rede/pipe lançam `NotSupportedException`. + - Caracterizado em `ExtensionsTests.ReadFully_ComStreamNaoSeekable_LancaNotSupportedException`. + +- **`Codout.Mailer/Models/MailerResponse.cs`** + - `ErrorMessages` não é inicializada (fica `null` em respostas de sucesso); + consumidores precisam checar `null` antes de iterar. + +## Codout.Mailer.AWS + +- **`Codout.Mailer.AWS/AWSDispatcher.cs`** + - O `AmazonSimpleEmailServiceV2Client` é **instanciado dentro de `Send`** + (não injetável). Não é possível testar a montagem da mensagem MIME nem o + tratamento da resposta do SES com fakes/handlers — só com chamada de rede + real, o que é proibido nos testes. A suíte cobre apenas as validações de + configuração (executadas antes de qualquer I/O) e o registro de DI. + - **Inconsistência**: a validação de settings (`AccessKey`/`RegionEndpoint`/ + `SecretKey`) **lança `InvalidOperationException`**, enquanto qualquer outra + falha é convertida em `MailerResponse { Sent = false }` pelo `catch`. + - **Observação**: no caminho de sucesso, o `MessageId` retornado pelo SES é + colocado em `ErrorMessages` (semanticamente errado, é o campo de erros). + - Sugestão (não aplicada): receber `IAmazonSimpleEmailServiceV2` via construtor + para permitir fakes. + +## Codout.Mailer.SendGrid + +- **`Codout.Mailer.SendGrid/SendGridDispatcher.cs`** + - O `SendGridClient` é **instanciado dentro de `Send`** (não injetável). O SDK + tem construtor `SendGridClient(HttpClient, ...)` que permitiria interceptar + com `HttpMessageHandler` fake, mas o dispatcher não o expõe — montagem do + `SendGridMessage` e tratamento de resposta não são testáveis sem rede. + - `SendGridSettings.StandBox` (provável typo de **Sandbox**) **não é usado** + em lugar nenhum. + - Não há validação da `ApiKey` (nem na construção nem antes do envio). + - `Sent` só é `true` para `202 Accepted` (200 OK resultaria `false`) — correto + para a API v3 do SendGrid, registrado como observação. + +## Codout.Mailer.Razor + +- A renderização Razor real (compilação em runtime + template embarcado) **foi + possível** sem host ASP.NET Core completo: bastou `IWebHostEnvironment` fake, + `DiagnosticListener("Microsoft.AspNetCore")` e `PreserveCompilationContext` + habilitado no csproj de teste. Coberta em `RazorRenderingIntegrationTests`. +- `RazorMailerOptions.Validate()` é `internal`, então a validação só é testável + através de `AddMailerRazor` (coberto em `ConfigureServicesTests`). +- `RazorMailerOptions.EnableCache` **não é usado** em lugar nenhum (nem no + engine nem no `AddMailerRazor`). + +## Codout.Framework.Storage + +- O pacote define `FileSystemStorageOptions` e `AwsStorageOptions`, mas **não + existe implementação `IStorage` de file system nem de AWS S3** no repositório + (somente a do Azure). A suíte cobre exceções, options, models e valida o + contrato `IStorage` com um fake em memória + (`InMemoryStorageContractTests`). + +## Codout.Framework.Storage.Azure + +- **`Codout.Framework.Storage.Azure/AzureStorage.cs`** + - `GetBlobUri` com CDN habilitado **não faz URL-encoding do `fileName`** + (interpolação direta), enquanto o endpoint de blob (SDK) escapa caracteres + especiais — o mesmo blob gera URIs divergentes (ex.: espaço vs `%20`). + Caracterizado em `AzureStorageBlobUriTests.GetBlobUri_ComCdn_NaoEscapaNomeDoArquivo`. + - Connection string sintaticamente inválida não falha na construção (client é + `Lazy`); o erro só aparece no primeiro uso e **escapa como `FormatException` + do SDK** em vez de `StorageException` da abstração. Caracterizado em + `AzureStorageConstructionTests`. + - Operações que dependem de requisição real (Exists/Download/List/SAS/Upload + efetivo etc.) **não são testáveis offline**: o `BlobServiceClient` é criado + internamente a partir da connection string, sem ponto de injeção para um + client fake e sem Azurite no ambiente. A suíte cobre construção, validação + de argumentos (que ocorre antes de qualquer I/O), montagem de URIs + (local, sem rede) e o registro de DI. +- **`Extensions/ServiceCollectionExtensions.cs`** + - `AddAzureStorage(options)` cria a instância de `AzureStorage` **eagerly no + momento do registro** (`AddSingleton` com instância), então options + inválidas falham no `AddAzureStorage`, não no primeiro resolve. + Caracterizado em `ServiceCollectionExtensionsTests`. + +## Observação de build (não é bug de produto) + +- `Directory.Build.props` liga `GeneratePackageOnBuild` globalmente; compilar os + projetos de teste dispara o pack dos projetos referenciados (e, fora da + solution, `$(SolutionDir)Packages` resolve para `/Packages/`). Os + csproj de teste desligam isso para si mesmos; para não gerar `.nupkg` ao rodar + as suítes, use `dotnet test ... -p:GeneratePackageOnBuild=false`. diff --git a/tests/FINDINGS-D.md b/tests/FINDINGS-D.md new file mode 100644 index 0000000..85ce55e --- /dev/null +++ b/tests/FINDINGS-D.md @@ -0,0 +1,86 @@ +# FINDINGS — suítes de teste NH / Mongo (sessão D) + +Achados registrados durante a criação das suítes `Codout.Framework.NH.Tests` e +`Codout.Framework.Mongo.Tests`. Nenhum código de produção foi alterado; os +comportamentos abaixo estão cobertos por testes de caracterização (marcados +com `// BUG?:` quando aplicável). + +## Ambiente / decisões de infraestrutura + +- **Mongo — caminho adotado: EphemeralMongo FUNCIONOU.** O pacote + `EphemeralMongo 3.2.0` baixou e subiu o `mongod` neste ambiente Linux, + inclusive com `UseSingleNodeReplicaSet = true` (necessário para as + transações do `MongoUnitOfWork`). Os testes de integração rodaram de + verdade (0 skipped). A fixture (`tests/Codout.Framework.Mongo.Tests/TestInfrastructure.cs`) + continua resiliente: se o `mongod` não subir em outro ambiente, os testes de + integração (`[SkippableFact]`) são marcados como SKIPPED com a razão da + falha, e os testes de unidade (validação de opções, null-checks, nome de + coleção, DI) rodam sem servidor. +- **NH — o NHibernate 5.6.0 NÃO possui `MicrosoftDataSqliteDriver`** embutido + (apenas `SQLite20Driver`, que reflete sobre `System.Data.SQLite`, e + `CsharpSqliteDriver`). Foi criado um driver **somente de teste** + (`MicrosoftDataSqliteTestDriver : ReflectionBasedDriver`) apontando para + `Microsoft.Data.Sqlite`. +- **Microsoft.Data.Sqlite não implementa `GetSchema("DataTypes")`**, usado + pelo auto-import de keywords do NHibernate no build da SessionFactory + (`SchemaMetadataUpdater`). Os testes desligam isso com + `hbm2ddl.keywords = none`. Quem for usar o pacote `Codout.Framework.NH` + com Microsoft.Data.Sqlite em produção esbarra no mesmo problema. +- SQLite `:memory:` não funciona com NHibernate na configuração padrão + (conexão é aberta/fechada por sessão e o banco evapora); os testes usam um + arquivo temporário por fixture. + +## Codout.Framework.Mongo + +- **`Codout.Framework.Mongo/MongoRepository.cs` — `WherePaged` retorna `total` errado.** + O `total` (out) é calculado **depois** de aplicar `Skip/Take`, ou seja, + devolve o tamanho da página (≤ `size`) e não o total de registros que + satisfazem o filtro. O `NHRepository.WherePaged` conta **antes** de paginar + (comportamento correto); um paginador construído sobre o `total` do Mongo + verá sempre "1 página". `// BUG?:` caracterizado em + `MongoRepositoryQueryTests.WherePaged_Caracterizacao_TotalRetornaTamanhoDaPaginaENaoOTotalDoFiltro`. + +- **`MongoRepository.Get(object key)` / `GetAsync` / `Load` só aceitam chaves ObjectId.** + Qualquer chave que não parseie como `ObjectId` (int, Guid, string arbitrária + — formatos legítimos de `_id` no MongoDB) retorna `null` **silenciosamente, + sem consultar o servidor** e sem lançar erro. Entidades com Id não-ObjectId + ficam inacessíveis por chave. `// BUG?:` caracterizado em + `MongoRepositoryUnitTests.Get_ComChaveNaoObjectId_RetornaNullSemConsultarOServidor`. + +- **`MongoRepository` não participa da transação do `MongoUnitOfWork`.** + O repositório nunca usa `MongoUnitOfWork.CurrentSession` (`IClientSessionHandle`); + todas as operações executam fora da sessão transacional. Consequência: + `uow.BeginTransaction(); repo.Save(x); uow.Rollback();` **não desfaz** o + `Save` — o documento permanece no banco. O UnitOfWork só tem efeito sobre + operações feitas manualmente com a sessão. `// BUG?:` caracterizado em + `MongoUnitOfWorkTests.Rollback_Caracterizacao_NaoDesfazOperacoesDoRepositorio`. + +- **`Get(predicate)` sync e async divergem.** A versão síncrona usa + `SingleOrDefault` (lança `InvalidOperationException` com múltiplos + resultados); `GetAsync(predicate)` usa `FirstOrDefault` (retorna o primeiro + sem validar unicidade). Mesmo contrato, semânticas diferentes. `// BUG?:` + caracterizado em + `MongoRepositoryAsyncTests.GetAsync_PorPredicadoComMultiplosResultados_NaoLanca_DiferenteDoSync`. + +- Observação (comportamento documentado, não necessariamente bug): + `Refresh`/`RefreshAsync` devolvem uma **nova** instância lida do banco e não + atualizam a instância passada (diferente do NH, que recarrega in-place) — + coberto em `MongoRepositoryCrudTests.Refresh_DeveRecarregarDoBanco`. + +## Codout.Framework.NH + +- **`Codout.Framework.NH/NHRepository.cs` — `AllReadOnly`/`WhereReadOnly` têm efeito colateral permanente na sessão.** + Ambos setam `Session.DefaultReadOnly = true` e **nunca restauram** o valor: + a sessão inteira vira read-only dali em diante. Qualquer entidade carregada + depois (mesmo via `All()`/`Get()`) sai do dirty-check e alterações são + silenciosamente ignoradas no `Flush`. `// BUG?:` caracterizado em + `NHRepositoryQueryTests.AllReadOnly_EfeitoColateral_EntidadesCarregadasDepoisNaoSaoPersistidas`. + +- **`NHRepository.WherePaged` não exige ordenação** — `Skip/Take` sem + `ORDER BY` produz paginação não determinística em SQL (o mesmo vale para a + versão Mongo). Comentário `// BUG?:` em + `NHRepositoryQueryTests.WherePaged_SemOrdenacaoExplicita_NaoFalha`. + +- `tests/Codout.Framework.NH.Tests/TestInfrastructure.cs` cobre o mapeamento + FluentNHibernate básico (`ClassMap` de `Widget`, PK identity, SchemaExport) + — o build da factory + CRUD completo passa sobre SQLite. diff --git a/tests/FINDINGS-E.md b/tests/FINDINGS-E.md new file mode 100644 index 0000000..25e556e --- /dev/null +++ b/tests/FINDINGS-E.md @@ -0,0 +1,131 @@ +# FINDINGS — suítes de teste DynamicLinq / Api.Dto / Application / Api.Client / Api / Multitenancy (sessão E) + +Achados registrados durante a criação das suítes `Codout.DynamicLinq.Tests`, +`Codout.Framework.Api.Dto.Tests`, `Codout.Framework.Application.Tests`, +`Codout.Framework.Api.Client.Tests`, `Codout.Framework.Api.Tests` e +`Codout.Multitenancy.Tests`. Nenhum código de produção foi alterado; os +comportamentos abaixo estão cobertos por testes de caracterização (marcados +com `// BUG?:` quando aplicável). + +## Codout.DynamicLinq + +- **`Codout.DynamicLinq/QueryableExtensions.cs`** + - O overload "simples" `ToDataSourceResult(take, skip, sort, filter)` repassa + `aggregates`/`group` nulos para o overload completo, e tanto `Aggregates()` + quanto o corpo principal fazem `xs as T[] ?? xs.ToArray()` — com argumento + nulo, `ToArray()` lança `ArgumentNullException`. O mesmo vale para `sort` + nulo (em `Sort()`) quando não há grupos. Consequência: o overload de 4 + parâmetros e o overload `ToDataSourceResult(DataSourceRequest)` com um + request recém-criado (propriedades nulas) **sempre lançam exceção**. Para + funcionar, o chamador precisa passar coleções vazias. + Coberto em `ToDataSourceResultSortPageTests.OverloadSemAggregatesEGroups_LancaArgumentNullException`, + `OverloadComDataSourceRequestPadrao_LancaArgumentNullException` e + `SortNulo_SemGrupos_LancaArgumentNullException`. + - Um `Filter` simples (sem `Logic`) é **ignorado silenciosamente**: `Filters()` + só aplica o filtro quando `filter.Logic != null`, então todo filtro de uma + condição precisa ser embrulhado em um composto (`Logic = "and"`). Coberto em + `ToDataSourceResultFilterTests.Filtro_SemLogic_EhIgnoradoSilenciosamente`. + +- **`Codout.DynamicLinq/EnumerableExtensions.cs`** + - `GroupByMany` repassa `Group.Aggregates` (nulo por padrão) para + `QueryableExtensions.Aggregates`, que lança `ArgumentNullException` na + enumeração dos grupos (a avaliação é deferida — a exceção só aparece quando + `result.Groups` é enumerado). Todo `Group` precisa de `Aggregates = []`. + Coberto em `AggregatesAndGroupTests.Group_SemAggregates_LancaAoEnumerarOsGrupos`. + +## Codout.Framework.Api.Dto + +- Os tipos do pacote (via shared project `Codout.Framework.Dto.Shared`) são + declarados no namespace **`Codout.Framework.Api.Client`**, e não em + `Codout.Framework.Api.Dto` — observação de contrato, documentada em + `DtoContractTests.TiposDoPacote_EstaoNoNamespaceCodoutFrameworkApiClient`. + +## Codout.Framework.Application + +- **`Codout.Framework.Application/CrudAppServiceBase.cs`** + - `SaveAsync(null)` lança `NullReferenceException` (em vez de + `ArgumentNullException`) e a mensagem usa `nameof(TDto)`, que vira o texto + literal `"TDto"` em vez do nome real do DTO. Coberto em + `CrudAppServiceBaseTests.SaveAsync_ComEntradaNula_LancaNullReferenceException`. + - `GetAllAsync(new DataSourceRequest())` lança `ArgumentNullException` (mesma + causa raiz do bug de DynamicLinq acima): um consumidor que poste um request + "vazio" na API recebe erro. Coberto em + `CrudAppServiceBaseTests.GetAllAsync_ComRequestPadrao_LancaArgumentNullException`. + - `UpdateAsync` nunca chama `Repository.Update/SaveOrUpdate` — depende de a + entidade estar rastreada pelo ORM e do `UnitOfWork.Commit()`. Funciona com + EF/NH, mas não com repositórios sem change tracking (observação, + caracterizada em `UpdateAsync_NaoChamaUpdateDoRepositorio`). + +- **`Codout.Framework.Application/MapperProfile.cs`** + - O `ReverseMap()` de `CreateMap(typeof(Entity<>), typeof(EntityDto<>))` não + consegue materializar entidades concretas: ao mapear `EntityDto` → + `Customer`, o AutoMapper tenta instanciar a classe **abstrata** + `Entity` e lança `ArgumentException`. Na prática, + `Mapper.Map(input)` — usado por `CrudAppServiceBase.SaveAsync` — + falha se o consumidor não registrar um mapa próprio DTO→Entidade. Coberto em + `MappingProfileTests.MappingProfile_MapaReverso_NaoConsegueCriarEntityConcreta`. + +## Codout.Framework.Api.Client + +- **`Codout.Framework.Api.Client/Extensions/HttpClientExtensions.cs`** + - `GetExceptionAsync` lança `new ApiClientException(...)` **dentro do próprio + bloco try**; o `catch` genérico logo abaixo a engole e relança + `new Exception(corpo)`. Resultado: o `ApiClientException` tipado (com o + `ApiException` estruturado) **nunca chega ao chamador** — toda resposta de + erro vira `Exception` com o corpo bruto como mensagem. Coberto em + `HttpClientExtensionsTests.RespostaDeErro_ComCorpoDeApiException_LancaExceptionGenericaComOCorpo`. + +- **`Codout.Framework.Api.Client/RestApiClient.cs`** + - Em `DeleteAsync`, a chamada `Client.DeleteAsync(...)` resolve para o método + de **instância** `HttpClient.DeleteAsync` (métodos de instância têm + precedência sobre extensões), e não para a extensão + `HttpClientExtensions.DeleteAsync` que valida o status. Consequência: + **erros HTTP no DELETE são silenciosamente ignorados** (nenhuma exceção é + lançada para 4xx/5xx). Coberto em + `RestApiClientTests.DeleteAsync_ComRespostaDeErro_NaoLancaExcecao`. + +- **`Codout.Framework.Api.Shared/ApiException.cs`** — `ApiException` é um POCO + (não herda de `System.Exception`), portanto não pode ser lançada diretamente; + apenas serializada/embrulhada (observação, caracterizada em + `ApiExceptionTests.ApiException_NaoEhUmaException`). + +- **`Codout.Framework.Api.Client/ApiClientBase.cs`** — o `HttpClient` é criado + internamente, sem ponto de injeção de `HttpMessageHandler`; os testes de + `RestApiClient` substituem o handler via reflection (campo privado + `HttpMessageInvoker._handler`) para evitar rede (observação de testabilidade). + +## Codout.Framework.Api + +- **`Codout.Framework.Api/Middleware/ApiExceptionMiddleware.cs`** + - Ao capturar uma exceção, o middleware **não altera `Response.StatusCode`**: + serializa o status vigente (200) no corpo e responde `200 OK` para qualquer + exceção não tratada, em vez de 500. Coberto em + `ApiExceptionMiddlewareTests.QuandoHaExcecao_StatusCodePermanece200`. + +- **`Codout.Framework.Api/RestApiEntityBase.cs`** + - `Get` retorna `200 OK` com corpo nulo quando a entidade não existe, embora o + contrato anunciado via `ProducesResponseType` inclua `404 NotFound`. Coberto + em `RestApiEntityBaseTests.Get_QuandoNaoEncontrado_RetornaOkComCorpoNulo`. + +## Codout.Multitenancy + +- **`Codout.Multitenancy/MemoryCacheTenantResolver.cs`** + - O cache é **consultado** com a chave de `GetContextIdentifier(httpContext)`, + mas **gravado** com a chave de `GetTenantIdentifier(tenantContext)`. Se as + duas convenções diferirem (ex.: busca por host, gravação por chave do + tenant), a consulta nunca encontra a entrada gravada e o tenant é + re-resolvido a cada request (cache inócuo + entradas órfãs). Só funciona + quando as implementações retornam o mesmo valor. Coberto em + `MemoryCacheTenantResolverTests.ResolveAsync_QuandoChaveDeBuscaDifereDaChaveDeGravacao_NuncaUsaOCache`. + +- **`Codout.Multitenancy/Internal/TenantPipelineMiddleware.cs`** + - Quando não há `TenantContext` no request, o middleware **não chama `_next`**: + o restante do pipeline é curto-circuitado silenciosamente (resposta vazia + 200). Coberto em + `TenantPipelineMiddlewareTests.UsePerTenant_SemTenant_CurtoCircuitaSemExecutarORoot`. + +- Observação: a pasta `Codout.Multitenancy` contém dois csproj que compilam os + mesmos fontes (`Codout.Multitenancy.csproj` net10.0 e + `Softprime.Multitenancy.csproj` netstandard2.0); a suíte referencia o + `Codout.Multitenancy.csproj`. A chave usada em `HttpContext.Items` ainda é + `"softprime.TenantContext"` (resquício do nome antigo). diff --git a/tests/FINDINGS.md b/tests/FINDINGS.md new file mode 100644 index 0000000..2b7ec6e --- /dev/null +++ b/tests/FINDINGS.md @@ -0,0 +1,49 @@ +# FINDINGS — Bugs pré-existentes encontrados pela Fase 4 (triagem) + +Durante a escrita das suítes de teste (2026-06-12), os bugs abaixo foram +encontrados no código de produção. **Nenhum foi corrigido**: a política da +fase foi caracterizar o comportamento atual (testes marcados com `// BUG?:`) +para não alterar contrato sem decisão explícita. Detalhes completos nos +arquivos `FINDINGS-A.md` a `FINDINGS-E.md` nesta pasta. + +## Alta severidade (corrigir primeiro; provável bump patch/minor) + +| # | Pacote | Bug | Detalhe | +|---|--------|-----|---------| +| 1 | Security.Argon2 | `NeedsRehash` compara com parâmetros errados — todo hash criado via `Strength` se reporta `SuccessRehashNeeded` para sempre (rehash a cada login) | FINDINGS-A | +| 2 | Mongo | `WherePaged` calcula `total` depois de Skip/Take — retorna o tamanho da página, não o total do filtro | FINDINGS-D | +| 3 | Mongo | `MongoRepository` não participa da transação do `MongoUnitOfWork` — `Rollback` não desfaz escritas | FINDINGS-D | +| 4 | EF | Interceptor de auditoria declara cópias locais de `IAuditable`/`ICurrentUserProvider` que sombreiam as de `Data.Auditing` — entidades auditáveis são ignoradas silenciosamente | FINDINGS-B | +| 5 | Api | `ApiExceptionMiddleware` nunca seta `Response.StatusCode` — exceções respondem `200 OK` | FINDINGS-E | +| 6 | NH | `AllReadOnly`/`WhereReadOnly` setam `Session.DefaultReadOnly=true` permanentemente — escritas posteriores na mesma sessão são ignoradas no Flush | FINDINGS-D | +| 7 | Multitenancy | `MemoryCacheTenantResolver` lê o cache pela chave de contexto e grava pela de tenant — se diferirem, o cache nunca acerta | FINDINGS-E | + +## Média severidade + +| # | Pacote | Bug | Detalhe | +|---|--------|-----|---------| +| 8 | Mailer | `MailerServiceBase` loga `model.To.Address` antes do try — NRE escapa em vez de `MailerResponse{Sent=false}` | FINDINGS-C | +| 9 | DynamicLinq | Overloads de `ToDataSourceResult` sem `aggregates`/`group` lançam `ArgumentNullException`; filtro sem `Logic` é ignorado | FINDINGS-E | +| 10 | Application | `UpdateAsync` nunca chama `Repository.Update`; `SaveAsync(null)` lança NRE com mensagem `"TDto"` | FINDINGS-E | +| 11 | Api.Client | Erros HTTP de DELETE silenciosamente ignorados (binding ao método de instância do HttpClient); `ApiClientException` engolida pelo próprio catch | FINDINGS-E | +| 12 | Mongo | `Get`/`GetAsync`/`Load` com chave não-ObjectId retornam null sem consultar; sync usa `SingleOrDefault`, async usa `FirstOrDefault` | FINDINGS-D | +| 13 | Storage.Azure | URI de CDN não faz URL-encoding do fileName; `AddAzureStorage` instancia eagerly | FINDINGS-C | +| 14 | EF | `RefreshAsync(entity, ct)` executa `Reload()` síncrono e ignora o token; `Commit()` × `CommitAsync()` assimétricos sem transação | FINDINGS-B | +| 15 | Common | `RemoveAccents` quebrado no .NET moderno (codepage não registrada); `GetAge` erra no dia do aniversário; `IsEmail("")` retorna true | FINDINGS-A | + +## Baixa severidade / observações de design + +- `MailerHealthCheck` sempre Healthy, campo `_dispatcher` morto (FINDINGS-C). +- `SendGridSettings.StandBox` (typo) sem uso; `EnableCache` do Razor sem efeito (FINDINGS-C). +- `Api.Dto` vive no namespace `Codout.Framework.Api.Client` (FINDINGS-E). +- `TenantPipelineMiddleware` sem tenant não chama `_next` (FINDINGS-E). +- `FileSystemStorageOptions`/`AwsStorageOptions` existem sem implementação (FINDINGS-C). +- `Get(predicate)` do Mongo: divergência sync/async de semântica (FINDINGS-D). +- `Chop`, `HtmlEncode` (duplo escape), `EnumHelper.GetLocalizedName` lendo `Description` (FINDINGS-A). + +## Como corrigir com segurança + +Cada correção deve: (1) ajustar o characterization test correspondente para o +comportamento correto, (2) passar no package validation (baseline NuGet), e +(3) entrar no CHANGELOG do pacote com bump apropriado (a maioria é patch; +itens que mudam contrato observável, como #2 e #5, merecem minor + nota).