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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+