From df5988b7a9c0701b18c2b9b2aec9365a83d70e55 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 29 Apr 2026 13:29:06 +0700 Subject: [PATCH] [CLAUDE] Tests Phase 2: Code generator format + sequence tests (SQLite in-memory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 — chống regression code generator. 17 test mới integration với DB thật (SQLite in-memory) tổng cộng 71 test pass < 3 giây. Test project: - tests/SolutionErp.Infrastructure.Tests/ (xUnit + FluentAssertions + EF SQLite 10) - ProjectReference SolutionErp.Infrastructure (transitively get Application + Domain) - Added vào SolutionErp.slnx Test fixtures: - Common/SqliteDbFixture.cs: - SQLite ":memory:" + shared connection + EnsureCreated() từ DbContext model - TestApplicationDbContext subclass — override OnModelCreating replace 'nvarchar(max)' → 'TEXT' (SQLite không support max keyword) - FixedDateTime stub IDateTime cho deterministic year boundary test Test files: - Services/ContractCodeGeneratorTests.cs (10 test): - Format per ContractType (5 type × Project scope) — RG-001 spec - Framework HĐ (NguyenTacNCC + NguyenTacDV) → year scope thay vì project - Sequence increment per prefix (3 calls → /01, /02, /03) - Different prefixes (project / supplier) → independent sequences - Year change (2026 → 2027) → reset sequence vì prefix khác - PersistsSequenceRow LastSeq verification - Services/PurchaseEvaluationCodeGeneratorTests.cs (7 test): - Format A/B (DuyetNcc → 'A', DuyetNccPhuongAn → 'B') - Seq là 3-digit padded (001..012) - Type A và B sequence độc lập trong cùng năm - Year boundary reset cả A và B CI gate update (.gitea/workflows/deploy.yml): - Step "Run integration tests (Infrastructure)" thêm sau Domain tests - TRX log saved riêng (infra-tests.trx) - Cả 2 step đều exit non-zero → no deploy Verify local: - dotnet test SolutionErp.slnx → Total tests: 71 (54 Domain + 17 Infra) / Passed: 71 / 2.1s - dotnet build SolutionErp.slnx → 0 error Phase 3+ pending: - Application handler tests (CQRS) với EF InMemory hoặc SQLite (~1 ngày) - API smoke tests qua WebApplicationFactory (~0.5 ngày) - FE Vitest cho lib utility (~0.5 ngày) Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/deploy.yml | 16 +- SolutionErp.slnx | 1 + .../Common/SqliteDbFixture.cs | 75 +++++++++ .../Services/ContractCodeGeneratorTests.cs | 144 ++++++++++++++++++ .../PurchaseEvaluationCodeGeneratorTests.cs | 118 ++++++++++++++ .../SolutionErp.Infrastructure.Tests.csproj | 31 ++++ 6 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Common/SqliteDbFixture.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/ContractCodeGeneratorTests.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationCodeGeneratorTests.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/SolutionErp.Infrastructure.Tests.csproj diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b080165..9bc2882 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -30,10 +30,9 @@ jobs: & 'C:\Program Files\nodejs\npm.cmd' --version # ============== TEST GATE ============== - # Run unit tests TRƯỚC build/publish/deploy. Test fail → exit non-zero - # → toàn bộ job stop, KHÔNG deploy. Phase 1 chỉ Domain layer (~54 test - # phase machine + policy registry). Mở rộng Application/Infra/Api - # khi có nhu cầu (xem docs/changelog/migration-todos.md). + # Run tests TRƯỚC build/publish/deploy. Fail → exit non-zero → no deploy. + # Phase 1: Domain (54 test policy state machine). + # Phase 2: Infrastructure (17 test code generators format/sequence/year scope). - name: Run unit tests (Domain) shell: powershell run: | @@ -43,6 +42,15 @@ jobs: --results-directory test-results if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Run integration tests (Infrastructure - SQLite in-memory) + shell: powershell + run: | + & 'C:\Program Files\dotnet\dotnet.exe' test tests/SolutionErp.Infrastructure.Tests/SolutionErp.Infrastructure.Tests.csproj ` + --configuration Release ` + --logger "trx;LogFileName=infra-tests.trx" ` + --results-directory test-results + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Upload test results if: always() continue-on-error: true # nếu Gitea runner chưa có upload-artifact action, skip không block deploy diff --git a/SolutionErp.slnx b/SolutionErp.slnx index d616e0c..21b888a 100644 --- a/SolutionErp.slnx +++ b/SolutionErp.slnx @@ -8,5 +8,6 @@ + diff --git a/tests/SolutionErp.Infrastructure.Tests/Common/SqliteDbFixture.cs b/tests/SolutionErp.Infrastructure.Tests/Common/SqliteDbFixture.cs new file mode 100644 index 0000000..28445ba --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Common/SqliteDbFixture.cs @@ -0,0 +1,75 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Infrastructure.Persistence; + +namespace SolutionErp.Infrastructure.Tests.Common; + +// Subclass cho test — override column types SQL Server-specific (`nvarchar(max)`) +// về TEXT vì SQLite không hỗ trợ. Identity table inherit từ IdentityDbContext +// dùng kiểu chuẩn nên không cần fix thêm. +public class TestApplicationDbContext(DbContextOptions options) + : ApplicationDbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Replace `nvarchar(max)` / `varchar(max)` → `TEXT` cho SQLite compat. + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + foreach (var prop in entity.GetProperties()) + { + var colType = prop.GetColumnType(); + if (colType is not null && colType.Contains("max", StringComparison.OrdinalIgnoreCase)) + { + prop.SetColumnType("TEXT"); + } + } + } + } +} + +// Fixture build SQLite in-memory ApplicationDbContext mỗi test isolated. +// Pattern: shared connection (open in fixture) + EnsureCreated() từ model. +// +// Tại sao SQLite chứ không InMemory provider? +// - InMemory không hỗ trợ transactions — code generator dùng IsolationLevel.Serializable +// - SQLite hỗ trợ transactions thật (BEGIN/COMMIT/ROLLBACK), tuy nhiên IsolationLevel +// enum value bị provider mapping gracefully (no exception, just default behavior) +// - Đủ để test format + sequential increment + scope reset, KHÔNG đủ cho test +// race condition thực (cần SQL Server thật) +public sealed class SqliteDbFixture : IDisposable +{ + private readonly SqliteConnection _connection; + public TestApplicationDbContext Db { get; } + + public SqliteDbFixture() + { + // ":memory:" + shared connection — DB tồn tại đến khi connection close. + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .EnableSensitiveDataLogging() + .Options; + + Db = new TestApplicationDbContext(options); + Db.Database.EnsureCreated(); + } + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +} + +// Stub IDateTime cho tests deterministic — control thời điểm Year boundary. +public sealed class FixedDateTime(DateTime utcNow) : IDateTime +{ + public DateTime UtcNow { get; set; } = utcNow; + public DateTime Now => UtcNow; // simplification — VPS chạy UTC, không bao giờ dùng local time trong tests +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/ContractCodeGeneratorTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/ContractCodeGeneratorTests.cs new file mode 100644 index 0000000..69ca29e --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/ContractCodeGeneratorTests.cs @@ -0,0 +1,144 @@ +using SolutionErp.Domain.Contracts; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Tests cho ContractCodeGenerator — format mã RG-001 + atomic sequence increment. +// Dùng SQLite in-memory cho transactions thật, KHÔNG test race condition +// (cần SQL Server thật cho SERIALIZABLE strict — đó là integration test riêng). + +public class ContractCodeGeneratorTests +{ + private static (ContractCodeGenerator gen, SqliteDbFixture fix, FixedDateTime dt) CreateGenerator(int year = 2026) + { + var fix = new SqliteDbFixture(); + var dt = new FixedDateTime(new DateTime(year, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var gen = new ContractCodeGenerator(fix.Db, dt); + return (gen, fix, dt); + } + + // ===== Format per ContractType (RG-001) ===== + + [Theory] + [InlineData(ContractType.HopDongThauPhu, "HĐTP")] + [InlineData(ContractType.HopDongGiaoKhoan, "HĐGK")] + [InlineData(ContractType.HopDongNhaCungCap, "NCC")] + [InlineData(ContractType.HopDongDichVu, "HĐDV")] + [InlineData(ContractType.HopDongMuaBan, "MB")] + public async Task Generate_ProjectScoped_FormatMatches_RG001(ContractType type, string expectedTypeCode) + { + var (gen, fix, _) = CreateGenerator(); + using (fix) + { + var contract = new Contract { Type = type }; + var code = await gen.GenerateAsync(contract, projectCode: "FLOCK01", supplierCode: "BTBM"); + // Expected format: {Project}/{TypeCode}/SOL&{Supplier}/{Seq} + code.Should().Be($"FLOCK01/{expectedTypeCode}/SOL&BTBM/01"); + } + } + + [Fact] + public async Task Generate_FrameworkContract_NCC_UsesYearScope_NotProjectCode() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var contract = new Contract { Type = ContractType.HopDongNguyenTacNCC }; + var code = await gen.GenerateAsync(contract, projectCode: "FLOCK01", supplierCode: "BTBM"); + // Framework HĐ → scope = year (không phải project) + code.Should().Be("2026/NCC/SOL&BTBM/01"); + } + } + + [Fact] + public async Task Generate_FrameworkContract_DichVu_UsesYearScope() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var contract = new Contract { Type = ContractType.HopDongNguyenTacDichVu }; + var code = await gen.GenerateAsync(contract, projectCode: "FLOCK01", supplierCode: "ABC"); + code.Should().Be("2026/HĐDV/SOL&ABC/01"); + } + } + + // ===== Sequence increment per prefix ===== + + [Fact] + public async Task Generate_SamePrefix_SequenceIncrements() + { + var (gen, fix, _) = CreateGenerator(); + using (fix) + { + var c = new Contract { Type = ContractType.HopDongThauPhu }; + var code1 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + var code2 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + var code3 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + + code1.Should().EndWith("/01"); + code2.Should().EndWith("/02"); + code3.Should().EndWith("/03"); + } + } + + [Fact] + public async Task Generate_DifferentPrefixes_IndependentSequences() + { + var (gen, fix, _) = CreateGenerator(); + using (fix) + { + var c = new Contract { Type = ContractType.HopDongThauPhu }; + var codeA = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + var codeB = await gen.GenerateAsync(c, "FLOCK02", "BTBM"); + var codeC = await gen.GenerateAsync(c, "FLOCK01", "OTHER"); + + // Mỗi prefix có sequence riêng, đều bắt đầu /01 + codeA.Should().Be("FLOCK01/HĐTP/SOL&BTBM/01"); + codeB.Should().Be("FLOCK02/HĐTP/SOL&BTBM/01"); + codeC.Should().Be("FLOCK01/HĐTP/SOL&OTHER/01"); + } + } + + // ===== Year boundary cho framework HĐ ===== + + [Fact] + public async Task Generate_Framework_YearChange_ResetsSequence() + { + var (gen, fix, dt) = CreateGenerator(year: 2026); + using (fix) + { + var c = new Contract { Type = ContractType.HopDongNguyenTacNCC }; + + // 2026: 2 phiếu + var code2026_1 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + var code2026_2 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + code2026_1.Should().Be("2026/NCC/SOL&BTBM/01"); + code2026_2.Should().Be("2026/NCC/SOL&BTBM/02"); + + // Sang 2027 → prefix mới (year đổi) → bắt đầu lại /01 + dt.UtcNow = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var code2027 = await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + code2027.Should().Be("2027/NCC/SOL&BTBM/01"); + } + } + + // ===== Persistence sequence row ===== + + [Fact] + public async Task Generate_PersistsSequenceRow_WithCorrectLastSeq() + { + var (gen, fix, _) = CreateGenerator(); + using (fix) + { + var c = new Contract { Type = ContractType.HopDongThauPhu }; + await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + await gen.GenerateAsync(c, "FLOCK01", "BTBM"); + + var seq = fix.Db.ContractCodeSequences.Single(s => s.Prefix == "FLOCK01/HĐTP/SOL&BTBM"); + seq.LastSeq.Should().Be(3); + seq.UpdatedAt.Should().NotBe(default); + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationCodeGeneratorTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationCodeGeneratorTests.cs new file mode 100644 index 0000000..ab10290 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationCodeGeneratorTests.cs @@ -0,0 +1,118 @@ +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Tests cho PurchaseEvaluationCodeGenerator — format `PE/{YYYY}/{TypeLetter}/{Seq:D3}`. +// Sequence per year × type: A và B độc lập, sang năm reset cả 2. + +public class PurchaseEvaluationCodeGeneratorTests +{ + private static (PurchaseEvaluationCodeGenerator gen, SqliteDbFixture fix, FixedDateTime dt) + CreateGenerator(int year = 2026) + { + var fix = new SqliteDbFixture(); + var dt = new FixedDateTime(new DateTime(year, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var gen = new PurchaseEvaluationCodeGenerator(fix.Db, dt); + return (gen, fix, dt); + } + + // ===== Format ===== + + [Theory] + [InlineData(PurchaseEvaluationType.DuyetNcc, "A")] + [InlineData(PurchaseEvaluationType.DuyetNccPhuongAn, "B")] + public async Task Generate_FirstPhieu_FormatMatches(PurchaseEvaluationType type, string expectedLetter) + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var pe = new PurchaseEvaluation { Type = type }; + var code = await gen.GenerateAsync(pe); + // Expected: PE/2026/A/001 hoặc PE/2026/B/001 + code.Should().Be($"PE/2026/{expectedLetter}/001"); + } + } + + [Fact] + public async Task Generate_SeqIs3DigitPadded() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var pe = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNcc }; + // Generate 12 phiếu — kiểm tra padding D3 + string? lastCode = null; + for (int i = 0; i < 12; i++) + lastCode = await gen.GenerateAsync(pe); + + lastCode.Should().Be("PE/2026/A/012"); + } + } + + // ===== A vs B independent sequences ===== + + [Fact] + public async Task Generate_TypeA_And_TypeB_HaveIndependentSequences() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var peA = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNcc }; + var peB = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNccPhuongAn }; + + var a1 = await gen.GenerateAsync(peA); + var b1 = await gen.GenerateAsync(peB); + var a2 = await gen.GenerateAsync(peA); + var b2 = await gen.GenerateAsync(peB); + + a1.Should().Be("PE/2026/A/001"); + a2.Should().Be("PE/2026/A/002"); + b1.Should().Be("PE/2026/B/001"); + b2.Should().Be("PE/2026/B/002"); + } + } + + // ===== Year boundary ===== + + [Fact] + public async Task Generate_YearChange_ResetsBothA_And_B_Sequences() + { + var (gen, fix, dt) = CreateGenerator(year: 2026); + using (fix) + { + var peA = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNcc }; + var peB = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNccPhuongAn }; + + await gen.GenerateAsync(peA); // PE/2026/A/001 + await gen.GenerateAsync(peA); // PE/2026/A/002 + await gen.GenerateAsync(peB); // PE/2026/B/001 + + dt.UtcNow = new DateTime(2027, 1, 5, 0, 0, 0, DateTimeKind.Utc); + + var a2027 = await gen.GenerateAsync(peA); + var b2027 = await gen.GenerateAsync(peB); + a2027.Should().Be("PE/2027/A/001"); + b2027.Should().Be("PE/2027/B/001"); + } + } + + // ===== Persistence row ===== + + [Fact] + public async Task Generate_PersistsSequenceRow_PrefixIsKey() + { + var (gen, fix, _) = CreateGenerator(year: 2026); + using (fix) + { + var peA = new PurchaseEvaluation { Type = PurchaseEvaluationType.DuyetNcc }; + await gen.GenerateAsync(peA); + await gen.GenerateAsync(peA); + await gen.GenerateAsync(peA); + + var seq = fix.Db.PurchaseEvaluationCodeSequences.Single(s => s.Prefix == "PE/2026/A"); + seq.LastSeq.Should().Be(3); + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/SolutionErp.Infrastructure.Tests.csproj b/tests/SolutionErp.Infrastructure.Tests/SolutionErp.Infrastructure.Tests.csproj new file mode 100644 index 0000000..11b0616 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/SolutionErp.Infrastructure.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + +